made installer and seperated stuff into diferent files

This commit is contained in:
2026-06-14 10:47:39 +02:00
parent 9045841645
commit 904607a045
22 changed files with 4766 additions and 3952 deletions

View File

@@ -1,380 +1,3 @@
.viewer-placeholder {
min-height: 100%;
display: grid;
place-items: center;
border: 1px dashed var(--bs-border-color);
border-radius: 8px;
color: var(--bs-secondary-color);
text-align: center;
}
.viewer-placeholder h1 {
color: var(--bs-body-color);
}
.task-list-view,
.project-view,
.version-view {
max-width: 1120px;
}
.task-list-header,
.version-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.task-list-header h1 {
margin: 0;
color: var(--bs-body-color);
font-size: 28px;
font-weight: 800;
}
.version-title {
display: block;
width: 100%;
margin: -4px -6px 0;
padding: 4px 6px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--bs-body-color);
font-size: 28px;
font-weight: 800;
}
.viewer-key {
margin-bottom: 4px;
color: var(--bs-secondary-color);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.section-title {
margin: 0 0 14px;
color: var(--bs-body-color);
font-size: 16px;
font-weight: 800;
}
.viewer-description {
min-height: 72px;
color: var(--bs-body-color);
line-height: 1.6;
white-space: pre-wrap;
}
.version-detail-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 340px;
gap: 32px;
align-items: start;
}
.version-panel {
margin-bottom: 28px;
}
.version-task-section {
max-width: 1080px;
}
.version-task-table {
overflow: hidden;
border: 1px solid var(--bs-border-color);
border-radius: 8px;
}
.version-task-row {
display: grid;
grid-template-columns:
minmax(92px, 0.7fr)
minmax(180px, 1.5fr)
minmax(120px, 0.9fr)
minmax(130px, 0.9fr)
minmax(150px, 1.1fr)
minmax(120px, 0.85fr);
gap: 14px;
align-items: center;
width: 100%;
}
.version-task-head {
border-bottom: 1px solid var(--bs-border-color);
background: var(--bs-secondary-bg);
}
.version-task-head button {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 7px;
min-height: 42px;
padding: 0 14px;
border: 0;
background: transparent;
color: var(--bs-secondary-color);
text-align: left;
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.version-task-head button:hover,
.version-task-head button:focus,
.version-task-head button.is-active {
color: var(--bs-body-color);
outline: 0;
}
.version-task-head button i {
width: 12px;
color: inherit;
font-size: 11px;
}
.version-task-item {
min-height: 50px;
padding: 8px 14px;
border: 0;
border-bottom: 1px solid var(--bs-border-color);
background: var(--bs-body-bg);
color: var(--bs-body-color);
text-align: left;
}
.version-task-item:last-child {
border-bottom: 0;
}
.version-task-item:hover,
.version-task-item:focus {
background: var(--bs-secondary-bg);
outline: 0;
}
.version-task-id {
color: var(--bs-secondary-color);
font-size: 13px;
font-weight: 800;
}
.version-task-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 700;
}
.version-task-loading {
padding: 14px;
color: var(--bs-secondary-color);
font-weight: 700;
}
.version-task-status {
display: inline-flex;
align-items: center;
width: fit-content;
max-width: 100%;
min-height: 26px;
padding: 3px 8px;
border: 1px solid color-mix(in srgb, var(--task-status-color, var(--color-accent)) 55%, var(--bs-border-color));
border-radius: 999px;
background: color-mix(in srgb, var(--task-status-color, var(--color-accent)) 16%, transparent);
color: var(--bs-body-color);
font-size: 12px;
font-weight: 800;
}
.version-task-pagination {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
min-height: 36px;
margin-top: 10px;
color: var(--bs-secondary-color);
font-size: 13px;
font-weight: 800;
}
.version-task-page-actions {
display: flex;
gap: 6px;
}
.version-task-page-actions .btn {
display: inline-grid;
width: 32px;
height: 30px;
place-items: center;
padding: 0;
}
.version-inline-field {
min-height: 34px;
padding: 5px 7px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
}
.version-title:hover,
.version-title:focus,
.version-inline-field:hover,
.version-inline-field:focus {
border-color: var(--bs-border-color);
background: var(--bs-secondary-bg);
outline: 0;
}
.version-title.is-readonly:hover,
.version-title.is-readonly:focus,
.version-inline-field.is-readonly:hover,
.version-inline-field.is-readonly:focus {
border-color: transparent;
background: transparent;
}
.version-inline-field.is-empty {
color: var(--bs-secondary-color);
font-style: italic;
}
.version-inline-field.is-saving {
opacity: 0.65;
pointer-events: none;
}
.version-inline-form {
display: grid;
gap: 8px;
}
.version-inline-actions {
display: flex;
gap: 8px;
}
.version-inline-actions .btn {
display: inline-grid;
width: 36px;
height: 32px;
place-items: center;
padding: 0;
}
.version-inline-input {
width: 100%;
}
.meta-list {
display: grid;
gap: 12px;
}
.meta-row {
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
gap: 12px;
align-items: center;
font-size: 14px;
}
.meta-label {
color: var(--bs-secondary-color);
font-weight: 700;
}
.meta-value {
min-width: 0;
color: var(--bs-body-color);
font-weight: 700;
}
.profile-view {
max-width: 860px;
}
.profile-header {
margin-bottom: 22px;
}
.profile-header h1 {
margin: 0;
color: var(--bs-body-color);
font-size: 28px;
font-weight: 800;
}
.profile-layout {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
gap: 32px;
align-items: start;
}
.profile-avatar-panel,
.profile-form-panel {
min-width: 0;
}
.profile-avatar-preview {
display: grid;
width: 132px;
height: 132px;
margin-bottom: 14px;
place-items: center;
overflow: hidden;
border: 1px solid var(--bs-border-color);
border-radius: 999px;
background: var(--bs-secondary-bg);
}
.profile-avatar-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.profile-avatar-fallback {
color: var(--bs-secondary-color);
font-size: 42px;
}
.profile-picture-button {
margin-bottom: 10px;
}
.profile-remove-picture {
display: flex;
align-items: center;
gap: 8px;
color: var(--bs-secondary-color);
font-size: 14px;
font-weight: 700;
}
.profile-actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 18px;
}
.profile-status {
color: var(--bs-secondary-color);
font-size: 14px;
font-weight: 700;
}
.admin-view {
max-width: 1120px;
}
@@ -812,24 +435,6 @@
}
@media (max-width: 700px) {
.task-list-header,
.version-header {
align-items: flex-start;
flex-direction: column;
}
.meta-row {
grid-template-columns: 1fr;
gap: 3px;
}
.version-detail-grid {
grid-template-columns: 1fr;
}
.profile-layout {
grid-template-columns: 1fr;
}
.admin-layout {
grid-template-columns: 1fr;
@@ -854,13 +459,4 @@
.admin-custom-field-item {
grid-template-columns: 1fr;
}
.version-task-table {
overflow-x: auto;
}
.version-task-row {
grid-template-columns: 100px 190px 130px 140px 170px 130px;
min-width: 870px;
}
}

View File

@@ -0,0 +1,13 @@
.viewer-placeholder {
min-height: 100%;
display: grid;
place-items: center;
border: 1px dashed var(--bs-border-color);
border-radius: 8px;
color: var(--bs-secondary-color);
text-align: center;
}
.viewer-placeholder h1 {
color: var(--bs-body-color);
}

View File

@@ -0,0 +1,81 @@
.profile-view {
max-width: 860px;
}
.profile-header {
margin-bottom: 22px;
}
.profile-header h1 {
margin: 0;
color: var(--bs-body-color);
font-size: 28px;
font-weight: 800;
}
.profile-layout {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
gap: 32px;
align-items: start;
}
.profile-avatar-panel,
.profile-form-panel {
min-width: 0;
}
.profile-avatar-preview {
display: grid;
width: 132px;
height: 132px;
margin-bottom: 14px;
place-items: center;
overflow: hidden;
border: 1px solid var(--bs-border-color);
border-radius: 999px;
background: var(--bs-secondary-bg);
}
.profile-avatar-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.profile-avatar-fallback {
color: var(--bs-secondary-color);
font-size: 42px;
}
.profile-picture-button {
margin-bottom: 10px;
}
.profile-remove-picture {
display: flex;
align-items: center;
gap: 8px;
color: var(--bs-secondary-color);
font-size: 14px;
font-weight: 700;
}
.profile-actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 18px;
}
.profile-status {
color: var(--bs-secondary-color);
font-size: 14px;
font-weight: 700;
}
@media (max-width: 700px) {
.profile-layout {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,3 @@
.project-view {
max-width: 1120px;
}

View File

@@ -0,0 +1,150 @@
.viewer-key {
margin-bottom: 4px;
color: var(--bs-secondary-color);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.section-title {
margin: 0 0 14px;
color: var(--bs-body-color);
font-size: 16px;
font-weight: 800;
}
.viewer-description {
min-height: 72px;
color: var(--bs-body-color);
line-height: 1.6;
white-space: pre-wrap;
}
.version-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.version-title {
display: block;
width: 100%;
margin: -4px -6px 0;
padding: 4px 6px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--bs-body-color);
font-size: 28px;
font-weight: 800;
}
.version-detail-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 340px;
gap: 32px;
align-items: start;
}
.version-panel {
margin-bottom: 28px;
}
.version-inline-field {
min-height: 34px;
padding: 5px 7px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
}
.version-title:hover,
.version-title:focus,
.version-inline-field:hover,
.version-inline-field:focus {
border-color: var(--bs-border-color);
background: var(--bs-secondary-bg);
outline: 0;
}
.version-title.is-readonly:hover,
.version-title.is-readonly:focus,
.version-inline-field.is-readonly:hover,
.version-inline-field.is-readonly:focus {
border-color: transparent;
background: transparent;
}
.version-inline-field.is-empty {
color: var(--bs-secondary-color);
font-style: italic;
}
.version-inline-field.is-saving {
opacity: 0.65;
pointer-events: none;
}
.version-inline-form {
display: grid;
gap: 8px;
}
.version-inline-actions {
display: flex;
gap: 8px;
}
.version-inline-actions .btn {
display: inline-grid;
width: 36px;
height: 32px;
place-items: center;
padding: 0;
}
.version-inline-input {
width: 100%;
}
.meta-list {
display: grid;
gap: 12px;
}
.meta-row {
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
gap: 12px;
align-items: center;
font-size: 14px;
}
.meta-label {
color: var(--bs-secondary-color);
font-weight: 700;
}
.meta-value {
min-width: 0;
color: var(--bs-body-color);
font-weight: 700;
}
@media (max-width: 700px) {
.version-header {
align-items: flex-start;
flex-direction: column;
}
.version-detail-grid {
grid-template-columns: 1fr;
}
.meta-row {
grid-template-columns: 1fr;
gap: 3px;
}
}

View File

@@ -0,0 +1,168 @@
.task-list-view {
max-width: 1120px;
}
.task-list-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.task-list-header h1 {
margin: 0;
color: var(--bs-body-color);
font-size: 28px;
font-weight: 800;
}
.version-task-table {
overflow: hidden;
border: 1px solid var(--bs-border-color);
border-radius: 8px;
}
.version-task-row {
display: grid;
grid-template-columns:
minmax(92px, 0.7fr)
minmax(180px, 1.5fr)
minmax(120px, 0.9fr)
minmax(130px, 0.9fr)
minmax(150px, 1.1fr)
minmax(120px, 0.85fr);
gap: 14px;
align-items: center;
width: 100%;
}
.version-task-head {
border-bottom: 1px solid var(--bs-border-color);
background: var(--bs-secondary-bg);
}
.version-task-head button {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 7px;
min-height: 42px;
padding: 0 14px;
border: 0;
background: transparent;
color: var(--bs-secondary-color);
text-align: left;
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.version-task-head button:hover,
.version-task-head button:focus,
.version-task-head button.is-active {
color: var(--bs-body-color);
outline: 0;
}
.version-task-head button i {
width: 12px;
color: inherit;
font-size: 11px;
}
.version-task-item {
min-height: 50px;
padding: 8px 14px;
border: 0;
border-bottom: 1px solid var(--bs-border-color);
background: var(--bs-body-bg);
color: var(--bs-body-color);
text-align: left;
}
.version-task-item:last-child {
border-bottom: 0;
}
.version-task-item:hover,
.version-task-item:focus {
background: var(--bs-secondary-bg);
outline: 0;
}
.version-task-id {
color: var(--bs-secondary-color);
font-size: 13px;
font-weight: 800;
}
.version-task-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 700;
}
.version-task-loading {
padding: 14px;
color: var(--bs-secondary-color);
font-weight: 700;
}
.version-task-status {
display: inline-flex;
align-items: center;
width: fit-content;
max-width: 100%;
min-height: 26px;
padding: 3px 8px;
border: 1px solid color-mix(in srgb, var(--task-status-color, var(--color-accent)) 55%, var(--bs-border-color));
border-radius: 999px;
background: color-mix(in srgb, var(--task-status-color, var(--color-accent)) 16%, transparent);
color: var(--bs-body-color);
font-size: 12px;
font-weight: 800;
}
.version-task-pagination {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
min-height: 36px;
margin-top: 10px;
color: var(--bs-secondary-color);
font-size: 13px;
font-weight: 800;
}
.version-task-page-actions {
display: flex;
gap: 6px;
}
.version-task-page-actions .btn {
display: inline-grid;
width: 32px;
height: 30px;
place-items: center;
padding: 0;
}
@media (max-width: 700px) {
.task-list-header {
align-items: flex-start;
flex-direction: column;
}
.version-task-table {
overflow-x: auto;
}
.version-task-row {
grid-template-columns: 100px 190px 130px 140px 170px 130px;
min-width: 870px;
}
}

View File

@@ -0,0 +1,7 @@
.version-view {
max-width: 1120px;
}
.version-task-section {
max-width: 1080px;
}

View File

@@ -1,12 +1,27 @@
<?php
$pageStyles[] = 'app/css/home.css';
$pageStyles[] = 'app/css/viewer.css';
$pageStyles[] = 'app/css/viewer/shared.css';
$pageStyles[] = 'app/css/viewer/dashboard.css';
$pageStyles[] = 'app/css/viewer/project.css';
$pageStyles[] = 'app/css/viewer/task_list.css';
$pageStyles[] = 'app/css/viewer/task.css';
$pageStyles[] = 'app/css/viewer/version.css';
$pageStyles[] = 'app/css/viewer/profile.css';
$pageStyles[] = 'app/css/viewer/admin.css';
$pageStyles[] = 'app/css/popups.css';
$pageScripts[] = 'https://unpkg.com/cytoscape@3.34.0/dist/cytoscape.min.js';
$pageScripts[] = 'app/js/home.js';
$pageScripts[] = 'app/js/home/core.js';
$pageScripts[] = 'app/js/home/dashboard.js';
$pageScripts[] = 'app/js/home/project.js';
$pageScripts[] = 'app/js/home/task_list.js';
$pageScripts[] = 'app/js/home/task.js';
$pageScripts[] = 'app/js/home/version.js';
$pageScripts[] = 'app/js/home/profile.js';
$pageScripts[] = 'app/js/home/admin.js';
$pageScripts[] = 'app/js/home/router.js';
$pageScripts[] = 'app/js/home/events.js';
$pageScripts[] = 'app/js/popups.js';
$user = requireLogin();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,745 @@
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();
}
}

View File

@@ -0,0 +1,524 @@
async function apiGet(path, params = {}) {
const url = new URL(path, window.location.origin);
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
url.searchParams.set(key, value);
}
});
const response = await fetch(url.toString(), {
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache'
}
});
return response.json();
}
async function apiPost(path, params = {}, body = {}) {
const url = new URL(path, window.location.origin);
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
url.searchParams.set(key, value);
}
});
const response = await fetch(url.toString(), {
method: 'POST',
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache'
},
body: JSON.stringify(body)
});
const text = await response.text();
try {
return JSON.parse(text);
} catch (error) {
console.error('API did not return JSON:', text);
return {
success: false,
error: 'API did not return JSON. Check console for PHP error.'
};
}
}
async function apiPostForm(path, params = {}, formData) {
const url = new URL(path, window.location.origin);
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
url.searchParams.set(key, value);
}
});
const response = await fetch(url.toString(), {
method: 'POST',
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache'
},
body: formData
});
const text = await response.text();
try {
return JSON.parse(text);
} catch (error) {
console.error('API did not return JSON:', text);
return {
success: false,
error: 'API did not return JSON. Check console for PHP error.'
};
}
}
let currentTask = null;
let currentVersion = null;
let currentProject = null;
let currentProfileUser = null;
let currentVersionTasks = [];
let currentProjectTasks = [];
let currentProjectTaskProject = null;
let currentTaskComments = [];
let workflowData = null;
let workflowGraph = null;
let selectedWorkflowTaskType = null;
let adminOptionsData = null;
let selectedAdminTaskType = null;
let selectedAdminUser = null;
let projectTreePromise = null;
const versionTaskSort = {
field: 'id',
direction: 'asc'
};
const projectTaskSort = {
field: 'id',
direction: 'asc'
};
const taskTablePageSize = 100;
let versionTaskPagination = null;
let projectTaskPagination = null;
const themeStyles = {
white: 'app/css/white_mode.css',
dark: 'app/css/dark_mode.css',
purple: 'app/css/purple_mode.css',
green: 'app/css/green_mode.css',
beige: 'app/css/beige_mode.css'
};
const customFieldTypes = [
{ id: 'string', name: 'String' },
{ id: 'int', name: 'Int' },
{ id: 'float', name: 'Float' },
{ id: 'date', name: 'Date' },
{ id: 'boolean', name: 'Boolean' },
{ id: 'text', name: 'Text' },
{ id: 'json', name: 'JSON' }
];
const taskLookupCache = {
types: null,
priorities: null,
users: null,
versionsByProject: new Map(),
projectsById: new Map(),
projectBlocksById: new Map()
};
function appFlag(name) {
return document.querySelector('.kiln-app')?.dataset[name] === '1';
}
function canEditTasks() {
return appFlag('canEditTasks');
}
function canEditVersions() {
return appFlag('canEditVersions');
}
function canEditProjects() {
return appFlag('canEditProjects');
}
function applyPermissionVisibility() {
const visibility = [
['taskEditButton', canEditTasks()],
['versionEditButton', canEditVersions()],
['projectEditButton', canEditProjects()]
];
visibility.forEach(([id, allowed]) => {
const element = document.getElementById(id);
if (element) element.hidden = !allowed;
});
[
['.task-editable', canEditTasks()],
['.version-editable', canEditVersions()],
['.project-editable', canEditProjects()]
].forEach(([selector, allowed]) => {
document.querySelectorAll(selector).forEach((element) => {
element.classList.toggle('is-readonly', !allowed);
if (allowed) {
element.setAttribute('tabindex', '0');
element.removeAttribute('aria-disabled');
return;
}
element.removeAttribute('title');
element.removeAttribute('tabindex');
element.setAttribute('aria-disabled', 'true');
});
});
}
function showView(viewId) {
document.querySelectorAll('#viewer > section').forEach((section) => {
section.hidden = true;
});
const view = document.getElementById(viewId);
if (!view) {
console.error(`Missing view: ${viewId}`);
return null;
}
view.hidden = false;
return view;
}
function setText(id, value) {
const element = document.getElementById(id);
if (element) {
element.textContent = value ?? '';
}
}
function setEditableText(id, displayValue, rawValue = displayValue, emptyText = '-') {
const element = document.getElementById(id);
if (!element) return;
const hasValue = displayValue !== null && displayValue !== undefined && displayValue !== '';
element.textContent = hasValue ? displayValue : emptyText;
element.dataset.currentValue = rawValue ?? '';
element.classList.remove('is-saving');
element.classList.toggle('is-empty', !hasValue);
}
function setEditableHtml(id, displayHtml, rawValue, emptyText = '-') {
const element = document.getElementById(id);
if (!element) return;
const hasValue = displayHtml !== null && displayHtml !== undefined && displayHtml !== '-';
element.innerHTML = hasValue ? displayHtml : emptyText;
element.dataset.currentValue = rawValue ?? '';
element.classList.remove('is-saving');
element.classList.toggle('is-empty', !hasValue || rawValue === '' || rawValue === null);
}
function setVersionEditableText(id, displayValue, rawValue = displayValue, emptyText = '-') {
const element = document.getElementById(id);
if (!element) return;
const hasValue = displayValue !== null && displayValue !== undefined && displayValue !== '';
element.textContent = hasValue ? displayValue : emptyText;
element.dataset.currentValue = rawValue ?? '';
element.classList.remove('is-saving');
element.classList.toggle('is-empty', !hasValue);
}
function renderMetaBadge(item) {
if (!item) {
return '-';
}
const icon = item.icon
? `<img src="${item.icon}" alt="" class="meta-icon">`
: '';
return `
<span class="meta-badge">
${icon}
<span>${escapeHtml(item.name)}</span>
</span>
`;
}
async function getTaskTypes() {
if (taskLookupCache.types) {
return taskLookupCache.types;
}
const result = await apiGet('/api/task.php', {
api: 'ListTypes'
});
taskLookupCache.types = result.success ? result.types : [];
return taskLookupCache.types;
}
async function getTaskPriorities() {
if (taskLookupCache.priorities) {
return taskLookupCache.priorities;
}
const result = await apiGet('/api/task.php', {
api: 'ListPriorities'
});
taskLookupCache.priorities = result.success ? result.priorities : [];
return taskLookupCache.priorities;
}
async function getUsers() {
if (taskLookupCache.users) {
return taskLookupCache.users;
}
const result = await apiGet('/api/user.php', {
api: 'ListUsers'
});
taskLookupCache.users = result.success ? result.users : [];
return taskLookupCache.users;
}
async function getProjectVersions(projectId) {
if (taskLookupCache.versionsByProject.has(projectId)) {
return taskLookupCache.versionsByProject.get(projectId);
}
const result = await apiGet('/api/version.php', {
api: 'ListVersions',
project_id: projectId
});
if (!result.success) {
taskLookupCache.versionsByProject.set(projectId, []);
return [];
}
const versions = [];
for (const versionId of result.versions) {
const versionInfo = await apiGet('/api/version.php', {
api: 'VersionInfo',
version_id: versionId
});
if (versionInfo.success && versionInfo.version) {
versions.push(versionInfo.version);
}
}
taskLookupCache.versionsByProject.set(projectId, versions);
return versions;
}
async function populateTaskOptionSelects() {
const [types, priorities] = await Promise.all([
getTaskTypes(),
getTaskPriorities()
]);
populateSelect(document.getElementById('createTaskType'), types, {
placeholder: 'Select type'
});
populateSelect(document.getElementById('createTaskPriority'), priorities, {
placeholder: 'Select priority'
});
}
async function populateTaskProjectSelect(selectedProjectId = '') {
const result = await apiGet('/api/project.php', {
api: 'ListProjects'
});
const projects = [];
if (result.success) {
for (const projectId of result.projects) {
const project = await getProjectInfo(projectId);
projects.push({
id: projectId,
name: project?.name ?? projectId
});
}
}
populateSelect(document.getElementById('taskFormProjectId'), projects, {
placeholder: 'Select project',
selectedValue: selectedProjectId
});
}
async function populateTaskRelationSelects(projectId = '', selected = {}) {
const users = await getUsers();
populateSelect(document.getElementById('taskFormAssignee'), users, {
placeholder: 'Unassigned',
selectedValue: selected.assignee ?? ''
});
const versions = projectId ? await getProjectVersions(projectId.toUpperCase()) : [];
populateSelect(document.getElementById('taskFormFixVersion'), versions, {
placeholder: 'No fix version',
selectedValue: selected.fix_version ?? ''
});
}
function resetTaskPopup() {
const form = document.getElementById('createTaskForm');
if (!form) return;
form.reset();
form.dataset.mode = 'create';
document.getElementById('taskPopupTitle').textContent = 'Create Task';
document.getElementById('taskPopupSubmit').textContent = 'Create Task';
document.getElementById('taskFormTaskId').value = '';
document.getElementById('taskFormProjectId').disabled = false;
}
function resetVersionPopup() {
const form = document.getElementById('createVersionForm');
if (!form) return;
form.reset();
form.dataset.mode = 'create';
document.getElementById('versionPopupTitle').textContent = 'Create Version';
document.getElementById('versionPopupSubmit').textContent = 'Create Version';
document.getElementById('versionFormVersionId').value = '';
document.getElementById('versionFormProjectId').value = '';
}
function populateSelect(select, options, settings = {}) {
if (!select) return;
const placeholder = settings.placeholder ?? 'Select value';
const includeEmpty = settings.includeEmpty ?? true;
const selectedValue = settings.selectedValue ?? '';
select.innerHTML = '';
if (includeEmpty) {
const option = document.createElement('option');
option.value = '';
option.textContent = placeholder;
select.appendChild(option);
}
options.forEach((item) => {
const option = document.createElement('option');
option.value = item.id;
option.textContent = item.name ?? item.email ?? item.id;
option.selected = String(option.value) === String(selectedValue);
select.appendChild(option);
});
}
async function getUserInfo(userId) {
if (!userId || userId === '-') {
return null;
}
const result = await apiGet('/api/user.php', {
api: 'UserInfo',
user_id: userId
});
return result.success ? result.user : null;
}
function renderUser(user) {
if (!user) {
return '-';
}
const avatar = user.picture
? `<img src="${user.picture}" alt="" class="user-avatar">`
: `
<span class="user-avatar user-avatar-fallback">
<i class="fa-solid fa-user"></i>
</span>
`;
return `
<span class="meta-user">
${avatar}
<span>${escapeHtml(user.name)}</span>
</span>
`;
}
function getCurrentUserId() {
return document.querySelector('.kiln-app')?.dataset.currentUserId ?? '';
}
function updateAccountProfile(user) {
setText('accountName', user.name ?? '');
setText('accountEmail', user.email ?? '');
const accountButton = document.querySelector('.kiln-account-btn');
const accountName = document.getElementById('accountName');
if (!accountButton || !accountName) return;
document.getElementById('accountAvatarImage')?.remove();
document.getElementById('accountAvatarFallback')?.remove();
const avatar = document.createElement(user.picture ? 'img' : 'span');
avatar.id = user.picture ? 'accountAvatarImage' : 'accountAvatarFallback';
avatar.className = user.picture ? 'user-avatar' : 'user-avatar user-avatar-fallback';
if (user.picture) {
avatar.src = user.picture;
avatar.alt = '';
} else {
avatar.innerHTML = '<i class="fa-solid fa-user"></i>';
}
accountButton.insertBefore(avatar, accountName);
}
function applyTheme(theme) {
const stylesheet = document.getElementById('themeStylesheet');
if (!stylesheet || !themeStyles[theme]) return;
stylesheet.href = themeStyles[theme];
stylesheet.dataset.theme = theme;
}
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}

View File

@@ -0,0 +1,15 @@
function loadDashboard(pushHistory = true) {
showView('dashboardView');
if (!pushHistory) return;
const url = new URL(window.location.href);
url.searchParams.set('page', 'home');
url.searchParams.delete('task');
url.searchParams.delete('project');
url.searchParams.delete('tasks');
url.searchParams.delete('version');
url.searchParams.delete('profile');
url.searchParams.delete('admin');
window.history.pushState({}, '', url);
}

View File

@@ -0,0 +1,623 @@
document.addEventListener('DOMContentLoaded', () => {
projectTreePromise = loadProjectTree();
populateTaskOptionSelects();
applyPermissionVisibility();
initTaskInlineEditing();
initVersionInlineEditing();
initProjectInlineEditing();
loadRouteFromUrl();
window.addEventListener('popstate', () => {
loadRouteFromUrl();
});
document.querySelectorAll('[data-view="dashboard"]').forEach((button) => {
button.addEventListener('click', () => {
loadDashboard();
});
});
document.getElementById('adminMenuButton')?.addEventListener('click', () => {
loadAdmin('workflows');
});
document.querySelectorAll('[data-admin-section]').forEach((button) => {
button.addEventListener('click', () => {
loadAdmin(button.dataset.adminSection);
});
});
document.getElementById('workflowRefreshButton')?.addEventListener('click', () => {
loadWorkflowEditor();
});
document.getElementById('workflowTaskTypeSelect')?.addEventListener('change', (event) => {
selectedWorkflowTaskType = event.target.value || null;
renderWorkflowEditor();
});
document.getElementById('workflowDefaultStateSelect')?.addEventListener('change', (event) => {
if (!selectedWorkflowTaskType) return;
saveWorkflowAction('SetDefaultState', {
task_type: selectedWorkflowTaskType,
state: event.target.value
});
});
document.getElementById('workflowStateForm')?.addEventListener('submit', async (event) => {
event.preventDefault();
const form = event.currentTarget;
const data = Object.fromEntries(new FormData(form));
const saved = await saveWorkflowAction('CreateState', data);
if (saved) form.reset();
});
document.getElementById('workflowTransitionForm')?.addEventListener('submit', async (event) => {
event.preventDefault();
const form = event.currentTarget;
const data = Object.fromEntries(new FormData(form));
const saved = await saveWorkflowAction('CreateTransition', data);
if (saved) form.reset();
});
document.getElementById('workflowAssignmentForm')?.addEventListener('submit', async (event) => {
event.preventDefault();
const form = event.currentTarget;
const data = Object.fromEntries(new FormData(form));
data.task_type = selectedWorkflowTaskType;
if (!data.task_type) {
alert('Select a task type first.');
return;
}
const saved = await saveWorkflowAction('AssignState', data);
if (saved) form.reset();
});
document.getElementById('adminWorkflowsPanel')?.addEventListener('click', (event) => {
const stateButton = event.target.closest('[data-workflow-delete-state]');
const transitionButton = event.target.closest('[data-workflow-delete-transition]');
const assignmentButton = event.target.closest('[data-workflow-remove-assignment]');
if (stateButton) {
if (!window.confirm('Delete this state and related transitions/assignments?')) return;
saveWorkflowAction('DeleteState', {}, {
state_id: stateButton.dataset.workflowDeleteState
});
}
if (transitionButton) {
saveWorkflowAction('DeleteTransition', {}, {
transition_id: transitionButton.dataset.workflowDeleteTransition
});
}
if (assignmentButton) {
saveWorkflowAction('RemoveAssignedState', {}, {
assignment_id: assignmentButton.dataset.workflowRemoveAssignment
});
}
});
document.querySelectorAll('[data-admin-option-form]').forEach((form) => {
form.addEventListener('submit', async (event) => {
event.preventDefault();
const saved = await saveAdminOptionForm(form.dataset.adminOptionForm, form);
if (saved) form.reset();
});
});
document.getElementById('adminTaskTypeSelect')?.addEventListener('change', (event) => {
selectedAdminTaskType = event.target.value || null;
renderAdminOptions();
});
document.getElementById('adminUserSelect')?.addEventListener('change', (event) => {
selectedAdminUser = event.target.value || null;
renderAdminOptions();
});
document.getElementById('adminCustomFieldForm')?.addEventListener('submit', async (event) => {
event.preventDefault();
const saved = await saveAdminCustomFieldForm(event.currentTarget);
if (saved) event.currentTarget.reset();
});
document.getElementById('adminOptionsPanel')?.addEventListener('submit', async (event) => {
const form = event.target.closest('[data-admin-option-edit]');
if (!form) return;
event.preventDefault();
await saveAdminOptionForm(form.dataset.adminOptionEdit, form, form.dataset.optionId);
});
document.getElementById('adminOptionsPanel')?.addEventListener('submit', async (event) => {
const form = event.target.closest('[data-admin-custom-field-edit]');
if (!form) return;
event.preventDefault();
await saveAdminCustomFieldForm(form, form.dataset.adminCustomFieldEdit);
});
document.getElementById('adminOptionsPanel')?.addEventListener('click', (event) => {
const deleteButton = event.target.closest('[data-admin-option-delete]');
if (!deleteButton) return;
if (!window.confirm('Delete this option?')) return;
deleteAdminOption(deleteButton.dataset.adminOptionDelete, deleteButton.dataset.optionId);
});
document.getElementById('adminOptionsPanel')?.addEventListener('click', (event) => {
const deleteButton = event.target.closest('[data-admin-custom-field-delete]');
if (!deleteButton) return;
if (!window.confirm('Delete this custom field?')) return;
deleteAdminCustomField(deleteButton.dataset.adminCustomFieldDelete);
});
document.getElementById('adminUsersPanel')?.addEventListener('change', async (event) => {
const checkbox = event.target.closest('[data-admin-user-right]');
if (!checkbox) return;
checkbox.disabled = true;
const saved = await setAdminUserRight(
checkbox.dataset.userId,
checkbox.dataset.rightId,
checkbox.checked
);
if (!saved) {
checkbox.checked = !checkbox.checked;
checkbox.disabled = false;
}
});
document.getElementById('adminUsersPanel')?.addEventListener('submit', async (event) => {
const form = event.target.closest('#adminProjectAccessForm');
if (!form) return;
event.preventDefault();
if (!selectedAdminUser) return;
const data = Object.fromEntries(new FormData(form));
if (!data.project_id) return;
await grantAdminProjectAccess(selectedAdminUser, data.project_id);
});
document.getElementById('adminUsersPanel')?.addEventListener('click', async (event) => {
const deleteButton = event.target.closest('[data-admin-project-access-delete]');
if (!deleteButton) return;
await revokeAdminProjectAccess(deleteButton.dataset.adminProjectAccessDelete);
});
document.getElementById('profileMenuButton')?.addEventListener('click', () => {
loadProfile();
});
document.getElementById('taskEditButton')?.addEventListener('click', () => {
if (!canEditTasks()) return;
openTaskEditPopup();
});
document.getElementById('taskView')?.addEventListener('click', (event) => {
const customField = event.target.closest('[data-custom-field-id]');
if (customField) {
if (!canEditTasks()) return;
openCustomFieldInlineEditor(customField);
return;
}
const statusButton = event.target.closest('#taskStatusButton');
if (statusButton) {
const menu = document.getElementById('taskStatusMenu');
if (menu) menu.hidden = !menu.hidden;
return;
}
const transitionButton = event.target.closest('[data-task-transition]');
if (transitionButton) {
transitionCurrentTask(transitionButton.dataset.taskTransition);
}
});
document.getElementById('taskView')?.addEventListener('keydown', (event) => {
if (['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON'].includes(event.target.tagName)) return;
const customField = event.target.closest('[data-custom-field-id]');
if (!customField || !['Enter', ' '].includes(event.key)) return;
event.preventDefault();
if (!canEditTasks()) return;
openCustomFieldInlineEditor(customField);
});
document.getElementById('taskCommentButton')?.addEventListener('click', () => {
document.getElementById('taskCommentInput')?.focus();
});
document.getElementById('versionEditButton')?.addEventListener('click', () => {
if (!canEditVersions()) return;
openVersionEditPopup();
});
document.getElementById('projectEditButton')?.addEventListener('click', () => {
if (!canEditProjects()) return;
openProjectEditPopup();
});
document.querySelectorAll('[data-version-task-sort]').forEach((button) => {
button.addEventListener('click', () => {
const field = button.dataset.versionTaskSort;
if (versionTaskSort.field === field) {
versionTaskSort.direction = versionTaskSort.direction === 'asc' ? 'desc' : 'asc';
} else {
versionTaskSort.field = field;
versionTaskSort.direction = 'asc';
}
if (currentVersion) {
loadVersionTasks(currentVersion.id, 1);
}
});
});
document.querySelectorAll('[data-project-task-sort]').forEach((button) => {
button.addEventListener('click', () => {
const field = button.dataset.projectTaskSort;
if (projectTaskSort.field === field) {
projectTaskSort.direction = projectTaskSort.direction === 'asc' ? 'desc' : 'asc';
} else {
projectTaskSort.field = field;
projectTaskSort.direction = 'asc';
}
if (currentProjectTaskProject) {
loadProjectTasks(currentProjectTaskProject, 1);
}
});
});
document.addEventListener('click', (event) => {
const pageButton = event.target.closest('[data-task-page]');
if (!pageButton || pageButton.disabled) return;
const page = Number(pageButton.dataset.page);
if (!Number.isFinite(page) || page < 1) return;
if (pageButton.dataset.taskPage === 'version' && currentVersion) {
loadVersionTasks(currentVersion.id, page);
}
if (pageButton.dataset.taskPage === 'project' && currentProjectTaskProject) {
loadProjectTasks(currentProjectTaskProject, page);
}
});
const createProjectForm = document.getElementById('createProjectForm');
if (createProjectForm) {
createProjectForm.addEventListener('submit', async (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(createProjectForm));
const mode = createProjectForm.dataset.mode ?? 'create';
const projectId = document.getElementById('projectFormId').value;
const result = await apiPost('/api/project.php', {
api: mode === 'edit' ? 'Edit' : 'Create',
project_id: mode === 'edit' ? projectId : undefined
}, data);
if (result.success) {
taskLookupCache.projectsById.delete(result.project_id);
closePopups();
createProjectForm.reset();
projectTreePromise = loadProjectTree();
if (mode === 'edit') {
await loadProject(projectId, false);
}
} else {
alert(result.error || 'Could not create project.');
}
});
}
const createVersionForm = document.getElementById('createVersionForm');
if (createVersionForm) {
createVersionForm.addEventListener('submit', async (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(createVersionForm));
const mode = createVersionForm.dataset.mode ?? 'create';
const versionId = data.version_id;
const projectId = data.project_id;
delete data.version_id;
if (mode === 'edit') {
delete data.project_id;
}
const result = await apiPost('/api/version.php', {
api: mode === 'edit' ? 'Edit' : 'Create',
version_id: mode === 'edit' ? versionId : undefined
}, data);
if (result.success) {
taskLookupCache.versionsByProject.delete(projectId);
closePopups();
projectTreePromise = loadProjectTree();
if (mode === 'edit') {
await loadVersion(versionId, false);
}
} else {
alert(result.error || 'Could not save version.');
}
});
}
const profileForm = document.getElementById('profileForm');
if (profileForm) {
document.getElementById('profilePictureInput')?.addEventListener('change', (event) => {
const file = event.target.files?.[0];
if (!file) {
renderProfileAvatar(currentProfileUser?.picture ?? null);
return;
}
document.getElementById('profileRemovePicture').checked = false;
const reader = new FileReader();
reader.addEventListener('load', () => renderProfileAvatar(reader.result));
reader.readAsDataURL(file);
});
document.getElementById('profileRemovePicture')?.addEventListener('change', (event) => {
if (event.target.checked) {
document.getElementById('profilePictureInput').value = '';
renderProfileAvatar(null);
} else {
renderProfileAvatar(currentProfileUser?.picture ?? null);
}
});
profileForm.addEventListener('submit', async (event) => {
event.preventDefault();
const password = document.getElementById('profilePassword').value;
const passwordConfirm = document.getElementById('profilePasswordConfirm').value;
const status = document.getElementById('profileStatus');
const submitButton = document.getElementById('profileSubmitButton');
if (password !== passwordConfirm) {
status.textContent = 'Passwords do not match.';
return;
}
const data = new FormData(profileForm);
if (!password) {
data.delete('password');
}
submitButton.disabled = true;
status.textContent = 'Saving...';
const result = await apiPostForm('/api/user.php', {
api: 'Edit',
user_id: getCurrentUserId()
}, data);
submitButton.disabled = false;
if (!result.success) {
status.textContent = result.error || 'Could not save profile.';
return;
}
const userResult = await apiGet('/api/user.php', {
api: 'UserInfo',
user_id: getCurrentUserId(),
_: Date.now()
});
if (userResult.success) {
taskLookupCache.users = null;
updateAccountProfile(userResult.user);
applyTheme(userResult.user.settings?.theme ?? 'dark');
renderProfile(userResult.user);
status.textContent = 'Saved.';
} else {
status.textContent = 'Saved. Refresh to see all changes.';
}
});
}
const createTaskForm = document.getElementById('createTaskForm');
if (createTaskForm) {
document.getElementById('taskFormProjectId')?.addEventListener('change', (event) => {
populateTaskRelationSelects(event.target.value.trim());
});
createTaskForm.addEventListener('submit', async (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(createTaskForm));
const mode = createTaskForm.dataset.mode ?? 'create';
const taskId = data.task_id;
delete data.task_id;
if (mode === 'edit') {
delete data.project_id;
}
const result = await apiPost('/api/task.php', {
api: mode === 'edit' ? 'Edit' : 'Create',
task_id: mode === 'edit' ? taskId : undefined
}, data);
if (result.success) {
closePopups();
projectTreePromise = loadProjectTree();
loadTask(mode === 'edit' ? taskId : result.task_id, mode !== 'edit');
} else {
alert(result.error || 'Could not save task.');
}
});
}
const taskCommentForm = document.getElementById('taskCommentForm');
if (taskCommentForm) {
taskCommentForm.addEventListener('submit', async (event) => {
event.preventDefault();
const input = document.getElementById('taskCommentInput');
const status = document.getElementById('taskCommentStatus');
const comment = input.value.trim();
if (!comment) {
status.textContent = 'Comment cannot be empty.';
return;
}
status.textContent = 'Saving...';
try {
await submitTaskComment(comment);
input.value = '';
status.textContent = 'Saved.';
} catch (error) {
status.textContent = error.message;
}
});
}
document.getElementById('taskCommentList')?.addEventListener('click', (event) => {
const replyButton = event.target.closest('[data-comment-reply]');
const editButton = event.target.closest('[data-comment-edit]');
const deleteButton = event.target.closest('[data-comment-delete]');
const cancelButton = event.target.closest('[data-comment-reply-cancel]');
const editCancelButton = event.target.closest('[data-comment-edit-cancel]');
if (replyButton) {
const commentCard = replyButton.closest('.task-comment');
const slot = commentCard?.querySelector('.task-comment-reply-slot');
if (!slot || slot.querySelector('form')) return;
slot.innerHTML = `
<form class="task-comment-reply-form" data-comment-reply-form="${replyButton.dataset.commentReply}">
<textarea class="form-control" name="comment" rows="2" placeholder="Write a reply..."></textarea>
<div class="task-comment-reply-actions">
<button class="btn btn-sm btn-primary" type="submit">Reply</button>
<button class="btn btn-sm btn-outline-secondary" type="button" data-comment-reply-cancel>Cancel</button>
</div>
</form>
`;
slot.querySelector('textarea')?.focus();
}
if (editButton) {
const commentCard = editButton.closest('.task-comment');
const slot = commentCard?.querySelector('.task-comment-edit-slot');
if (!slot || slot.querySelector('form')) return;
slot.innerHTML = `
<form class="task-comment-edit-form" data-comment-edit-form="${editButton.dataset.commentEdit}">
<textarea class="form-control" name="comment" rows="3">${escapeHtml(commentCard.dataset.commentText ?? '')}</textarea>
<div class="task-comment-reply-actions">
<button class="btn btn-sm btn-primary" type="submit">Save</button>
<button class="btn btn-sm btn-outline-secondary" type="button" data-comment-edit-cancel>Cancel</button>
</div>
</form>
`;
slot.querySelector('textarea')?.focus();
}
if (deleteButton) {
if (!window.confirm('Delete this comment?')) return;
deleteTaskComment(deleteButton.dataset.commentDelete).catch((error) => {
alert(error.message);
});
}
if (cancelButton) {
cancelButton.closest('.task-comment-reply-slot').innerHTML = '';
}
if (editCancelButton) {
editCancelButton.closest('.task-comment-edit-slot').innerHTML = '';
}
});
document.getElementById('taskCommentList')?.addEventListener('submit', async (event) => {
const form = event.target.closest('[data-comment-reply-form], [data-comment-edit-form]');
if (!form) return;
event.preventDefault();
const textarea = form.querySelector('textarea');
const button = form.querySelector('button[type="submit"]');
const comment = textarea.value.trim();
const responseTo = form.dataset.commentReplyForm;
const editCommentId = form.dataset.commentEditForm;
if (!comment) return;
button.disabled = true;
try {
if (editCommentId) {
await updateTaskComment(editCommentId, comment);
} else {
await submitTaskComment(comment, responseTo);
}
} catch (error) {
alert(error.message);
button.disabled = false;
}
});
});

View File

@@ -0,0 +1,63 @@
async function loadProfile(pushHistory = true) {
const view = showView('profileView');
if (!view) return;
if (pushHistory) {
const url = new URL(window.location.href);
url.searchParams.set('page', 'home');
url.searchParams.set('profile', '1');
url.searchParams.delete('task');
url.searchParams.delete('project');
url.searchParams.delete('tasks');
url.searchParams.delete('version');
url.searchParams.delete('admin');
window.history.pushState({}, '', url);
}
const userId = getCurrentUserId();
const result = await apiGet('/api/user.php', {
api: 'UserInfo',
user_id: userId,
_: Date.now()
});
if (!result.success) {
view.innerHTML = '<div class="alert alert-danger">Could not load profile.</div>';
return;
}
renderProfile(result.user);
}
function renderProfile(user) {
currentProfileUser = user;
setText('profileStatus', '');
document.getElementById('profileName').value = user.name ?? '';
document.getElementById('profileEmail').value = user.email ?? '';
document.getElementById('profilePassword').value = '';
document.getElementById('profilePasswordConfirm').value = '';
document.getElementById('profileTheme').value = user.settings?.theme ?? 'dark';
document.getElementById('profileRemovePicture').checked = false;
document.getElementById('profilePictureInput').value = '';
renderProfileAvatar(user.picture);
}
function renderProfileAvatar(picture) {
const preview = document.getElementById('profileAvatarPreview');
if (!preview) return;
if (picture) {
preview.innerHTML = `<img src="${picture}" alt="">`;
return;
}
preview.innerHTML = `
<span class="profile-avatar-fallback">
<i class="fa-solid fa-user"></i>
</span>
`;
}

View File

@@ -0,0 +1,397 @@
async function loadProjectTree() {
const tree = document.getElementById('projectTree');
if (!tree) return;
tree.innerHTML = '<div class="kiln-tree-muted">Loading projects...</div>';
const result = await apiGet('/api/project.php', {
api: 'ListProjects'
});
if (!result.success) {
tree.innerHTML = '<div class="text-danger">Could not load projects.</div>';
return;
}
if (!result.projects.length) {
tree.innerHTML = '<div class="kiln-tree-muted">No projects yet.</div>';
return;
}
tree.innerHTML = '';
taskLookupCache.projectBlocksById.clear();
for (const projectId of result.projects) {
const project = await getProjectInfo(projectId);
const projectBlock = document.createElement('div');
projectBlock.dataset.projectId = projectId;
const projectButton = document.createElement('button');
projectButton.className = 'kiln-tree-project';
projectButton.innerHTML = `
<i class="fa-solid fa-caret-right me-1" data-project-caret></i>
<span>${escapeHtml(project?.name ?? projectId)}</span>
`;
projectButton.addEventListener('click', (event) => {
if (event.target.closest('[data-project-caret]')) {
toggleProject(projectId, projectBlock);
return;
}
loadProject(projectId);
if (!projectBlock.querySelector('.kiln-tree-group')) {
toggleProject(projectId, projectBlock, true);
}
});
projectBlock.appendChild(projectButton);
tree.appendChild(projectBlock);
taskLookupCache.projectBlocksById.set(projectId, projectBlock);
}
if (currentTask?.project) {
const projectBlock = taskLookupCache.projectBlocksById.get(currentTask.project);
if (projectBlock && !projectBlock.querySelector('.kiln-tree-group')) {
await toggleProject(currentTask.project, projectBlock, true);
}
}
}
async function getProjectInfo(projectId) {
if (taskLookupCache.projectsById.has(projectId)) {
return taskLookupCache.projectsById.get(projectId);
}
const result = await apiGet('/api/project.php', {
api: 'ProjectInfo',
project_id: projectId
});
const project = result.success ? result.project : null;
taskLookupCache.projectsById.set(projectId, project);
return project;
}
async function ensureProjectOpen(projectId) {
await projectTreePromise;
const projectBlock = taskLookupCache.projectBlocksById.get(projectId);
if (!projectBlock || projectBlock.querySelector('.kiln-tree-group')) {
return;
}
await toggleProject(projectId, projectBlock, true);
}
async function toggleProject(projectId, projectBlock, forceOpen = false) {
let group = projectBlock.querySelector('.kiln-tree-group');
if (group) {
if (forceOpen) return;
group.remove();
setProjectCaret(projectBlock, false);
return;
}
setProjectCaret(projectBlock, true);
group = document.createElement('div');
group.className = 'kiln-tree-group';
group.innerHTML = '<div class="kiln-tree-muted">Loading...</div>';
projectBlock.appendChild(group);
const versions = await apiGet('/api/version.php', {
api: 'ListVersions',
project_id: projectId
});
group.innerHTML = '';
const versionGroup = document.createElement('div');
versionGroup.className = 'kiln-tree-version-group';
group.appendChild(versionGroup);
const versionLabel = document.createElement('div');
versionLabel.className = 'kiln-tree-label';
versionLabel.textContent = 'Versions';
versionGroup.appendChild(versionLabel);
if (document.querySelector('.kiln-app')?.dataset.canCreateVersions === '1') {
const createVersion = document.createElement('button');
createVersion.className = 'kiln-tree-version';
createVersion.innerHTML = '<i class="fa-solid fa-plus me-1"></i>Create new version';
createVersion.addEventListener('click', () => openVersionCreatePopup(projectId));
versionGroup.appendChild(createVersion);
}
if (versions.success && versions.versions.length) {
for (const versionId of versions.versions) {
const versionInfo = await apiGet('/api/version.php', {
api: 'VersionInfo',
version_id: versionId
});
const versionButton = document.createElement('button');
versionButton.className = 'kiln-tree-version';
if (versionInfo.success && versionInfo.version) {
versionButton.textContent = versionInfo.version.name;
} else {
versionButton.textContent = `Version ${versionId}`;
}
versionButton.addEventListener('click', () => loadVersion(versionId));
versionGroup.appendChild(versionButton);
}
}
const showTasksButton = document.createElement('button');
showTasksButton.className = 'kiln-tree-version';
showTasksButton.innerHTML = '<i class="fa-solid fa-list-check me-1"></i>Show all tasks';
showTasksButton.addEventListener('click', (event) => {
event.stopPropagation();
loadProjectTasks(projectId);
});
group.appendChild(showTasksButton);
}
function setProjectCaret(projectBlock, isOpen) {
const caret = projectBlock.querySelector('[data-project-caret]');
if (!caret) return;
caret.classList.toggle('fa-caret-right', !isOpen);
caret.classList.toggle('fa-caret-down', isOpen);
}
async function loadProject(projectId, pushHistory = true) {
const view = showView('projectView');
if (!view) return;
if (pushHistory) {
const url = new URL(window.location.href);
url.searchParams.set('page', 'home');
url.searchParams.set('project', projectId);
url.searchParams.delete('tasks');
url.searchParams.delete('task');
url.searchParams.delete('version');
url.searchParams.delete('profile');
url.searchParams.delete('admin');
window.history.pushState({}, '', url);
}
const result = await apiGet('/api/project.php', {
api: 'ProjectInfo',
project_id: projectId,
_: Date.now()
});
if (!result.success) {
view.innerHTML = '<div class="alert alert-danger">Project not found.</div>';
return;
}
await renderProject(result.project);
await ensureProjectOpen(result.project.id);
}
async function renderProject(project) {
currentProject = project;
taskLookupCache.projectsById.set(project.id, project);
const owner = project.owner ? await getUserInfo(project.owner) : null;
setVersionEditableText('projectKey', project.id);
setVersionEditableText('projectName', project.name, project.name);
setVersionEditableText('projectInlineName', project.name, project.name);
setText('projectId', project.id);
setEditableHtml('projectOwner', renderUser(owner), project.owner ?? '', 'No owner');
setText('projectCreated', project.created_date || '-');
}
async function populateProjectOwnerSelect(selectedOwner = '') {
populateSelect(document.getElementById('projectFormOwner'), await getUsers(), {
includeEmpty: false,
selectedValue: selectedOwner
});
}
function resetProjectPopup() {
const form = document.getElementById('createProjectForm');
if (!form) return;
form.reset();
form.dataset.mode = 'create';
document.getElementById('projectPopupTitle').textContent = 'Create Project';
document.getElementById('projectPopupSubmit').textContent = 'Create Project';
document.getElementById('projectFormId').disabled = false;
document.getElementById('projectFormId').value = '';
document.getElementById('projectFormName').value = '';
}
async function openProjectCreatePopup() {
resetProjectPopup();
await populateProjectOwnerSelect(getCurrentUserId());
openPopup('createProject');
}
async function openProjectEditPopup() {
if (!currentProject) return;
resetProjectPopup();
const form = document.getElementById('createProjectForm');
if (!form) return;
form.dataset.mode = 'edit';
document.getElementById('projectPopupTitle').textContent = `Edit ${currentProject.id}`;
document.getElementById('projectPopupSubmit').textContent = 'Update Project';
document.getElementById('projectFormId').value = currentProject.id;
document.getElementById('projectFormId').disabled = true;
document.getElementById('projectFormName').value = currentProject.name ?? '';
await populateProjectOwnerSelect(currentProject.owner ?? '');
openPopup('createProject');
}
function initProjectInlineEditing() {
if (!canEditProjects()) return;
document.querySelectorAll('.project-editable').forEach((element) => {
if (element.dataset.inlineReady === 'true') return;
element.dataset.inlineReady = 'true';
element.title = 'Click to edit';
element.addEventListener('click', () => openProjectInlineEditor(element));
element.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openProjectInlineEditor(element);
}
});
});
}
async function openProjectInlineEditor(element) {
if (!canEditProjects() || !currentProject || element.querySelector('.version-inline-form')) return;
const field = element.dataset.projectField;
const currentValue = element.dataset.currentValue ?? '';
const originalHtml = element.innerHTML;
const originalClasses = Array.from(element.classList);
if (!['name', 'owner'].includes(field)) return;
element.classList.remove('is-empty');
element.innerHTML = '';
const form = document.createElement('form');
form.className = 'version-inline-form';
let input;
if (field === 'owner') {
input = document.createElement('select');
input.className = 'form-select version-inline-input';
populateSelect(input, await getUsers(), {
includeEmpty: false,
selectedValue: currentValue
});
} else {
input = document.createElement('input');
input.className = 'form-control version-inline-input';
input.type = 'text';
input.required = true;
input.maxLength = 128;
input.value = currentValue;
}
form.appendChild(input);
const actions = document.createElement('div');
actions.className = 'version-inline-actions';
actions.innerHTML = `
<button class="btn btn-sm btn-primary" type="submit">
<i class="fa-solid fa-check"></i>
</button>
<button class="btn btn-sm btn-outline-secondary" type="button" data-project-inline-cancel>
<i class="fa-solid fa-xmark"></i>
</button>
`;
form.appendChild(actions);
element.appendChild(form);
input.focus();
if (input.select && input.tagName !== 'SELECT') {
input.select();
}
let closed = false;
const restore = () => {
if (closed) return;
closed = true;
element.innerHTML = originalHtml;
element.className = originalClasses.join(' ');
};
const save = async () => {
const nextValue = field === 'name' ? input.value.trim() : input.value;
if (nextValue === currentValue) {
restore();
return;
}
const result = await apiPost('/api/project.php', {
api: 'Edit',
project_id: currentProject.id
}, {
[field]: nextValue
});
if (!result.success) {
alert(result.error || 'Could not update project.');
restore();
return;
}
taskLookupCache.projectsById.delete(currentProject.id);
closed = true;
await loadProject(currentProject.id, false);
projectTreePromise = loadProjectTree();
};
form.addEventListener('submit', async (event) => {
event.preventDefault();
await save();
});
if (input.tagName === 'SELECT') {
input.addEventListener('change', save);
}
form.querySelector('[data-project-inline-cancel]')?.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
restore();
});
input.addEventListener('keydown', async (event) => {
if (event.key === 'Escape') {
event.preventDefault();
restore();
}
});
}

View File

@@ -0,0 +1,42 @@
function loadRouteFromUrl() {
const params = new URLSearchParams(window.location.search);
const taskId = params.get('task');
const versionId = params.get('version');
const projectId = params.get('project');
const projectTasks = params.get('tasks');
const profile = params.get('profile');
const adminSection = params.get('admin');
closePopups();
if (adminSection) {
loadAdmin(adminSection, false);
return;
}
if (profile) {
loadProfile(false);
return;
}
if (taskId) {
loadTask(taskId, false);
return;
}
if (versionId) {
loadVersion(versionId, false);
return;
}
if (projectId) {
if (projectTasks) {
loadProjectTasks(projectId, 1, false);
} else {
loadProject(projectId, false);
}
return;
}
loadDashboard(false);
}

View File

@@ -0,0 +1,685 @@
async function loadTask(taskId, pushHistory = true) {
const view = showView('taskView');
if (!view) return;
if (pushHistory) {
const url = new URL(window.location.href);
url.searchParams.set('page', 'home');
url.searchParams.set('task', taskId);
url.searchParams.delete('project');
url.searchParams.delete('tasks');
url.searchParams.delete('version');
url.searchParams.delete('profile');
url.searchParams.delete('admin');
window.history.pushState({}, '', url);
}
const result = await apiGet('/api/task.php', {
api: 'TaskInfo',
task_id: taskId,
_: Date.now()
});
if (!result.success) {
view.innerHTML = '<div class="alert alert-danger">Task not found.</div>';
return;
}
await renderTask(result.task);
await loadTaskComments(result.task.id);
await ensureProjectOpen(result.task.project);
}
async function renderTask(task) {
currentTask = task;
const [types, priorities, versions, reporter, assignee] = await Promise.all([
getTaskTypes(),
getTaskPriorities(),
getProjectVersions(task.project),
getUserInfo(task.reporter),
task.assignee ? getUserInfo(task.assignee) : null
]);
const type = types.find((item) => String(item.id) === String(task.type)) ?? null;
const priority = priorities.find((item) => String(item.id) === String(task.priority)) ?? null;
const fixVersion = versions.find((item) => String(item.id) === String(task.fix_version)) ?? null;
setEditableText('taskKey', task.id);
setEditableText('taskTitle', task.title, task.title);
setEditableText('taskDescription', task.description, task.description, 'No description provided.');
setText('taskProject', task.project);
document.getElementById('taskReporter').innerHTML = renderUser(reporter);
setEditableHtml('taskAssignee', renderUser(assignee), task.assignee ?? '', 'Unassigned');
setEditableText('taskFixVersion', fixVersion ? fixVersion.name : null, task.fix_version ?? '', 'No fix version');
setText('taskCreated', task.created_date);
setText('taskUpdated', task.last_changed);
setEditableHtml('taskType', renderMetaBadge(type), task.type);
setEditableHtml('taskPriority', renderMetaBadge(priority), task.priority);
renderTaskStatus(task);
renderTaskCustomFields(task.custom_fields ?? []);
}
function renderTaskStatus(task) {
const slot = document.getElementById('taskStatusSlot');
if (!slot) return;
slot.innerHTML = '';
if (!task.status_state) return;
const isCurrentAssignee = String(task.assignee ?? '') === String(getCurrentUserId());
const canTransition = (canEditTasks() || appFlag('isAdmin') || isCurrentAssignee)
&& (task.status_transitions ?? []).length > 0;
const statusStyle = `--task-status-color: ${escapeHtml(task.status_state.color ?? '#6c757d')}`;
if (!canTransition) {
slot.innerHTML = `
<span class="task-status-badge" style="${statusStyle}">
${escapeHtml(task.status_state.name)}
</span>
`;
return;
}
slot.innerHTML = `
<div class="task-status-control">
<button class="task-status-badge task-status-button" type="button" style="${statusStyle}" id="taskStatusButton">
<span>${escapeHtml(task.status_state.name)}</span>
<i class="fa-solid fa-chevron-down"></i>
</button>
<div class="task-status-menu" id="taskStatusMenu" hidden>
${(task.status_transitions ?? []).map((transition) => `
<button type="button" data-task-transition="${escapeHtml(transition.id)}">
<span>${escapeHtml(transition.action_name)}</span>
<small>${escapeHtml(transition.to_state?.name ?? '')}</small>
</button>
`).join('')}
</div>
</div>
`;
}
function renderTaskCustomFields(fields) {
const panel = document.getElementById('taskCustomFieldsPanel');
const list = document.getElementById('taskCustomFieldList');
if (!panel || !list) return;
const canEdit = canEditTasks();
panel.hidden = fields.length === 0;
list.innerHTML = fields.map((field) => `
<div class="task-custom-field-row">
<span>${escapeHtml(field.name)}</span>
<strong
class="task-custom-field-value ${field.raw_value ? '' : 'is-empty'} ${canEdit ? '' : 'is-readonly'}"
${canEdit ? 'tabindex="0"' : 'aria-disabled="true"'}
data-custom-field-id="${escapeHtml(field.id)}"
data-custom-field-type="${escapeHtml(field.type)}"
data-current-value="${escapeHtml(field.raw_value ?? '')}"
>${field.raw_value ? escapeHtml(field.raw_value) : 'No value'}</strong>
</div>
`).join('');
}
async function loadTaskComments(taskId) {
const list = document.getElementById('taskCommentList');
const empty = document.getElementById('taskCommentEmpty');
if (!list || !empty) return;
list.innerHTML = '';
empty.hidden = true;
currentTaskComments = [];
setText('taskCommentStatus', '');
const result = await apiGet('/api/task.php', {
api: 'ListComments',
task_id: taskId,
_: Date.now()
});
if (!result.success) {
list.innerHTML = '<div class="alert alert-danger mb-0">Could not load comments.</div>';
return;
}
currentTaskComments = result.comments ?? [];
renderTaskComments();
}
function renderTaskComments() {
const list = document.getElementById('taskCommentList');
const empty = document.getElementById('taskCommentEmpty');
if (!list || !empty) return;
list.innerHTML = '';
empty.hidden = currentTaskComments.length > 0;
const commentsByParent = groupCommentsByParent(currentTaskComments);
const renderComment = (comment, depth = 0) => {
const user = {
id: comment.commenter,
name: comment.commenter_name,
email: comment.commenter_email,
picture: comment.commenter_picture
};
const article = document.createElement('article');
const canManageComment = String(comment.commenter) === String(getCurrentUserId());
const manageActions = canManageComment
? `
<button class="task-comment-action" type="button" data-comment-edit="${escapeHtml(comment.id)}">Edit</button>
<button class="task-comment-action text-danger" type="button" data-comment-delete="${escapeHtml(comment.id)}">Delete</button>
`
: '';
article.className = `task-comment${comment.response_to ? ' is-reply' : ''}`;
article.style.setProperty('--comment-depth', String(Math.min(depth, 8)));
article.dataset.commentId = comment.id;
article.dataset.commentText = comment.comment;
article.innerHTML = `
<div class="task-comment-header">
${renderUser(user)}
<span class="task-comment-id">#${escapeHtml(comment.id)}</span>
</div>
<div class="task-comment-body">${escapeHtml(comment.comment)}</div>
<div class="task-comment-action-row">
<button class="task-comment-action" type="button" data-comment-reply="${escapeHtml(comment.id)}">Reply</button>
${manageActions}
</div>
<div class="task-comment-reply-slot"></div>
<div class="task-comment-edit-slot"></div>
`;
list.appendChild(article);
const children = commentsByParent.get(Number(comment.id)) ?? [];
children.forEach((child) => renderComment(child, depth + 1));
};
const rootComments = commentsByParent.get(null) ?? [];
rootComments.forEach((comment) => renderComment(comment));
}
function groupCommentsByParent(comments) {
const knownIds = new Set(comments.map((comment) => Number(comment.id)));
const groups = new Map([[null, []]]);
[...comments]
.sort((first, second) => Number(first.id) - Number(second.id))
.forEach((comment) => {
const parentId = comment.response_to && knownIds.has(Number(comment.response_to))
? Number(comment.response_to)
: null;
if (!groups.has(parentId)) {
groups.set(parentId, []);
}
groups.get(parentId).push(comment);
});
return groups;
}
async function submitTaskComment(comment, responseTo = null) {
if (!currentTask) return;
const result = await apiPost('/api/task.php', {
api: 'CreateComment',
task_id: currentTask.id
}, {
comment,
response_to: responseTo
});
if (!result.success) {
throw new Error(result.error || 'Could not save comment.');
}
await loadTaskComments(currentTask.id);
}
async function updateTaskComment(commentId, comment) {
if (!currentTask) return;
const result = await apiPost('/api/task.php', {
api: 'EditComment',
task_id: currentTask.id,
comment_id: commentId
}, {
comment
});
if (!result.success) {
throw new Error(result.error || 'Could not update comment.');
}
await loadTaskComments(currentTask.id);
}
async function deleteTaskComment(commentId) {
if (!currentTask) return;
const result = await apiPost('/api/task.php', {
api: 'DeleteComment',
task_id: currentTask.id,
comment_id: commentId
});
if (!result.success) {
throw new Error(result.error || 'Could not delete comment.');
}
await loadTaskComments(currentTask.id);
}
async function openTaskCreatePopup() {
const form = document.getElementById('createTaskForm');
if (!form) return;
await populateTaskOptionSelects();
await populateTaskProjectSelect();
await populateTaskRelationSelects();
resetTaskPopup();
openPopup('createTask');
}
async function openTaskEditPopup() {
if (!currentTask) return;
const form = document.getElementById('createTaskForm');
if (!form) return;
await populateTaskOptionSelects();
await populateTaskProjectSelect(currentTask.project);
await populateTaskRelationSelects(currentTask.project, {
assignee: currentTask.assignee ?? '',
fix_version: currentTask.fix_version ?? ''
});
form.reset();
form.dataset.mode = 'edit';
document.getElementById('taskPopupTitle').textContent = `Edit ${currentTask.id}`;
document.getElementById('taskPopupSubmit').textContent = 'Update Task';
document.getElementById('taskFormTaskId').value = currentTask.id;
document.getElementById('taskFormProjectId').value = currentTask.project;
document.getElementById('taskFormProjectId').disabled = true;
document.getElementById('taskFormTitle').value = currentTask.title ?? '';
document.getElementById('taskFormDescription').value = currentTask.description ?? '';
document.getElementById('createTaskType').value = currentTask.type ?? '';
document.getElementById('createTaskPriority').value = currentTask.priority ?? '';
document.getElementById('taskFormFixVersion').value = currentTask.fix_version ?? '';
document.getElementById('taskFormAssignee').value = currentTask.assignee ?? '';
openPopup('createTask');
}
function initTaskInlineEditing() {
if (!canEditTasks()) return;
document.querySelectorAll('.task-editable').forEach((element) => {
if (element.dataset.inlineReady === 'true') return;
element.dataset.inlineReady = 'true';
element.title = 'Click to edit';
element.addEventListener('click', () => openTaskInlineEditor(element));
element.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openTaskInlineEditor(element);
}
});
});
}
async function openTaskInlineEditor(element) {
if (!canEditTasks() || !currentTask || element.querySelector('.task-inline-form')) return;
const field = element.dataset.taskField;
const currentValue = element.dataset.currentValue ?? '';
const originalHtml = element.innerHTML;
const originalClasses = Array.from(element.classList);
element.classList.remove('is-empty');
element.innerHTML = '';
const form = document.createElement('form');
form.className = 'task-inline-form';
const input = await createTaskInlineInput(field, currentValue);
form.appendChild(input);
const actions = document.createElement('div');
actions.className = 'task-inline-actions';
actions.innerHTML = `
<button class="btn btn-sm btn-primary" type="submit">
<i class="fa-solid fa-check"></i>
</button>
<button class="btn btn-sm btn-outline-secondary" type="button" data-inline-cancel>
<i class="fa-solid fa-xmark"></i>
</button>
`;
if (input.tagName !== 'SELECT') {
form.appendChild(actions);
}
element.appendChild(form);
input.focus();
if (input.select && input.tagName !== 'SELECT') {
input.select();
}
let editorClosed = false;
const restore = () => {
if (editorClosed) return;
editorClosed = true;
element.innerHTML = originalHtml;
element.className = originalClasses.join(' ');
};
let saving = false;
const save = async () => {
if (saving) return;
const nextValue = input.value;
if (String(nextValue) === String(currentValue)) {
restore();
return;
}
saving = true;
element.classList.add('is-saving');
const result = await apiPost('/api/task.php', {
api: 'Edit',
task_id: currentTask.id
}, {
[field]: nextValue
});
if (!result.success) {
alert(result.error || 'Could not update task.');
saving = false;
restore();
return;
}
editorClosed = true;
element.classList.remove('is-saving');
currentTask = {
...currentTask,
[field]: nextValue === '' ? null : nextValue
};
if (field === 'type') {
await loadTask(currentTask.id, false);
return;
}
await renderTask(currentTask);
};
form.addEventListener('submit', async (event) => {
event.preventDefault();
await save();
});
form.querySelector('[data-inline-cancel]')?.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
restore();
});
input.addEventListener('keydown', async (event) => {
if (event.key === 'Escape') {
event.preventDefault();
restore();
}
if (event.key === 'Enter' && input.tagName === 'TEXTAREA' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
await save();
}
});
if (input.tagName === 'SELECT') {
input.addEventListener('change', save);
input.addEventListener('blur', () => {
window.setTimeout(() => {
if (editorClosed) return;
if (element.classList.contains('is-saving')) return;
if (element.contains(document.activeElement)) return;
restore();
}, 120);
});
}
}
async function createTaskInlineInput(field, currentValue) {
if (field === 'description') {
const textarea = document.createElement('textarea');
textarea.className = 'form-control task-inline-input';
textarea.rows = 5;
textarea.value = currentValue;
return textarea;
}
if (field === 'type') {
const select = document.createElement('select');
select.className = 'form-select task-inline-select';
populateSelect(select, await getTaskTypes(), {
includeEmpty: false,
selectedValue: currentValue
});
return select;
}
if (field === 'priority') {
const select = document.createElement('select');
select.className = 'form-select task-inline-select';
populateSelect(select, await getTaskPriorities(), {
includeEmpty: false,
selectedValue: currentValue
});
return select;
}
if (field === 'fix_version') {
const select = document.createElement('select');
select.className = 'form-select task-inline-select';
populateSelect(select, await getProjectVersions(currentTask.project), {
placeholder: 'No fix version',
selectedValue: currentValue
});
return select;
}
if (field === 'assignee') {
const select = document.createElement('select');
select.className = 'form-select task-inline-select';
populateSelect(select, await getUsers(), {
placeholder: 'Unassigned',
selectedValue: currentValue
});
return select;
}
const input = document.createElement('input');
input.className = 'form-control task-inline-input';
input.type = 'text';
input.value = currentValue;
input.required = field === 'title';
return input;
}
function createCustomFieldInput(type, currentValue) {
if (type === 'boolean') {
const select = document.createElement('select');
select.className = 'form-select task-inline-select';
[
{ id: '', name: 'No value' },
{ id: 'true', name: 'True' },
{ id: 'false', name: 'False' }
].forEach((item) => {
const option = document.createElement('option');
option.value = item.id;
option.textContent = item.name;
option.selected = String(item.id) === String(currentValue);
select.appendChild(option);
});
return select;
}
if (type === 'text' || type === 'json') {
const textarea = document.createElement('textarea');
textarea.className = 'form-control task-inline-input';
textarea.rows = type === 'json' ? 4 : 3;
textarea.value = currentValue;
return textarea;
}
const input = document.createElement('input');
input.className = 'form-control task-inline-input';
input.type = type === 'date' ? 'date' : 'text';
input.inputMode = ['int', 'float'].includes(type) ? 'decimal' : '';
input.value = currentValue;
return input;
}
async function openCustomFieldInlineEditor(element) {
if (!canEditTasks() || !currentTask || element.querySelector('.task-inline-form')) return;
const fieldId = element.dataset.customFieldId;
const fieldType = element.dataset.customFieldType ?? 'string';
const currentValue = element.dataset.currentValue ?? '';
const originalHtml = element.innerHTML;
const originalClasses = Array.from(element.classList);
element.classList.remove('is-empty');
element.innerHTML = '';
const form = document.createElement('form');
form.className = 'task-inline-form';
const input = createCustomFieldInput(fieldType, currentValue);
form.appendChild(input);
const actions = document.createElement('div');
actions.className = 'task-inline-actions';
actions.innerHTML = `
<button class="btn btn-sm btn-primary" type="submit">
<i class="fa-solid fa-check"></i>
</button>
<button class="btn btn-sm btn-outline-secondary" type="button" data-inline-cancel>
<i class="fa-solid fa-xmark"></i>
</button>
`;
form.appendChild(actions);
element.appendChild(form);
input.focus();
if (input.select && input.tagName !== 'SELECT') {
input.select();
}
let closed = false;
const restore = () => {
if (closed) return;
closed = true;
element.innerHTML = originalHtml;
element.className = originalClasses.join(' ');
};
const save = async () => {
const nextValue = input.value;
if (String(nextValue) === String(currentValue)) {
restore();
return;
}
element.classList.add('is-saving');
const result = await apiPost('/api/task.php', {
api: 'SetCustomFieldValue',
task_id: currentTask.id
}, {
field_id: fieldId,
value: nextValue
});
if (!result.success) {
alert(result.error || 'Could not update custom field.');
restore();
return;
}
closed = true;
await loadTask(currentTask.id, false);
};
form.addEventListener('submit', async (event) => {
event.preventDefault();
await save();
});
form.querySelector('[data-inline-cancel]')?.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
restore();
});
input.addEventListener('keydown', async (event) => {
if (event.key === 'Escape') {
event.preventDefault();
restore();
}
if (event.key === 'Enter' && input.tagName !== 'TEXTAREA') {
event.preventDefault();
await save();
}
if (event.key === 'Enter' && input.tagName === 'TEXTAREA' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
await save();
}
});
}
async function transitionCurrentTask(transitionId) {
if (!currentTask || !transitionId) return;
const result = await apiPost('/api/task.php', {
api: 'TransitionStatus',
task_id: currentTask.id
}, {
transition_id: transitionId
});
if (!result.success) {
alert(result.error || 'Could not transition task.');
return;
}
await loadTask(currentTask.id, false);
}

View File

@@ -0,0 +1,181 @@
async function loadProjectTasks(projectId, page = 1, pushHistory = true) {
const view = showView('taskListView');
if (!view) return;
if (pushHistory) {
const url = new URL(window.location.href);
url.searchParams.set('page', 'home');
url.searchParams.set('project', projectId);
url.searchParams.set('tasks', '1');
url.searchParams.delete('task');
url.searchParams.delete('version');
url.searchParams.delete('profile');
url.searchParams.delete('admin');
window.history.pushState({}, '', url);
}
currentProjectTaskProject = projectId;
setText('taskListProjectKey', projectId);
setText('taskListTitle', `${projectId} Tasks`);
const container = document.getElementById('taskListContainer');
const empty = document.getElementById('taskListEmpty');
container.innerHTML = '<div class="version-task-loading">Loading tasks...</div>';
empty.hidden = true;
currentProjectTasks = [];
projectTaskPagination = null;
const result = await apiGet('/api/task.php', {
api: 'ListTasksByProject',
project_id: projectId,
page,
per_page: taskTablePageSize,
sort: projectTaskSort.field,
direction: projectTaskSort.direction
});
if (!result.success) {
container.innerHTML = '<div class="alert alert-danger mb-0">Could not load tasks.</div>';
return;
}
if (!result.tasks.length) {
container.innerHTML = '';
empty.hidden = false;
renderTaskTablePagination('project');
return;
}
currentProjectTasks = await normalizeTaskTableRows(result.tasks);
projectTaskPagination = result.pagination ?? null;
renderTaskTable('project');
}
async function normalizeTaskTableRows(tasks) {
const [types, priorities] = await Promise.all([
getTaskTypes(),
getTaskPriorities()
]);
return tasks.map((task) => {
const normalizedTask = typeof task === 'string'
? { id: task, title: '', type: null, priority: null }
: task;
const type = types.find((item) => String(item.id) === String(normalizedTask.type)) ?? null;
const priority = priorities.find((item) => String(item.id) === String(normalizedTask.priority)) ?? null;
return {
...normalizedTask,
typeOption: type,
priorityOption: priority,
typeName: type?.name ?? '',
priorityName: priority?.name ?? '',
statusName: normalizedTask.status_state?.name ?? '',
statusColor: normalizedTask.status_state?.color ?? '',
assigneeUser: normalizedTask.assignee
? {
id: normalizedTask.assignee,
name: normalizedTask.assignee_name ?? `User ${normalizedTask.assignee}`,
picture: normalizedTask.assignee_picture ?? null
}
: null,
assigneeName: normalizedTask.assignee_name ?? ''
};
});
}
function renderTaskTable(kind) {
const isVersion = kind === 'version';
const list = document.getElementById(isVersion ? 'versionTaskList' : 'taskListContainer');
const empty = document.getElementById(isVersion ? 'versionTaskEmpty' : 'taskListEmpty');
const tasks = isVersion ? currentVersionTasks : currentProjectTasks;
if (!list || !empty) return;
updateTaskTableSortButtons(kind);
list.innerHTML = '';
empty.hidden = tasks.length > 0;
tasks.forEach((task) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'version-task-row version-task-item';
button.innerHTML = `
<span class="version-task-id">${escapeHtml(task.id)}</span>
<span class="version-task-title">${escapeHtml(task.title || '-')}</span>
<span>${renderMetaBadge(task.typeOption)}</span>
<span>${renderMetaBadge(task.priorityOption)}</span>
<span>${renderUser(task.assigneeUser)}</span>
<span>${renderTaskTableStatus(task)}</span>
`;
button.addEventListener('click', () => loadTask(task.id));
list.appendChild(button);
});
renderTaskTablePagination(kind);
}
function renderTaskTableStatus(task) {
if (!task.statusName) return '-';
const color = task.statusColor || '#6c757d';
return `
<span class="version-task-status" style="--task-status-color: ${escapeHtml(color)}">
${escapeHtml(task.statusName)}
</span>
`;
}
function renderTaskTablePagination(kind) {
const isVersion = kind === 'version';
const pagination = isVersion ? versionTaskPagination : projectTaskPagination;
const container = document.getElementById(isVersion ? 'versionTaskPagination' : 'projectTaskPagination');
if (!container) return;
if (!pagination || pagination.total <= pagination.per_page) {
container.innerHTML = '';
return;
}
const start = ((pagination.page - 1) * pagination.per_page) + 1;
const end = Math.min(pagination.total, pagination.page * pagination.per_page);
container.innerHTML = `
<span>${escapeHtml(start)}-${escapeHtml(end)} of ${escapeHtml(pagination.total)}</span>
<div class="version-task-page-actions">
<button class="btn btn-sm btn-outline-secondary" type="button" data-task-page="${escapeHtml(kind)}" data-page="${escapeHtml(pagination.page - 1)}" ${pagination.page <= 1 ? 'disabled' : ''}>
<i class="fa-solid fa-chevron-left"></i>
</button>
<button class="btn btn-sm btn-outline-secondary" type="button" data-task-page="${escapeHtml(kind)}" data-page="${escapeHtml(pagination.page + 1)}" ${pagination.page >= pagination.total_pages ? 'disabled' : ''}>
<i class="fa-solid fa-chevron-right"></i>
</button>
</div>
`;
}
function updateTaskTableSortButtons(kind) {
const isVersion = kind === 'version';
const selector = isVersion ? '[data-version-task-sort]' : '[data-project-task-sort]';
const state = isVersion ? versionTaskSort : projectTaskSort;
document.querySelectorAll(selector).forEach((button) => {
const field = isVersion ? button.dataset.versionTaskSort : button.dataset.projectTaskSort;
const isActive = field === state.field;
const icon = button.querySelector('i');
button.classList.toggle('is-active', isActive);
if (!icon) return;
icon.className = isActive
? `fa-solid ${state.direction === 'asc' ? 'fa-sort-up' : 'fa-sort-down'}`
: 'fa-solid fa-sort';
});
}

View File

@@ -0,0 +1,258 @@
function openVersionCreatePopup(projectId) {
resetVersionPopup();
document.getElementById('versionFormProjectId').value = projectId;
openPopup('createVersion');
}
function openVersionEditPopup() {
if (!currentVersion) return;
resetVersionPopup();
const form = document.getElementById('createVersionForm');
if (!form) return;
form.dataset.mode = 'edit';
document.getElementById('versionPopupTitle').textContent = `Edit ${currentVersion.name}`;
document.getElementById('versionPopupSubmit').textContent = 'Update Version';
document.getElementById('versionFormVersionId').value = currentVersion.id;
document.getElementById('versionFormProjectId').value = currentVersion.project;
document.getElementById('versionFormName').value = currentVersion.name ?? '';
document.getElementById('versionFormDescription').value = currentVersion.description ?? '';
document.getElementById('versionFormDueDate').value = currentVersion.due_date ?? '';
document.getElementById('versionFormReleasedDate').value = currentVersion.released_date ?? '';
openPopup('createVersion');
}
async function loadVersion(versionId, pushHistory = true) {
const view = showView('versionView');
if (!view) return;
if (pushHistory) {
const url = new URL(window.location.href);
url.searchParams.set('page', 'home');
url.searchParams.set('version', versionId);
url.searchParams.delete('task');
url.searchParams.delete('project');
url.searchParams.delete('tasks');
url.searchParams.delete('profile');
url.searchParams.delete('admin');
window.history.pushState({}, '', url);
}
const result = await apiGet('/api/version.php', {
api: 'VersionInfo',
version_id: versionId
});
if (!result.success) {
setText('versionName', 'Version not found');
setText('versionDescription', '');
return;
}
await renderVersion(result.version);
await loadVersionTasks(versionId);
await ensureProjectOpen(result.version.project);
}
async function renderVersion(version) {
currentVersion = version;
setVersionEditableText('versionKey', `Version ${version.id}`);
setVersionEditableText('versionName', version.name, version.name);
setVersionEditableText('versionDescription', version.description, version.description, 'No description provided.');
setText('versionProject', version.project);
setText('versionCreated', version.created_date || '-');
setVersionEditableText('versionDueDate', version.due_date, version.due_date, 'No due date');
setVersionEditableText('versionReleasedDate', version.released_date, version.released_date, 'Not released');
}
async function loadVersionTasks(versionId, page = 1) {
const list = document.getElementById('versionTaskList');
const empty = document.getElementById('versionTaskEmpty');
if (!list || !empty) return;
list.innerHTML = '<div class="version-task-loading">Loading tasks...</div>';
empty.hidden = true;
currentVersionTasks = [];
versionTaskPagination = null;
const result = await apiGet('/api/task.php', {
api: 'ListTasksByVersion',
version_id: versionId,
page,
per_page: taskTablePageSize,
sort: versionTaskSort.field,
direction: versionTaskSort.direction
});
if (!result.success) {
list.innerHTML = '<div class="alert alert-danger mb-0">Could not load tasks.</div>';
return;
}
if (!result.tasks.length) {
list.innerHTML = '';
empty.hidden = false;
renderTaskTablePagination('version');
return;
}
currentVersionTasks = await normalizeTaskTableRows(result.tasks);
versionTaskPagination = result.pagination ?? null;
renderTaskTable('version');
}
function initVersionInlineEditing() {
if (!canEditVersions()) return;
document.querySelectorAll('.version-editable').forEach((element) => {
if (element.dataset.inlineReady === 'true') return;
element.dataset.inlineReady = 'true';
element.title = 'Click to edit';
element.addEventListener('click', () => openVersionInlineEditor(element));
element.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openVersionInlineEditor(element);
}
});
});
}
async function openVersionInlineEditor(element) {
if (!canEditVersions() || !currentVersion || element.querySelector('.version-inline-form')) return;
const field = element.dataset.versionField;
const currentValue = element.dataset.currentValue ?? '';
const originalHtml = element.innerHTML;
const originalClasses = Array.from(element.classList);
element.classList.remove('is-empty');
element.innerHTML = '';
const form = document.createElement('form');
form.className = 'version-inline-form';
const input = createVersionInlineInput(field, currentValue);
form.appendChild(input);
const actions = document.createElement('div');
actions.className = 'version-inline-actions';
actions.innerHTML = `
<button class="btn btn-sm btn-primary" type="submit">
<i class="fa-solid fa-check"></i>
</button>
<button class="btn btn-sm btn-outline-secondary" type="button" data-version-inline-cancel>
<i class="fa-solid fa-xmark"></i>
</button>
`;
form.appendChild(actions);
element.appendChild(form);
input.focus();
if (input.select && input.type !== 'date') {
input.select();
}
let editorClosed = false;
const restore = () => {
if (editorClosed) return;
editorClosed = true;
element.innerHTML = originalHtml;
element.className = originalClasses.join(' ');
};
let saving = false;
const save = async () => {
if (saving) return;
const nextValue = input.value;
if (String(nextValue) === String(currentValue)) {
restore();
return;
}
saving = true;
element.classList.add('is-saving');
const result = await apiPost('/api/version.php', {
api: 'Edit',
version_id: currentVersion.id
}, {
[field]: nextValue
});
if (!result.success) {
alert(result.error || 'Could not update version.');
saving = false;
restore();
return;
}
editorClosed = true;
element.classList.remove('is-saving');
currentVersion = {
...currentVersion,
[field]: nextValue === '' ? null : nextValue
};
taskLookupCache.versionsByProject.delete(currentVersion.project);
await renderVersion(currentVersion);
projectTreePromise = loadProjectTree();
await projectTreePromise;
};
form.addEventListener('submit', async (event) => {
event.preventDefault();
await save();
});
form.querySelector('[data-version-inline-cancel]')?.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
restore();
});
input.addEventListener('keydown', async (event) => {
if (event.key === 'Escape') {
event.preventDefault();
restore();
}
if (event.key === 'Enter' && input.tagName === 'TEXTAREA' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
await save();
}
});
}
function createVersionInlineInput(field, currentValue) {
if (field === 'description') {
const textarea = document.createElement('textarea');
textarea.className = 'form-control version-inline-input';
textarea.rows = 5;
textarea.value = currentValue;
return textarea;
}
const input = document.createElement('input');
input.className = 'form-control version-inline-input';
input.type = field === 'due_date' || field === 'released_date' ? 'date' : 'text';
input.value = currentValue;
input.required = field === 'name';
return input;
}