diff --git a/ProjectKiln/app/css/viewer.css b/ProjectKiln/app/css/viewer/admin.css
similarity index 52%
rename from ProjectKiln/app/css/viewer.css
rename to ProjectKiln/app/css/viewer/admin.css
index 86597f9..806a7fe 100644
--- a/ProjectKiln/app/css/viewer.css
+++ b/ProjectKiln/app/css/viewer/admin.css
@@ -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;
- }
}
diff --git a/ProjectKiln/app/css/viewer/dashboard.css b/ProjectKiln/app/css/viewer/dashboard.css
new file mode 100644
index 0000000..58f06f7
--- /dev/null
+++ b/ProjectKiln/app/css/viewer/dashboard.css
@@ -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);
+}
diff --git a/ProjectKiln/app/css/viewer/profile.css b/ProjectKiln/app/css/viewer/profile.css
new file mode 100644
index 0000000..b931705
--- /dev/null
+++ b/ProjectKiln/app/css/viewer/profile.css
@@ -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;
+ }
+}
diff --git a/ProjectKiln/app/css/viewer/project.css b/ProjectKiln/app/css/viewer/project.css
new file mode 100644
index 0000000..b9c6323
--- /dev/null
+++ b/ProjectKiln/app/css/viewer/project.css
@@ -0,0 +1,3 @@
+.project-view {
+ max-width: 1120px;
+}
diff --git a/ProjectKiln/app/css/viewer/shared.css b/ProjectKiln/app/css/viewer/shared.css
new file mode 100644
index 0000000..fcd646f
--- /dev/null
+++ b/ProjectKiln/app/css/viewer/shared.css
@@ -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;
+ }
+}
diff --git a/ProjectKiln/app/css/viewer/task_list.css b/ProjectKiln/app/css/viewer/task_list.css
new file mode 100644
index 0000000..37a914e
--- /dev/null
+++ b/ProjectKiln/app/css/viewer/task_list.css
@@ -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;
+ }
+}
diff --git a/ProjectKiln/app/css/viewer/version.css b/ProjectKiln/app/css/viewer/version.css
new file mode 100644
index 0000000..3a6d60a
--- /dev/null
+++ b/ProjectKiln/app/css/viewer/version.css
@@ -0,0 +1,7 @@
+.version-view {
+ max-width: 1120px;
+}
+
+.version-task-section {
+ max-width: 1080px;
+}
diff --git a/ProjectKiln/app/home.php b/ProjectKiln/app/home.php
index d101afb..7f5b8fd 100644
--- a/ProjectKiln/app/home.php
+++ b/ProjectKiln/app/home.php
@@ -1,12 +1,27 @@
{
- 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');
- });
- });
-}
-
-async function loadProjectTree() {
- const tree = document.getElementById('projectTree');
-
- if (!tree) return;
-
- tree.innerHTML = '
Loading projects...
';
-
- const result = await apiGet('/api/project.php', {
- api: 'ListProjects'
- });
-
- if (!result.success) {
- tree.innerHTML = 'Could not load projects.
';
- return;
- }
-
- if (!result.projects.length) {
- tree.innerHTML = 'No projects yet.
';
- 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 = `
-
- ${escapeHtml(project?.name ?? projectId)}
- `;
- 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 = 'Loading...
';
- 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 = '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 = '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 = 'Project not found.
';
- 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 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 = 'Loading tasks...
';
- 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 = 'Could not load tasks.
';
- 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 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 = 'Task not found.
';
- 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 = `
-
- ${escapeHtml(task.status_state.name)}
-
- `;
- return;
- }
-
- slot.innerHTML = `
-
-
-
-
- `;
-}
-
-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) => `
-
- ${escapeHtml(field.name)}
- ${field.raw_value ? escapeHtml(field.raw_value) : 'No value'}
-
- `).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 = 'Could not load comments.
';
- 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
- ? `
-
-
- `
- : '';
- 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 = `
-
- ${escapeHtml(comment.comment)}
-
-
-
- `;
-
- 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);
-}
-
-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
- ? `
`
- : '';
-
- return `
-
- ${icon}
- ${escapeHtml(item.name)}
-
- `;
-}
-
-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 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 openVersionCreatePopup(projectId) {
- resetVersionPopup();
- document.getElementById('versionFormProjectId').value = projectId;
- openPopup('createVersion');
-}
-
-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 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');
-}
-
-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 = `
-
-
- `;
-
- 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 = `
-
-
- `;
- 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);
-}
-
-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 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 = 'Could not load profile.
';
- 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 = `
`;
- return;
- }
-
- preview.innerHTML = `
-
-
-
- `;
-}
-
-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 = 'Loading tasks...
';
- 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 = 'Could not load tasks.
';
- return;
- }
-
- if (!result.tasks.length) {
- list.innerHTML = '';
- empty.hidden = false;
- renderTaskTablePagination('version');
- return;
- }
-
- currentVersionTasks = await normalizeTaskTableRows(result.tasks);
- versionTaskPagination = result.pagination ?? null;
- renderTaskTable('version');
-}
-
-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 = `
- ${escapeHtml(task.id)}
- ${escapeHtml(task.title || '-')}
- ${renderMetaBadge(task.typeOption)}
- ${renderMetaBadge(task.priorityOption)}
- ${renderUser(task.assigneeUser)}
- ${renderTaskTableStatus(task)}
- `;
- button.addEventListener('click', () => loadTask(task.id));
-
- list.appendChild(button);
- });
-
- renderTaskTablePagination(kind);
-}
-
-function renderTaskTableStatus(task) {
- if (!task.statusName) return '-';
-
- const color = task.statusColor || '#6c757d';
-
- return `
-
- ${escapeHtml(task.statusName)}
-
- `;
-}
-
-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 = `
- ${escapeHtml(start)}-${escapeHtml(end)} of ${escapeHtml(pagination.total)}
-
-
-
-
- `;
-}
-
-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';
- });
-}
-
-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);
- }
- });
- });
-}
-
-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 = `
-
-
- `;
- 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();
- }
- });
-}
-
-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 = `
-
-
- `;
- 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;
-}
-
-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
- ? `
`
- : `
-
-
-
- `;
-
- return `
-
- ${avatar}
- ${escapeHtml(user.name)}
-
- `;
-}
-
-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 = '';
- }
-
- 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;
-}
-
-async function loadWorkflowEditor() {
- const result = await apiGet('/api/workflow.php', {
- api: 'WorkflowData',
- _: Date.now()
- });
-
- if (!result.success) {
- document.getElementById('workflowGraph').innerHTML = 'Could not load workflow data.
';
- return;
- }
-
- workflowData = result.workflow;
- if (!selectedWorkflowTaskType && workflowData.task_types.length) {
- selectedWorkflowTaskType = String(workflowData.task_types[0].id);
- }
-
- if (selectedWorkflowTaskType && !workflowData.task_types.some((type) => String(type.id) === String(selectedWorkflowTaskType))) {
- selectedWorkflowTaskType = workflowData.task_types.length ? String(workflowData.task_types[0].id) : null;
- }
-
- renderWorkflowEditor();
-}
-
-function renderWorkflowEditor() {
- if (!workflowData) return;
-
- populateWorkflowSelects();
- renderWorkflowLists();
- renderWorkflowGraph();
-}
-
-function populateWorkflowSelects() {
- const stateOptions = workflowData.states.map((state) => ({
- id: state.id,
- name: state.name
- }));
-
- populateSelect(document.getElementById('workflowTransitionFrom'), stateOptions, {
- placeholder: 'From state'
- });
- populateSelect(document.getElementById('workflowTransitionTo'), stateOptions, {
- placeholder: 'To state'
- });
- populateSelect(document.getElementById('workflowAssignmentState'), stateOptions, {
- placeholder: 'State'
- });
- populateSelect(document.getElementById('workflowTaskTypeSelect'), workflowData.task_types, {
- placeholder: 'Task type',
- selectedValue: selectedWorkflowTaskType ?? ''
- });
-
- const assignedStates = getSelectedWorkflowAssignments()
- .map((assignment) => workflowData.states.find((state) => Number(state.id) === Number(assignment.state)))
- .filter(Boolean);
- const selectedType = getSelectedWorkflowTaskType();
-
- populateSelect(document.getElementById('workflowDefaultStateSelect'), assignedStates, {
- placeholder: 'No default state',
- selectedValue: selectedType?.default_state ?? ''
- });
-}
-
-function renderWorkflowLists() {
- const statesById = new Map(workflowData.states.map((state) => [Number(state.id), state]));
- const selectedAssignments = getSelectedWorkflowAssignments();
-
- const stateList = document.getElementById('workflowStateList');
- stateList.innerHTML = workflowData.states.map((state) => `
-
-
-
- ${escapeHtml(state.name)}
-
-
-
- `).join('') || 'No states yet.
';
-
- const transitionList = document.getElementById('workflowTransitionList');
- transitionList.innerHTML = workflowData.transitions.map((transition) => `
-
-
- ${escapeHtml(statesById.get(Number(transition.from_id))?.name ?? transition.from_id)}
-
- ${escapeHtml(statesById.get(Number(transition.to_id))?.name ?? transition.to_id)}
- ${escapeHtml(transition.action_name)}
-
-
-
- `).join('') || 'No transitions yet.
';
-
- const assignmentList = document.getElementById('workflowAssignmentList');
- assignmentList.innerHTML = selectedAssignments.map((assignment) => `
-
-
- ${escapeHtml(statesById.get(Number(assignment.state))?.name ?? assignment.state)}
-
-
-
- `).join('') || 'No assigned states yet.
';
-}
-
-function getSelectedWorkflowTaskType() {
- return workflowData?.task_types.find((type) => String(type.id) === String(selectedWorkflowTaskType)) ?? null;
-}
-
-function getSelectedWorkflowAssignments() {
- if (!selectedWorkflowTaskType) return [];
-
- return workflowData.assignments.filter((assignment) => String(assignment.task_type) === String(selectedWorkflowTaskType));
-}
-
-function renderWorkflowGraph() {
- const graph = document.getElementById('workflowGraph');
- const empty = document.getElementById('workflowGraphEmpty');
-
- if (!graph || !empty) return;
-
- const assignedStateIds = new Set(getSelectedWorkflowAssignments().map((assignment) => Number(assignment.state)));
- const visibleStates = workflowData.states.filter((state) => assignedStateIds.has(Number(state.id)));
- const visibleTransitions = workflowData.transitions.filter((transition) => (
- assignedStateIds.has(Number(transition.from_id))
- && assignedStateIds.has(Number(transition.to_id))
- ));
-
- if (!visibleStates.length) {
- if (workflowGraph) {
- workflowGraph.destroy();
- workflowGraph = null;
- }
-
- graph.innerHTML = '';
- empty.hidden = false;
- empty.textContent = selectedWorkflowTaskType
- ? 'Assign states to this task type to render its workflow graph.'
- : 'Select a task type to render its workflow graph.';
- return;
- }
-
- empty.hidden = true;
-
- if (typeof cytoscape !== 'function') {
- graph.innerHTML = 'Cytoscape.js could not be loaded.
';
- return;
- }
-
- const elements = [
- ...visibleStates.map((state) => ({
- data: {
- id: `state-${state.id}`,
- label: state.name,
- color: state.color
- }
- })),
- ...visibleTransitions.map((transition) => ({
- data: {
- id: `transition-${transition.id}`,
- source: `state-${transition.from_id}`,
- target: `state-${transition.to_id}`,
- label: transition.action_name
- }
- }))
- ];
- const styles = getComputedStyle(document.documentElement);
- const borderColor = styles.getPropertyValue('--bs-border-color').trim() || '#3a3f46';
- const bodyColor = styles.getPropertyValue('--bs-body-color').trim() || '#f3f4f6';
- const secondaryColor = styles.getPropertyValue('--bs-secondary-color').trim() || '#aeb7c4';
-
- if (workflowGraph) {
- workflowGraph.destroy();
- }
-
- workflowGraph = cytoscape({
- container: graph,
- elements,
- style: [
- {
- selector: 'node',
- style: {
- 'background-color': 'data(color)',
- 'border-color': borderColor,
- 'border-width': 1,
- 'color': bodyColor,
- 'font-size': 13,
- 'font-weight': 700,
- 'label': 'data(label)',
- 'text-valign': 'center',
- 'text-halign': 'center',
- 'text-wrap': 'wrap',
- 'text-max-width': 90,
- 'width': 86,
- 'height': 42,
- 'shape': 'round-rectangle'
- }
- },
- {
- selector: 'edge',
- style: {
- 'curve-style': 'bezier',
- 'target-arrow-shape': 'triangle',
- 'line-color': secondaryColor,
- 'target-arrow-color': secondaryColor,
- 'color': secondaryColor,
- 'font-size': 11,
- 'label': 'data(label)',
- 'text-rotation': 'autorotate',
- 'text-margin-y': -8,
- 'width': 2
- }
- }
- ],
- layout: {
- name: 'breadthfirst',
- directed: true,
- padding: 36,
- spacingFactor: 1.25
- }
- });
-}
-
-async function saveWorkflowAction(api, body = {}, params = {}) {
- const result = await apiPost('/api/workflow.php', {
- api,
- ...params
- }, body);
-
- if (!result.success) {
- alert(result.error || 'Could not save workflow change.');
- return false;
- }
-
- await loadWorkflowEditor();
- return true;
-}
-
-async function loadAdminOptions() {
- let result = null;
-
- try {
- result = await apiGet('/api/admin_options.php', {
- api: 'OptionsData',
- _: Date.now()
- });
- } catch (error) {
- result = {
- success: false,
- error: 'Could not load admin options.'
- };
- }
-
- if (!result.success) {
- const typeEditor = document.getElementById('adminSelectedTypeEditor');
- const priorityList = document.getElementById('adminPriorityList');
- const userRightsList = document.getElementById('adminUserRightsList');
-
- if (typeEditor) {
- typeEditor.innerHTML = 'Could not load task types.
';
- }
-
- if (priorityList) {
- priorityList.innerHTML = 'Could not load priorities.
';
- }
-
- if (userRightsList) {
- userRightsList.innerHTML = 'Could not load users.
';
- }
-
- return;
- }
-
- adminOptionsData = {
- types: result.options?.types ?? [],
- priorities: result.options?.priorities ?? [],
- custom_fields: result.options?.custom_fields ?? [],
- users: result.options?.users ?? [],
- rights: result.options?.rights ?? [],
- user_rights: result.options?.user_rights ?? [],
- projects: result.options?.projects ?? [],
- user_access: result.options?.user_access ?? []
- };
- if (!selectedAdminTaskType && adminOptionsData.types.length) {
- selectedAdminTaskType = String(adminOptionsData.types[0].id);
- }
-
- if (selectedAdminTaskType && !adminOptionsData.types.some((type) => String(type.id) === String(selectedAdminTaskType))) {
- selectedAdminTaskType = adminOptionsData.types.length ? String(adminOptionsData.types[0].id) : null;
- }
-
- if (!selectedAdminUser && adminOptionsData.users.length) {
- selectedAdminUser = String(adminOptionsData.users[0].id);
- }
-
- if (selectedAdminUser && !adminOptionsData.users.some((user) => String(user.id) === String(selectedAdminUser))) {
- selectedAdminUser = adminOptionsData.users.length ? String(adminOptionsData.users[0].id) : null;
- }
-
- renderAdminOptions();
-}
-
-function renderAdminOptions() {
- if (!adminOptionsData) return;
-
- const types = adminOptionsData.types ?? [];
- const priorities = adminOptionsData.priorities ?? [];
-
- populateSelect(document.getElementById('adminTaskTypeSelect'), types, {
- placeholder: 'Select task type',
- selectedValue: selectedAdminTaskType ?? ''
- });
- renderSelectedAdminTaskType();
- renderAdminCustomFields();
- renderAdminOptionList('priority', priorities, document.getElementById('adminPriorityList'));
- renderAdminUsers();
-}
-
-function getSelectedAdminTaskType() {
- return adminOptionsData?.types.find((type) => String(type.id) === String(selectedAdminTaskType)) ?? null;
-}
-
-function renderSelectedAdminTaskType() {
- const container = document.getElementById('adminSelectedTypeEditor');
-
- if (!container) return;
-
- const type = getSelectedAdminTaskType();
-
- if (!type) {
- container.innerHTML = 'Create or select a task type first.
';
- return;
- }
-
- container.innerHTML = `
-
- `;
-}
-
-function renderAdminCustomFields() {
- const container = document.getElementById('adminCustomFieldList');
-
- if (!container) return;
-
- if (!selectedAdminTaskType) {
- container.innerHTML = 'Select a task type first.
';
- return;
- }
-
- const fields = (adminOptionsData.custom_fields ?? [])
- .filter((field) => String(field.task_type) === String(selectedAdminTaskType));
-
- container.innerHTML = fields.map((field) => `
-
- `).join('') || 'No custom fields yet.
';
-}
-
-function renderAdminOptionList(kind, options, container) {
- if (!container) return;
-
- container.innerHTML = options.map((option) => `
-
- `).join('') || 'No entries yet.
';
-}
-
-function getSelectedAdminUser() {
- return adminOptionsData?.users.find((user) => String(user.id) === String(selectedAdminUser)) ?? null;
-}
-
-function userHasAdminRight(userId, rightId) {
- return (adminOptionsData?.user_rights ?? []).some((entry) => (
- String(entry.user_id) === String(userId)
- && String(entry.right_id) === String(rightId)
- ));
-}
-
-function getAdminUserAccess(userId) {
- return (adminOptionsData?.user_access ?? []).filter((entry) => (
- String(entry.user_id) === String(userId)
- ));
-}
-
-function getAdminProject(projectId) {
- return (adminOptionsData?.projects ?? []).find((project) => (
- String(project.id) === String(projectId)
- )) ?? null;
-}
-
-function renderAdminUsers() {
- const select = document.getElementById('adminUserSelect');
- const container = document.getElementById('adminUserRightsList');
-
- if (!select || !container) return;
-
- const users = adminOptionsData.users ?? [];
- const rights = adminOptionsData.rights ?? [];
- const projects = adminOptionsData.projects ?? [];
- const selectedUser = getSelectedAdminUser();
-
- populateSelect(select, users, {
- placeholder: 'Select user',
- selectedValue: selectedAdminUser ?? ''
- });
-
- if (!selectedUser) {
- container.innerHTML = 'Select a user first.
';
- return;
- }
-
- if (!rights.length) {
- container.innerHTML = 'No rights configured yet.
';
- return;
- }
-
- const explicitAccess = getAdminUserAccess(selectedUser.id);
- const explicitProjectIds = new Set(explicitAccess.map((entry) => String(entry.project_id)));
- const ownerProjects = projects.filter((project) => String(project.owner) === String(selectedUser.id));
- const ownerProjectIds = new Set(ownerProjects.map((project) => String(project.id)));
- const addableProjects = projects.filter((project) => (
- !explicitProjectIds.has(String(project.id))
- && !ownerProjectIds.has(String(project.id))
- ));
-
- container.innerHTML = `
-
- ${renderUser(selectedUser)}
-
-
-
-
Rights
-
- ${rights.map((right) => {
- const checked = userHasAdminRight(selectedUser.id, right.id);
- const isSelfAdminRight = appFlag('isAdmin')
- && String(selectedUser.id) === String(getCurrentUserId())
- && String(right.name) === 'Admin';
-
- return `
-
- `;
- }).join('')}
-
-
-
-
-
Project Access
-
-
-
- ${explicitAccess.map((access) => {
- const project = getAdminProject(access.project_id);
-
- return `
-
- ${escapeHtml(project?.name ?? access.project_id)} ${escapeHtml(access.project_id)}
-
-
- `;
- }).join('') || '
No explicit project access yet.
'}
-
-
- ${ownerProjects.length ? `
-
- Owner access
- ${ownerProjects.map((project) => `
- ${escapeHtml(project.name)} ${escapeHtml(project.id)}
- `).join('')}
-
- ` : ''}
-
- `;
-}
-
-async function setAdminUserRight(userId, rightId, enabled) {
- const result = await apiPost('/api/admin_options.php', {
- api: 'SetUserRight'
- }, {
- user_id: userId,
- right_id: rightId,
- enabled: enabled ? 1 : 0
- });
-
- if (!result.success) {
- alert(result.error || 'Could not update user right.');
- return false;
- }
-
- await loadAdminOptions();
- return true;
-}
-
-async function grantAdminProjectAccess(userId, projectId) {
- const result = await apiPost('/api/admin_options.php', {
- api: 'GrantProjectAccess'
- }, {
- user_id: userId,
- project_id: projectId
- });
-
- if (!result.success) {
- alert(result.error || 'Could not add project access.');
- return false;
- }
-
- await loadAdminOptions();
- return true;
-}
-
-async function revokeAdminProjectAccess(accessId) {
- const result = await apiPost('/api/admin_options.php', {
- api: 'RevokeProjectAccess',
- access_id: accessId
- });
-
- if (!result.success) {
- alert(result.error || 'Could not remove project access.');
- return false;
- }
-
- await loadAdminOptions();
- return true;
-}
-
-async function saveAdminOptionForm(kind, form, optionId = null) {
- const formData = new FormData(form);
- formData.set('kind', kind);
-
- if (optionId) {
- formData.set('id', optionId);
- }
-
- const result = await apiPostForm('/api/admin_options.php', {
- api: optionId ? 'UpdateOption' : 'CreateOption'
- }, formData);
-
- if (!result.success) {
- alert(result.error || 'Could not save option.');
- return false;
- }
-
- if (kind === 'type' && !optionId && result.option_id) {
- selectedAdminTaskType = String(result.option_id);
- }
-
- taskLookupCache.types = null;
- taskLookupCache.priorities = null;
- await populateTaskOptionSelects();
- await loadAdminOptions();
-
- return true;
-}
-
-async function deleteAdminOption(kind, optionId) {
- const result = await apiPost('/api/admin_options.php', {
- api: 'DeleteOption',
- kind,
- id: optionId
- });
-
- if (!result.success) {
- alert(result.error || 'Could not delete option.');
- return false;
- }
-
- if (kind === 'type' && String(selectedAdminTaskType) === String(optionId)) {
- selectedAdminTaskType = null;
- }
-
- taskLookupCache.types = null;
- taskLookupCache.priorities = null;
- await populateTaskOptionSelects();
- await loadAdminOptions();
-
- return true;
-}
-
-async function saveAdminCustomFieldForm(form, fieldId = null) {
- if (!selectedAdminTaskType) {
- alert('Select a task type first.');
- return false;
- }
-
- const data = Object.fromEntries(new FormData(form));
- data.task_type = selectedAdminTaskType;
-
- if (fieldId) {
- data.id = fieldId;
- }
-
- const result = await apiPost('/api/admin_options.php', {
- api: fieldId ? 'UpdateCustomField' : 'CreateCustomField'
- }, data);
-
- if (!result.success) {
- alert(result.error || 'Could not save custom field.');
- return false;
- }
-
- await loadAdminOptions();
- return true;
-}
-
-async function deleteAdminCustomField(fieldId) {
- const result = await apiPost('/api/admin_options.php', {
- api: 'DeleteCustomField',
- field_id: fieldId
- });
-
- if (!result.success) {
- alert(result.error || 'Could not delete custom field.');
- return false;
- }
-
- await loadAdminOptions();
- return true;
-}
-
-function escapeHtml(value) {
- return String(value)
- .replaceAll('&', '&')
- .replaceAll('<', '<')
- .replaceAll('>', '>')
- .replaceAll('"', '"')
- .replaceAll("'", ''');
-}
-
-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;
- }
-
- showView('dashboardView');
-}
-
-function loadAdmin(section = 'workflows', pushHistory = true) {
- if (document.querySelector('.kiln-app')?.dataset.isAdmin !== '1') {
- showView('dashboardView');
- 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();
- }
-}
-
-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', () => {
- showView('dashboardView');
-
- 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);
- });
- });
-
- 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 = `
-
- `;
- 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 = `
-
- `;
- 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;
- }
- });
-});
diff --git a/ProjectKiln/app/js/home/admin.js b/ProjectKiln/app/js/home/admin.js
new file mode 100644
index 0000000..a92d1a8
--- /dev/null
+++ b/ProjectKiln/app/js/home/admin.js
@@ -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 = 'Could not load workflow data.
';
+ return;
+ }
+
+ workflowData = result.workflow;
+ if (!selectedWorkflowTaskType && workflowData.task_types.length) {
+ selectedWorkflowTaskType = String(workflowData.task_types[0].id);
+ }
+
+ if (selectedWorkflowTaskType && !workflowData.task_types.some((type) => String(type.id) === String(selectedWorkflowTaskType))) {
+ selectedWorkflowTaskType = workflowData.task_types.length ? String(workflowData.task_types[0].id) : null;
+ }
+
+ renderWorkflowEditor();
+}
+
+function renderWorkflowEditor() {
+ if (!workflowData) return;
+
+ populateWorkflowSelects();
+ renderWorkflowLists();
+ renderWorkflowGraph();
+}
+
+function populateWorkflowSelects() {
+ const stateOptions = workflowData.states.map((state) => ({
+ id: state.id,
+ name: state.name
+ }));
+
+ populateSelect(document.getElementById('workflowTransitionFrom'), stateOptions, {
+ placeholder: 'From state'
+ });
+ populateSelect(document.getElementById('workflowTransitionTo'), stateOptions, {
+ placeholder: 'To state'
+ });
+ populateSelect(document.getElementById('workflowAssignmentState'), stateOptions, {
+ placeholder: 'State'
+ });
+ populateSelect(document.getElementById('workflowTaskTypeSelect'), workflowData.task_types, {
+ placeholder: 'Task type',
+ selectedValue: selectedWorkflowTaskType ?? ''
+ });
+
+ const assignedStates = getSelectedWorkflowAssignments()
+ .map((assignment) => workflowData.states.find((state) => Number(state.id) === Number(assignment.state)))
+ .filter(Boolean);
+ const selectedType = getSelectedWorkflowTaskType();
+
+ populateSelect(document.getElementById('workflowDefaultStateSelect'), assignedStates, {
+ placeholder: 'No default state',
+ selectedValue: selectedType?.default_state ?? ''
+ });
+}
+
+function renderWorkflowLists() {
+ const statesById = new Map(workflowData.states.map((state) => [Number(state.id), state]));
+ const selectedAssignments = getSelectedWorkflowAssignments();
+
+ const stateList = document.getElementById('workflowStateList');
+ stateList.innerHTML = workflowData.states.map((state) => `
+
+
+
+ ${escapeHtml(state.name)}
+
+
+
+ `).join('') || 'No states yet.
';
+
+ const transitionList = document.getElementById('workflowTransitionList');
+ transitionList.innerHTML = workflowData.transitions.map((transition) => `
+
+
+ ${escapeHtml(statesById.get(Number(transition.from_id))?.name ?? transition.from_id)}
+
+ ${escapeHtml(statesById.get(Number(transition.to_id))?.name ?? transition.to_id)}
+ ${escapeHtml(transition.action_name)}
+
+
+
+ `).join('') || 'No transitions yet.
';
+
+ const assignmentList = document.getElementById('workflowAssignmentList');
+ assignmentList.innerHTML = selectedAssignments.map((assignment) => `
+
+
+ ${escapeHtml(statesById.get(Number(assignment.state))?.name ?? assignment.state)}
+
+
+
+ `).join('') || 'No assigned states yet.
';
+}
+
+function getSelectedWorkflowTaskType() {
+ return workflowData?.task_types.find((type) => String(type.id) === String(selectedWorkflowTaskType)) ?? null;
+}
+
+function getSelectedWorkflowAssignments() {
+ if (!selectedWorkflowTaskType) return [];
+
+ return workflowData.assignments.filter((assignment) => String(assignment.task_type) === String(selectedWorkflowTaskType));
+}
+
+function renderWorkflowGraph() {
+ const graph = document.getElementById('workflowGraph');
+ const empty = document.getElementById('workflowGraphEmpty');
+
+ if (!graph || !empty) return;
+
+ const assignedStateIds = new Set(getSelectedWorkflowAssignments().map((assignment) => Number(assignment.state)));
+ const visibleStates = workflowData.states.filter((state) => assignedStateIds.has(Number(state.id)));
+ const visibleTransitions = workflowData.transitions.filter((transition) => (
+ assignedStateIds.has(Number(transition.from_id))
+ && assignedStateIds.has(Number(transition.to_id))
+ ));
+
+ if (!visibleStates.length) {
+ if (workflowGraph) {
+ workflowGraph.destroy();
+ workflowGraph = null;
+ }
+
+ graph.innerHTML = '';
+ empty.hidden = false;
+ empty.textContent = selectedWorkflowTaskType
+ ? 'Assign states to this task type to render its workflow graph.'
+ : 'Select a task type to render its workflow graph.';
+ return;
+ }
+
+ empty.hidden = true;
+
+ if (typeof cytoscape !== 'function') {
+ graph.innerHTML = 'Cytoscape.js could not be loaded.
';
+ return;
+ }
+
+ const elements = [
+ ...visibleStates.map((state) => ({
+ data: {
+ id: `state-${state.id}`,
+ label: state.name,
+ color: state.color
+ }
+ })),
+ ...visibleTransitions.map((transition) => ({
+ data: {
+ id: `transition-${transition.id}`,
+ source: `state-${transition.from_id}`,
+ target: `state-${transition.to_id}`,
+ label: transition.action_name
+ }
+ }))
+ ];
+ const styles = getComputedStyle(document.documentElement);
+ const borderColor = styles.getPropertyValue('--bs-border-color').trim() || '#3a3f46';
+ const bodyColor = styles.getPropertyValue('--bs-body-color').trim() || '#f3f4f6';
+ const secondaryColor = styles.getPropertyValue('--bs-secondary-color').trim() || '#aeb7c4';
+
+ if (workflowGraph) {
+ workflowGraph.destroy();
+ }
+
+ workflowGraph = cytoscape({
+ container: graph,
+ elements,
+ style: [
+ {
+ selector: 'node',
+ style: {
+ 'background-color': 'data(color)',
+ 'border-color': borderColor,
+ 'border-width': 1,
+ 'color': bodyColor,
+ 'font-size': 13,
+ 'font-weight': 700,
+ 'label': 'data(label)',
+ 'text-valign': 'center',
+ 'text-halign': 'center',
+ 'text-wrap': 'wrap',
+ 'text-max-width': 90,
+ 'width': 86,
+ 'height': 42,
+ 'shape': 'round-rectangle'
+ }
+ },
+ {
+ selector: 'edge',
+ style: {
+ 'curve-style': 'bezier',
+ 'target-arrow-shape': 'triangle',
+ 'line-color': secondaryColor,
+ 'target-arrow-color': secondaryColor,
+ 'color': secondaryColor,
+ 'font-size': 11,
+ 'label': 'data(label)',
+ 'text-rotation': 'autorotate',
+ 'text-margin-y': -8,
+ 'width': 2
+ }
+ }
+ ],
+ layout: {
+ name: 'breadthfirst',
+ directed: true,
+ padding: 36,
+ spacingFactor: 1.25
+ }
+ });
+}
+
+async function saveWorkflowAction(api, body = {}, params = {}) {
+ const result = await apiPost('/api/workflow.php', {
+ api,
+ ...params
+ }, body);
+
+ if (!result.success) {
+ alert(result.error || 'Could not save workflow change.');
+ return false;
+ }
+
+ await loadWorkflowEditor();
+ return true;
+}
+
+async function loadAdminOptions() {
+ let result = null;
+
+ try {
+ result = await apiGet('/api/admin_options.php', {
+ api: 'OptionsData',
+ _: Date.now()
+ });
+ } catch (error) {
+ result = {
+ success: false,
+ error: 'Could not load admin options.'
+ };
+ }
+
+ if (!result.success) {
+ const typeEditor = document.getElementById('adminSelectedTypeEditor');
+ const priorityList = document.getElementById('adminPriorityList');
+ const userRightsList = document.getElementById('adminUserRightsList');
+
+ if (typeEditor) {
+ typeEditor.innerHTML = 'Could not load task types.
';
+ }
+
+ if (priorityList) {
+ priorityList.innerHTML = 'Could not load priorities.
';
+ }
+
+ if (userRightsList) {
+ userRightsList.innerHTML = 'Could not load users.
';
+ }
+
+ return;
+ }
+
+ adminOptionsData = {
+ types: result.options?.types ?? [],
+ priorities: result.options?.priorities ?? [],
+ custom_fields: result.options?.custom_fields ?? [],
+ users: result.options?.users ?? [],
+ rights: result.options?.rights ?? [],
+ user_rights: result.options?.user_rights ?? [],
+ projects: result.options?.projects ?? [],
+ user_access: result.options?.user_access ?? []
+ };
+ if (!selectedAdminTaskType && adminOptionsData.types.length) {
+ selectedAdminTaskType = String(adminOptionsData.types[0].id);
+ }
+
+ if (selectedAdminTaskType && !adminOptionsData.types.some((type) => String(type.id) === String(selectedAdminTaskType))) {
+ selectedAdminTaskType = adminOptionsData.types.length ? String(adminOptionsData.types[0].id) : null;
+ }
+
+ if (!selectedAdminUser && adminOptionsData.users.length) {
+ selectedAdminUser = String(adminOptionsData.users[0].id);
+ }
+
+ if (selectedAdminUser && !adminOptionsData.users.some((user) => String(user.id) === String(selectedAdminUser))) {
+ selectedAdminUser = adminOptionsData.users.length ? String(adminOptionsData.users[0].id) : null;
+ }
+
+ renderAdminOptions();
+}
+
+function renderAdminOptions() {
+ if (!adminOptionsData) return;
+
+ const types = adminOptionsData.types ?? [];
+ const priorities = adminOptionsData.priorities ?? [];
+
+ populateSelect(document.getElementById('adminTaskTypeSelect'), types, {
+ placeholder: 'Select task type',
+ selectedValue: selectedAdminTaskType ?? ''
+ });
+ renderSelectedAdminTaskType();
+ renderAdminCustomFields();
+ renderAdminOptionList('priority', priorities, document.getElementById('adminPriorityList'));
+ renderAdminUsers();
+}
+
+function getSelectedAdminTaskType() {
+ return adminOptionsData?.types.find((type) => String(type.id) === String(selectedAdminTaskType)) ?? null;
+}
+
+function renderSelectedAdminTaskType() {
+ const container = document.getElementById('adminSelectedTypeEditor');
+
+ if (!container) return;
+
+ const type = getSelectedAdminTaskType();
+
+ if (!type) {
+ container.innerHTML = 'Create or select a task type first.
';
+ return;
+ }
+
+ container.innerHTML = `
+
+ `;
+}
+
+function renderAdminCustomFields() {
+ const container = document.getElementById('adminCustomFieldList');
+
+ if (!container) return;
+
+ if (!selectedAdminTaskType) {
+ container.innerHTML = 'Select a task type first.
';
+ return;
+ }
+
+ const fields = (adminOptionsData.custom_fields ?? [])
+ .filter((field) => String(field.task_type) === String(selectedAdminTaskType));
+
+ container.innerHTML = fields.map((field) => `
+
+ `).join('') || 'No custom fields yet.
';
+}
+
+function renderAdminOptionList(kind, options, container) {
+ if (!container) return;
+
+ container.innerHTML = options.map((option) => `
+
+ `).join('') || 'No entries yet.
';
+}
+
+function getSelectedAdminUser() {
+ return adminOptionsData?.users.find((user) => String(user.id) === String(selectedAdminUser)) ?? null;
+}
+
+function userHasAdminRight(userId, rightId) {
+ return (adminOptionsData?.user_rights ?? []).some((entry) => (
+ String(entry.user_id) === String(userId)
+ && String(entry.right_id) === String(rightId)
+ ));
+}
+
+function getAdminUserAccess(userId) {
+ return (adminOptionsData?.user_access ?? []).filter((entry) => (
+ String(entry.user_id) === String(userId)
+ ));
+}
+
+function getAdminProject(projectId) {
+ return (adminOptionsData?.projects ?? []).find((project) => (
+ String(project.id) === String(projectId)
+ )) ?? null;
+}
+
+function renderAdminUsers() {
+ const select = document.getElementById('adminUserSelect');
+ const container = document.getElementById('adminUserRightsList');
+
+ if (!select || !container) return;
+
+ const users = adminOptionsData.users ?? [];
+ const rights = adminOptionsData.rights ?? [];
+ const projects = adminOptionsData.projects ?? [];
+ const selectedUser = getSelectedAdminUser();
+
+ populateSelect(select, users, {
+ placeholder: 'Select user',
+ selectedValue: selectedAdminUser ?? ''
+ });
+
+ if (!selectedUser) {
+ container.innerHTML = 'Select a user first.
';
+ return;
+ }
+
+ if (!rights.length) {
+ container.innerHTML = 'No rights configured yet.
';
+ return;
+ }
+
+ const explicitAccess = getAdminUserAccess(selectedUser.id);
+ const explicitProjectIds = new Set(explicitAccess.map((entry) => String(entry.project_id)));
+ const ownerProjects = projects.filter((project) => String(project.owner) === String(selectedUser.id));
+ const ownerProjectIds = new Set(ownerProjects.map((project) => String(project.id)));
+ const addableProjects = projects.filter((project) => (
+ !explicitProjectIds.has(String(project.id))
+ && !ownerProjectIds.has(String(project.id))
+ ));
+
+ container.innerHTML = `
+
+ ${renderUser(selectedUser)}
+
+
+
+
Rights
+
+ ${rights.map((right) => {
+ const checked = userHasAdminRight(selectedUser.id, right.id);
+ const isSelfAdminRight = appFlag('isAdmin')
+ && String(selectedUser.id) === String(getCurrentUserId())
+ && String(right.name) === 'Admin';
+
+ return `
+
+ `;
+ }).join('')}
+
+
+
+
+
Project Access
+
+
+
+ ${explicitAccess.map((access) => {
+ const project = getAdminProject(access.project_id);
+
+ return `
+
+ ${escapeHtml(project?.name ?? access.project_id)} ${escapeHtml(access.project_id)}
+
+
+ `;
+ }).join('') || '
No explicit project access yet.
'}
+
+
+ ${ownerProjects.length ? `
+
+ Owner access
+ ${ownerProjects.map((project) => `
+ ${escapeHtml(project.name)} ${escapeHtml(project.id)}
+ `).join('')}
+
+ ` : ''}
+
+ `;
+}
+
+async function setAdminUserRight(userId, rightId, enabled) {
+ const result = await apiPost('/api/admin_options.php', {
+ api: 'SetUserRight'
+ }, {
+ user_id: userId,
+ right_id: rightId,
+ enabled: enabled ? 1 : 0
+ });
+
+ if (!result.success) {
+ alert(result.error || 'Could not update user right.');
+ return false;
+ }
+
+ await loadAdminOptions();
+ return true;
+}
+
+async function grantAdminProjectAccess(userId, projectId) {
+ const result = await apiPost('/api/admin_options.php', {
+ api: 'GrantProjectAccess'
+ }, {
+ user_id: userId,
+ project_id: projectId
+ });
+
+ if (!result.success) {
+ alert(result.error || 'Could not add project access.');
+ return false;
+ }
+
+ await loadAdminOptions();
+ return true;
+}
+
+async function revokeAdminProjectAccess(accessId) {
+ const result = await apiPost('/api/admin_options.php', {
+ api: 'RevokeProjectAccess',
+ access_id: accessId
+ });
+
+ if (!result.success) {
+ alert(result.error || 'Could not remove project access.');
+ return false;
+ }
+
+ await loadAdminOptions();
+ return true;
+}
+
+async function saveAdminOptionForm(kind, form, optionId = null) {
+ const formData = new FormData(form);
+ formData.set('kind', kind);
+
+ if (optionId) {
+ formData.set('id', optionId);
+ }
+
+ const result = await apiPostForm('/api/admin_options.php', {
+ api: optionId ? 'UpdateOption' : 'CreateOption'
+ }, formData);
+
+ if (!result.success) {
+ alert(result.error || 'Could not save option.');
+ return false;
+ }
+
+ if (kind === 'type' && !optionId && result.option_id) {
+ selectedAdminTaskType = String(result.option_id);
+ }
+
+ taskLookupCache.types = null;
+ taskLookupCache.priorities = null;
+ await populateTaskOptionSelects();
+ await loadAdminOptions();
+
+ return true;
+}
+
+async function deleteAdminOption(kind, optionId) {
+ const result = await apiPost('/api/admin_options.php', {
+ api: 'DeleteOption',
+ kind,
+ id: optionId
+ });
+
+ if (!result.success) {
+ alert(result.error || 'Could not delete option.');
+ return false;
+ }
+
+ if (kind === 'type' && String(selectedAdminTaskType) === String(optionId)) {
+ selectedAdminTaskType = null;
+ }
+
+ taskLookupCache.types = null;
+ taskLookupCache.priorities = null;
+ await populateTaskOptionSelects();
+ await loadAdminOptions();
+
+ return true;
+}
+
+async function saveAdminCustomFieldForm(form, fieldId = null) {
+ if (!selectedAdminTaskType) {
+ alert('Select a task type first.');
+ return false;
+ }
+
+ const data = Object.fromEntries(new FormData(form));
+ data.task_type = selectedAdminTaskType;
+
+ if (fieldId) {
+ data.id = fieldId;
+ }
+
+ const result = await apiPost('/api/admin_options.php', {
+ api: fieldId ? 'UpdateCustomField' : 'CreateCustomField'
+ }, data);
+
+ if (!result.success) {
+ alert(result.error || 'Could not save custom field.');
+ return false;
+ }
+
+ await loadAdminOptions();
+ return true;
+}
+
+async function deleteAdminCustomField(fieldId) {
+ const result = await apiPost('/api/admin_options.php', {
+ api: 'DeleteCustomField',
+ field_id: fieldId
+ });
+
+ if (!result.success) {
+ alert(result.error || 'Could not delete custom field.');
+ return false;
+ }
+
+ await loadAdminOptions();
+ return true;
+}
+
+function loadAdmin(section = 'workflows', pushHistory = true) {
+ if (document.querySelector('.kiln-app')?.dataset.isAdmin !== '1') {
+ loadDashboard(false);
+ return;
+ }
+
+ const view = showView('adminView');
+
+ if (!view) return;
+
+ if (!['workflows', 'options', 'users'].includes(section)) {
+ section = 'workflows';
+ }
+
+ document.querySelectorAll('[data-admin-section]').forEach((button) => {
+ button.classList.toggle('is-active', button.dataset.adminSection === section);
+ });
+
+ document.querySelectorAll('.admin-panel').forEach((panel) => {
+ panel.hidden = true;
+ });
+
+ const panelBySection = {
+ workflows: 'adminWorkflowsPanel',
+ options: 'adminOptionsPanel',
+ users: 'adminUsersPanel'
+ };
+ const panel = document.getElementById(panelBySection[section]);
+ if (panel) panel.hidden = false;
+
+ if (pushHistory) {
+ const url = new URL(window.location.href);
+ url.searchParams.set('page', 'home');
+ url.searchParams.set('admin', section);
+ url.searchParams.delete('task');
+ url.searchParams.delete('project');
+ url.searchParams.delete('tasks');
+ url.searchParams.delete('version');
+ url.searchParams.delete('profile');
+ window.history.pushState({}, '', url);
+ }
+
+ if (section === 'workflows') {
+ loadWorkflowEditor();
+ } else if (section === 'options' || section === 'users') {
+ loadAdminOptions();
+ }
+}
diff --git a/ProjectKiln/app/js/home/core.js b/ProjectKiln/app/js/home/core.js
new file mode 100644
index 0000000..f104bb2
--- /dev/null
+++ b/ProjectKiln/app/js/home/core.js
@@ -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
+ ? `
`
+ : '';
+
+ return `
+
+ ${icon}
+ ${escapeHtml(item.name)}
+
+ `;
+}
+
+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
+ ? `
`
+ : `
+
+
+
+ `;
+
+ return `
+
+ ${avatar}
+ ${escapeHtml(user.name)}
+
+ `;
+}
+
+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 = '';
+ }
+
+ 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('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+}
diff --git a/ProjectKiln/app/js/home/dashboard.js b/ProjectKiln/app/js/home/dashboard.js
new file mode 100644
index 0000000..827c4c8
--- /dev/null
+++ b/ProjectKiln/app/js/home/dashboard.js
@@ -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);
+}
diff --git a/ProjectKiln/app/js/home/events.js b/ProjectKiln/app/js/home/events.js
new file mode 100644
index 0000000..709fa38
--- /dev/null
+++ b/ProjectKiln/app/js/home/events.js
@@ -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 = `
+
+ `;
+ 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 = `
+
+ `;
+ 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;
+ }
+ });
+});
diff --git a/ProjectKiln/app/js/home/profile.js b/ProjectKiln/app/js/home/profile.js
new file mode 100644
index 0000000..409b2b0
--- /dev/null
+++ b/ProjectKiln/app/js/home/profile.js
@@ -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 = 'Could not load profile.
';
+ 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 = `
`;
+ return;
+ }
+
+ preview.innerHTML = `
+
+
+
+ `;
+}
+
diff --git a/ProjectKiln/app/js/home/project.js b/ProjectKiln/app/js/home/project.js
new file mode 100644
index 0000000..e9df2ee
--- /dev/null
+++ b/ProjectKiln/app/js/home/project.js
@@ -0,0 +1,397 @@
+async function loadProjectTree() {
+ const tree = document.getElementById('projectTree');
+
+ if (!tree) return;
+
+ tree.innerHTML = 'Loading projects...
';
+
+ const result = await apiGet('/api/project.php', {
+ api: 'ListProjects'
+ });
+
+ if (!result.success) {
+ tree.innerHTML = 'Could not load projects.
';
+ return;
+ }
+
+ if (!result.projects.length) {
+ tree.innerHTML = 'No projects yet.
';
+ 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 = `
+
+ ${escapeHtml(project?.name ?? projectId)}
+ `;
+ 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 = 'Loading...
';
+ 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 = '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 = '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 = 'Project not found.
';
+ 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 = `
+
+
+ `;
+ 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();
+ }
+ });
+}
+
diff --git a/ProjectKiln/app/js/home/router.js b/ProjectKiln/app/js/home/router.js
new file mode 100644
index 0000000..ed8e11f
--- /dev/null
+++ b/ProjectKiln/app/js/home/router.js
@@ -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);
+}
diff --git a/ProjectKiln/app/js/home/task.js b/ProjectKiln/app/js/home/task.js
new file mode 100644
index 0000000..5da73ac
--- /dev/null
+++ b/ProjectKiln/app/js/home/task.js
@@ -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 = 'Task not found.
';
+ 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 = `
+
+ ${escapeHtml(task.status_state.name)}
+
+ `;
+ return;
+ }
+
+ slot.innerHTML = `
+
+
+
+
+ `;
+}
+
+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) => `
+
+ ${escapeHtml(field.name)}
+ ${field.raw_value ? escapeHtml(field.raw_value) : 'No value'}
+
+ `).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 = 'Could not load comments.
';
+ 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
+ ? `
+
+
+ `
+ : '';
+ 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 = `
+
+ ${escapeHtml(comment.comment)}
+
+
+
+ `;
+
+ 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 = `
+
+
+ `;
+
+ 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 = `
+
+
+ `;
+ 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);
+}
+
diff --git a/ProjectKiln/app/js/home/task_list.js b/ProjectKiln/app/js/home/task_list.js
new file mode 100644
index 0000000..8f9e93f
--- /dev/null
+++ b/ProjectKiln/app/js/home/task_list.js
@@ -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 = 'Loading tasks...
';
+ 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 = 'Could not load tasks.
';
+ 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 = `
+ ${escapeHtml(task.id)}
+ ${escapeHtml(task.title || '-')}
+ ${renderMetaBadge(task.typeOption)}
+ ${renderMetaBadge(task.priorityOption)}
+ ${renderUser(task.assigneeUser)}
+ ${renderTaskTableStatus(task)}
+ `;
+ button.addEventListener('click', () => loadTask(task.id));
+
+ list.appendChild(button);
+ });
+
+ renderTaskTablePagination(kind);
+}
+
+function renderTaskTableStatus(task) {
+ if (!task.statusName) return '-';
+
+ const color = task.statusColor || '#6c757d';
+
+ return `
+
+ ${escapeHtml(task.statusName)}
+
+ `;
+}
+
+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 = `
+ ${escapeHtml(start)}-${escapeHtml(end)} of ${escapeHtml(pagination.total)}
+
+
+
+
+ `;
+}
+
+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';
+ });
+}
diff --git a/ProjectKiln/app/js/home/version.js b/ProjectKiln/app/js/home/version.js
new file mode 100644
index 0000000..4701501
--- /dev/null
+++ b/ProjectKiln/app/js/home/version.js
@@ -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 = 'Loading tasks...
';
+ 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 = 'Could not load tasks.
';
+ 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 = `
+
+
+ `;
+ 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;
+}
+
diff --git a/ProjectKiln/index.php b/ProjectKiln/index.php
index d861f36..e13dae8 100644
--- a/ProjectKiln/index.php
+++ b/ProjectKiln/index.php
@@ -16,6 +16,30 @@ const THEME_STYLES = [
'beige' => 'app/css/beige_mode.css',
];
+function projectKilnTableExists(string $table): bool
+{
+ $stmt = db()->prepare(
+ 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?'
+ );
+ $stmt->execute([$table]);
+
+ return (int)$stmt->fetchColumn() > 0;
+}
+
+function projectKilnIsInstalled(): bool
+{
+ if (!projectKilnTableExists('settings')) {
+ return false;
+ }
+
+ $stmt = db()->prepare(
+ "SELECT COUNT(*) FROM settings WHERE setting_name = 'installed' AND LOWER(setting_value) = 'true'"
+ );
+ $stmt->execute();
+
+ return (int)$stmt->fetchColumn() > 0;
+}
+
function currentTheme(): string
{
$theme = 'dark';
@@ -43,24 +67,34 @@ function currentTheme(): string
return array_key_exists($storedTheme, THEME_STYLES) ? $storedTheme : $theme;
}
-$page = $_GET['page'] ?? 'home';
-$theme = currentTheme();
+$isInstalled = projectKilnIsInstalled();
+$theme = $isInstalled ? currentTheme() : 'dark';
-$routes = [
- 'home' => 'home.php',
- 'login' => 'login.php',
- 'logout' => 'logout.php',
-];
+if (!$isInstalled) {
+ define('PROJECTKILN_INSTALL_EMBEDDED', true);
-if (!isset($routes[$page])) {
- $pageFile = __DIR__ . '/app/404.php';
+ ob_start();
+ require __DIR__ . '/install/install.php';
+ $pageContent = ob_get_clean();
} else {
- $pageFile = __DIR__ . '/app/' . $routes[$page];
-}
+ $page = $_GET['page'] ?? 'home';
-ob_start();
-require $pageFile;
-$pageContent = ob_get_clean();
+ $routes = [
+ 'home' => 'home.php',
+ 'login' => 'login.php',
+ 'logout' => 'logout.php',
+ ];
+
+ if (!isset($routes[$page])) {
+ $pageFile = __DIR__ . '/app/404.php';
+ } else {
+ $pageFile = __DIR__ . '/app/' . $routes[$page];
+ }
+
+ ob_start();
+ require $pageFile;
+ $pageContent = ob_get_clean();
+}
?>
diff --git a/ProjectKiln/install/install.php b/ProjectKiln/install/install.php
new file mode 100644
index 0000000..359c3d8
--- /dev/null
+++ b/ProjectKiln/install/install.php
@@ -0,0 +1,251 @@
+prepare(
+ 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?'
+ );
+ $stmt->execute([$table]);
+
+ return (int)$stmt->fetchColumn() > 0;
+}
+
+function installerIsInstalled(PDO $pdo): bool {
+ if (!installerTableExists($pdo, 'settings')) {
+ return false;
+ }
+
+ $stmt = $pdo->prepare(
+ "SELECT COUNT(*) FROM settings WHERE setting_name = 'installed' AND LOWER(setting_value) = 'true'"
+ );
+ $stmt->execute();
+
+ return (int)$stmt->fetchColumn() > 0;
+}
+
+function redirectHome(): never {
+ header('Location: ' . (installerEmbedded() ? '?page=home' : '../?page=home'));
+ exit;
+}
+
+if (installerEmbedded()) {
+ $pageStyles[] = 'app/css/login.css';
+}
+
+$pdo = db();
+
+if (installerIsInstalled($pdo)) {
+ redirectHome();
+}
+
+$installError = null;
+$formError = null;
+
+try {
+ require __DIR__ . '/install_db.php';
+ require_once __DIR__ . '/../auth.php';
+} catch (Throwable $exception) {
+ $installError = $exception->getMessage();
+}
+
+if ($installError === null && $_SERVER['REQUEST_METHOD'] === 'POST') {
+ $username = trim((string)($_POST['username'] ?? ''));
+ $email = strtolower(trim((string)($_POST['email'] ?? '')));
+ $password = (string)($_POST['password'] ?? '');
+ $passwordConfirm = (string)($_POST['password_confirm'] ?? '');
+
+ if ($username === '' || $email === '' || $password === '') {
+ $formError = 'Please fill in all required fields.';
+ } elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
+ $formError = 'Please enter a valid email address.';
+ } elseif (strlen($password) < 8) {
+ $formError = 'Password must be at least 8 characters long.';
+ } elseif ($password !== $passwordConfirm) {
+ $formError = 'Passwords do not match.';
+ } else {
+ $userId = register($username, $email, $password);
+
+ if ($userId === null) {
+ $formError = 'Admin user could not be created. Check if the email is already used.';
+ } else {
+ $adminRightStmt = $pdo->prepare("SELECT id FROM rights WHERE name = 'Admin' LIMIT 1");
+ $adminRightStmt->execute();
+ $adminRightId = (int)$adminRightStmt->fetchColumn();
+
+ if ($adminRightId <= 0) {
+ $formError = 'Admin right is missing after database setup.';
+ } else {
+ $existingRight = $pdo->prepare(
+ 'SELECT COUNT(*) FROM user_rights WHERE user_id = ? AND right_id = ?'
+ );
+ $existingRight->execute([$userId, $adminRightId]);
+
+ if ((int)$existingRight->fetchColumn() === 0) {
+ $assignAdmin = $pdo->prepare(
+ 'INSERT INTO user_rights (user_id, right_id) VALUES (?, ?)'
+ );
+ $assignAdmin->execute([$userId, $adminRightId]);
+ }
+
+ $markInstalled = $pdo->prepare(
+ "INSERT INTO settings (id, setting_name, setting_value)
+ VALUES (2, 'installed', 'true')
+ ON DUPLICATE KEY UPDATE setting_name = VALUES(setting_name), setting_value = VALUES(setting_value)"
+ );
+ $markInstalled->execute();
+
+ login($email, $password, false);
+ redirectHome();
+ }
+ }
+ }
+}
+
+function oldInput(string $name): string {
+ return htmlspecialchars((string)($_POST[$name] ?? ''), ENT_QUOTES, 'UTF-8');
+}
+?>
+
+
+
+
+
+
+ ProjectKiln Install
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ProjectKiln Setup
+
+
+
+ Create the first administrator account to finish the installation.
+
+
+
+
+
+
+
+ Database setup failed: = htmlspecialchars($installError, ENT_QUOTES, 'UTF-8') ?>
+
+
+
+
+
+
+ = htmlspecialchars($formError, ENT_QUOTES, 'UTF-8') ?>
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ProjectKiln/install/install_db.php b/ProjectKiln/install/install_db.php
new file mode 100644
index 0000000..b3f645e
--- /dev/null
+++ b/ProjectKiln/install/install_db.php
@@ -0,0 +1,495 @@
+prepare(
+ 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?'
+ );
+ $stmt->execute([$table]);
+
+ return (int) $stmt->fetchColumn() > 0;
+}
+
+function indexExists(PDO $pdo, string $table, string $index): bool {
+ $stmt = $pdo->prepare(
+ 'SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?'
+ );
+ $stmt->execute([$table, $index]);
+
+ return (int) $stmt->fetchColumn() > 0;
+}
+
+function foreignKeyExists(PDO $pdo, string $constraint): bool {
+ $stmt = $pdo->prepare(
+ "SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
+ WHERE CONSTRAINT_SCHEMA = DATABASE()
+ AND CONSTRAINT_NAME = ?
+ AND CONSTRAINT_TYPE = 'FOREIGN KEY'"
+ );
+ $stmt->execute([$constraint]);
+
+ return (int) $stmt->fetchColumn() > 0;
+}
+
+function addIndexIfMissing(PDO $pdo, string $table, string $index, string $definition): void {
+ if (indexExists($pdo, $table, $index)) {
+ return;
+ }
+
+ $pdo->exec('ALTER TABLE ' . quoteIdentifier($table) . ' ADD ' . $definition);
+}
+
+function addForeignKeyIfMissing(PDO $pdo, string $constraint, string $definition): void {
+ if (foreignKeyExists($pdo, $constraint)) {
+ return;
+ }
+
+ $pdo->exec($definition);
+}
+
+function assetBlob(string $relativePath): ?string {
+ $path = __DIR__ . '/../../raw-resources/' . ltrim($relativePath, '/');
+
+ if (!is_file($path)) {
+ return null;
+ }
+
+ return file_get_contents($path) ?: null;
+}
+
+function upsert(PDO $pdo, string $sql, array $params): void {
+ $stmt = $pdo->prepare($sql);
+ $stmt->execute($params);
+}
+
+try {
+ $pdo->exec('SET FOREIGN_KEY_CHECKS = 0');
+
+ $pdo->exec(<<<'SQL'
+CREATE TABLE IF NOT EXISTS `task_states` (
+ `id` int NOT NULL AUTO_INCREMENT,
+ `name` varchar(128) COLLATE utf8mb4_0900_bin NOT NULL,
+ `color` varchar(7) COLLATE utf8mb4_0900_bin NOT NULL,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
+SQL);
+
+ $pdo->exec(<<<'SQL'
+CREATE TABLE IF NOT EXISTS `task_priorities` (
+ `id` int NOT NULL AUTO_INCREMENT,
+ `name` varchar(20) COLLATE utf8mb4_0900_bin NOT NULL,
+ `logo` blob,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
+SQL);
+
+ $pdo->exec(<<<'SQL'
+CREATE TABLE IF NOT EXISTS `task_types` (
+ `id` int NOT NULL AUTO_INCREMENT,
+ `name` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL,
+ `logo` blob,
+ `default_state` int DEFAULT NULL,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
+SQL);
+
+ $pdo->exec(<<<'SQL'
+CREATE TABLE IF NOT EXISTS `users` (
+ `id` int NOT NULL AUTO_INCREMENT,
+ `name` varchar(128) COLLATE utf8mb4_0900_bin NOT NULL,
+ `email` varchar(128) COLLATE utf8mb4_0900_bin NOT NULL,
+ `passwd` varchar(255) COLLATE utf8mb4_0900_bin NOT NULL,
+ `picture` mediumblob,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
+SQL);
+
+ $pdo->exec(<<<'SQL'
+CREATE TABLE IF NOT EXISTS `projects` (
+ `id` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL,
+ `name` varchar(128) COLLATE utf8mb4_0900_bin NOT NULL,
+ `owner` int NOT NULL,
+ `created_date` date NOT NULL,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
+SQL);
+
+ $pdo->exec(<<<'SQL'
+CREATE TABLE IF NOT EXISTS `versions` (
+ `id` int NOT NULL AUTO_INCREMENT,
+ `name` varchar(128) COLLATE utf8mb4_0900_bin NOT NULL,
+ `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin,
+ `created_date` date NOT NULL,
+ `due_date` date DEFAULT NULL,
+ `released_date` date DEFAULT NULL,
+ `project` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
+SQL);
+
+ $pdo->exec(<<<'SQL'
+CREATE TABLE IF NOT EXISTS `tasks` (
+ `id` varchar(26) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL,
+ `title` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL,
+ `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin,
+ `project` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL,
+ `created_date` date NOT NULL,
+ `last_changed` date NOT NULL,
+ `reporter` int NOT NULL,
+ `assignee` int DEFAULT NULL,
+ `fix_version` int DEFAULT NULL,
+ `type` int NOT NULL,
+ `priority` int NOT NULL,
+ `status` int DEFAULT NULL,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
+SQL);
+
+ $pdo->exec(<<<'SQL'
+CREATE TABLE IF NOT EXISTS `assigned_task_states` (
+ `id` int NOT NULL AUTO_INCREMENT,
+ `state` int NOT NULL,
+ `task_type` int NOT NULL,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
+SQL);
+
+ $pdo->exec(<<<'SQL'
+CREATE TABLE IF NOT EXISTS `auth_tokens` (
+ `id` int NOT NULL AUTO_INCREMENT,
+ `user_id` int NOT NULL,
+ `token_hash` char(64) COLLATE utf8mb4_0900_bin NOT NULL,
+ `expires_at` datetime NOT NULL,
+ `revoked_at` datetime DEFAULT NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `last_used_at` datetime DEFAULT NULL,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
+SQL);
+
+ $pdo->exec(<<<'SQL'
+CREATE TABLE IF NOT EXISTS `comments` (
+ `id` int NOT NULL AUTO_INCREMENT,
+ `response_to` int DEFAULT NULL,
+ `task_id` varchar(26) COLLATE utf8mb4_0900_bin NOT NULL,
+ `commenter` int NOT NULL,
+ `comment` text COLLATE utf8mb4_0900_bin NOT NULL,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
+SQL);
+
+ $pdo->exec(<<<'SQL'
+CREATE TABLE IF NOT EXISTS `custom_task_fields` (
+ `id` int NOT NULL AUTO_INCREMENT,
+ `task_type` int NOT NULL,
+ `name` varchar(128) COLLATE utf8mb4_0900_bin NOT NULL,
+ `type` varchar(128) COLLATE utf8mb4_0900_bin NOT NULL,
+ `default_value` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
+SQL);
+
+ $pdo->exec(<<<'SQL'
+CREATE TABLE IF NOT EXISTS `custom_field_values` (
+ `id` int NOT NULL AUTO_INCREMENT,
+ `field_id` int NOT NULL,
+ `task_id` varchar(26) COLLATE utf8mb4_0900_bin NOT NULL,
+ `value` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
+SQL);
+
+ $pdo->exec(<<<'SQL'
+CREATE TABLE IF NOT EXISTS `remember_tokens` (
+ `id` int NOT NULL AUTO_INCREMENT,
+ `user_id` int NOT NULL,
+ `selector` char(32) COLLATE utf8mb4_0900_bin NOT NULL,
+ `token_hash` char(64) COLLATE utf8mb4_0900_bin NOT NULL,
+ `expires_at` datetime NOT NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `last_used_at` datetime DEFAULT NULL,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
+SQL);
+
+ $pdo->exec(<<<'SQL'
+CREATE TABLE IF NOT EXISTS `rights` (
+ `id` int NOT NULL AUTO_INCREMENT,
+ `name` varchar(128) COLLATE utf8mb4_0900_bin NOT NULL,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
+SQL);
+
+ $pdo->exec(<<<'SQL'
+CREATE TABLE IF NOT EXISTS `settings` (
+ `id` int NOT NULL AUTO_INCREMENT,
+ `setting_name` varchar(128) COLLATE utf8mb4_0900_bin NOT NULL,
+ `setting_value` varchar(256) COLLATE utf8mb4_0900_bin NOT NULL,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
+SQL);
+
+ $pdo->exec(<<<'SQL'
+CREATE TABLE IF NOT EXISTS `task_state_transitions` (
+ `id` int NOT NULL AUTO_INCREMENT,
+ `from_id` int NOT NULL,
+ `to_id` int NOT NULL,
+ `action_name` varchar(128) COLLATE utf8mb4_0900_bin NOT NULL,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
+SQL);
+
+ $pdo->exec(<<<'SQL'
+CREATE TABLE IF NOT EXISTS `user_access` (
+ `id` int NOT NULL AUTO_INCREMENT,
+ `user_id` int NOT NULL,
+ `project_id` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
+SQL);
+
+ $pdo->exec(<<<'SQL'
+CREATE TABLE IF NOT EXISTS `user_rights` (
+ `id` int NOT NULL AUTO_INCREMENT,
+ `user_id` int NOT NULL,
+ `right_id` int NOT NULL,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
+SQL);
+
+ $pdo->exec(<<<'SQL'
+CREATE TABLE IF NOT EXISTS `user_settings` (
+ `id` int NOT NULL AUTO_INCREMENT,
+ `user_id` int NOT NULL,
+ `setting_name` varchar(128) COLLATE utf8mb4_0900_bin NOT NULL,
+ `setting_value` varchar(256) COLLATE utf8mb4_0900_bin NOT NULL,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
+SQL);
+
+ $indexes = [
+ ['assigned_task_states', 'state', 'KEY `state` (`state`)'],
+ ['assigned_task_states', 'task_type', 'KEY `task_type` (`task_type`)'],
+ ['auth_tokens', 'unique_token_hash', 'UNIQUE KEY `unique_token_hash` (`token_hash`)'],
+ ['auth_tokens', 'user_id', 'KEY `user_id` (`user_id`)'],
+ ['comments', 'response_to', 'KEY `response_to` (`response_to`)'],
+ ['comments', 'task_id', 'KEY `task_id` (`task_id`)'],
+ ['comments', 'commenter', 'KEY `commenter` (`commenter`)'],
+ ['custom_field_values', 'field_id', 'KEY `field_id` (`field_id`)'],
+ ['custom_field_values', 'task_id', 'KEY `task_id` (`task_id`)'],
+ ['custom_task_fields', 'task_type', 'KEY `task_type` (`task_type`)'],
+ ['projects', 'owner', 'KEY `owner` (`owner`)'],
+ ['remember_tokens', 'unique_selector', 'UNIQUE KEY `unique_selector` (`selector`)'],
+ ['remember_tokens', 'user_id', 'KEY `user_id` (`user_id`)'],
+ ['tasks', 'project', 'KEY `project` (`project`)'],
+ ['tasks', 'reporter', 'KEY `reporter` (`reporter`,`assignee`,`fix_version`,`type`,`priority`)'],
+ ['tasks', 'type', 'KEY `type` (`type`)'],
+ ['tasks', 'priority', 'KEY `priority` (`priority`)'],
+ ['tasks', 'assignee', 'KEY `assignee` (`assignee`)'],
+ ['tasks', 'fix_version', 'KEY `fix_version` (`fix_version`)'],
+ ['tasks', 'status', 'KEY `status` (`status`)'],
+ ['task_state_transitions', 'from_id', 'KEY `from_id` (`from_id`)'],
+ ['task_state_transitions', 'to_id', 'KEY `to_id` (`to_id`)'],
+ ['task_types', 'default_state', 'KEY `default_state` (`default_state`)'],
+ ['users', 'unique_email', 'UNIQUE KEY `unique_email` (`email`)'],
+ ['user_access', 'user_id', 'KEY `user_id` (`user_id`)'],
+ ['user_access', 'project_id', 'KEY `project_id` (`project_id`)'],
+ ['user_rights', 'user_id', 'KEY `user_id` (`user_id`)'],
+ ['user_rights', 'right_id', 'KEY `right_id` (`right_id`)'],
+ ['user_settings', 'user_id', 'KEY `user_id` (`user_id`)'],
+ ['versions', 'project', 'KEY `project` (`project`)'],
+ ];
+
+ foreach ($indexes as [$table, $index, $definition]) {
+ addIndexIfMissing($pdo, $table, $index, $definition);
+ }
+
+ upsert(
+ $pdo,
+ "INSERT INTO `settings` (`id`, `setting_name`, `setting_value`)
+ VALUES (1, 'version', '0.0.1')
+ ON DUPLICATE KEY UPDATE `setting_name` = VALUES(`setting_name`), `setting_value` = VALUES(`setting_value`)",
+ []
+ );
+
+ $states = [
+ [1, 'Open', '#858e99'],
+ [2, 'In Progress', '#3384e1'],
+ [3, 'Resolved', '#1a7f21'],
+ [4, 'Reopened', '#8693a2'],
+ [5, 'Closed', '#3c810e'],
+ ];
+
+ foreach ($states as $state) {
+ upsert(
+ $pdo,
+ "INSERT INTO `task_states` (`id`, `name`, `color`)
+ VALUES (?, ?, ?)
+ ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `color` = VALUES(`color`)",
+ $state
+ );
+ }
+
+ $priorities = [
+ [1, 'Critical', 'images_task_priority/1_critical.svg'],
+ [2, 'Heigh', 'images_task_priority/2_heigh.svg'],
+ [3, 'Medium', 'images_task_priority/3_medium.svg'],
+ [4, 'Low', 'images_task_priority/4_low.svg'],
+ [5, 'Trivial', 'images_task_priority/5_trivial.svg'],
+ ];
+
+ foreach ($priorities as [$id, $name, $asset]) {
+ upsert(
+ $pdo,
+ "INSERT INTO `task_priorities` (`id`, `name`, `logo`)
+ VALUES (?, ?, ?)
+ ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `logo` = VALUES(`logo`)",
+ [$id, $name, assetBlob($asset)]
+ );
+ }
+
+ $types = [
+ [1, 'Unknown', 'images_task_types/unknown.svg', 1],
+ [2, 'Bug', 'images_task_types/bug.svg', 1],
+ [3, 'New Feature', 'images_task_types/new_feature.svg', 1],
+ [4, 'Improvement', 'images_task_types/improvement.svg', 1],
+ [5, 'Task', 'images_task_types/task.svg', 1],
+ ];
+
+ foreach ($types as [$id, $name, $asset, $defaultState]) {
+ upsert(
+ $pdo,
+ "INSERT INTO `task_types` (`id`, `name`, `logo`, `default_state`)
+ VALUES (?, ?, ?, ?)
+ ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `logo` = VALUES(`logo`), `default_state` = VALUES(`default_state`)",
+ [$id, $name, assetBlob($asset), $defaultState]
+ );
+ }
+
+ $transitions = [
+ [1, 1, 2, 'Start Work'],
+ [2, 2, 3, 'Resolve Issue'],
+ [3, 3, 4, 'Reopen Issue'],
+ [4, 4, 2, 'Start Work'],
+ [5, 1, 5, 'Close'],
+ [6, 2, 5, 'Close'],
+ [7, 2, 1, 'Stop Work'],
+ [8, 5, 4, 'Reopen Issue'],
+ ];
+
+ foreach ($transitions as $transition) {
+ upsert(
+ $pdo,
+ "INSERT INTO `task_state_transitions` (`id`, `from_id`, `to_id`, `action_name`)
+ VALUES (?, ?, ?, ?)
+ ON DUPLICATE KEY UPDATE `from_id` = VALUES(`from_id`), `to_id` = VALUES(`to_id`), `action_name` = VALUES(`action_name`)",
+ $transition
+ );
+ }
+
+ $assignedStates = [
+ [1, 1, 5], [2, 2, 5], [3, 3, 5], [4, 5, 5], [5, 4, 5],
+ [6, 1, 1],
+ [7, 1, 2], [8, 2, 2], [9, 3, 2], [10, 4, 2], [11, 5, 2],
+ [12, 1, 3], [13, 2, 3], [14, 3, 3], [15, 4, 3], [16, 5, 3],
+ [17, 1, 4], [18, 2, 4], [19, 3, 4], [20, 4, 4], [21, 5, 4],
+ ];
+
+ foreach ($assignedStates as $assignedState) {
+ upsert(
+ $pdo,
+ "INSERT INTO `assigned_task_states` (`id`, `state`, `task_type`)
+ VALUES (?, ?, ?)
+ ON DUPLICATE KEY UPDATE `state` = VALUES(`state`), `task_type` = VALUES(`task_type`)",
+ $assignedState
+ );
+ }
+
+ $rights = [
+ [1, 'Admin'],
+ [2, 'Create Tasks'],
+ [3, 'Edit Tasks'],
+ [4, 'Create Versions'],
+ [5, 'Edit Versions'],
+ [6, 'Create Projects'],
+ [7, 'Edit Projects'],
+ ];
+
+ foreach ($rights as $right) {
+ upsert(
+ $pdo,
+ "INSERT INTO `rights` (`id`, `name`)
+ VALUES (?, ?)
+ ON DUPLICATE KEY UPDATE `name` = VALUES(`name`)",
+ $right
+ );
+ }
+
+ $pdo->exec('SET FOREIGN_KEY_CHECKS = 1');
+
+ $foreignKeys = [
+ ['assigned_task_states_ibfk_1', 'ALTER TABLE `assigned_task_states` ADD CONSTRAINT `assigned_task_states_ibfk_1` FOREIGN KEY (`task_type`) REFERENCES `task_types` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'],
+ ['assigned_task_states_ibfk_2', 'ALTER TABLE `assigned_task_states` ADD CONSTRAINT `assigned_task_states_ibfk_2` FOREIGN KEY (`state`) REFERENCES `task_states` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'],
+ ['auth_tokens_ibfk_1', 'ALTER TABLE `auth_tokens` ADD CONSTRAINT `auth_tokens_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE'],
+ ['comments_ibfk_1', 'ALTER TABLE `comments` ADD CONSTRAINT `comments_ibfk_1` FOREIGN KEY (`commenter`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'],
+ ['comments_ibfk_2', 'ALTER TABLE `comments` ADD CONSTRAINT `comments_ibfk_2` FOREIGN KEY (`task_id`) REFERENCES `tasks` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'],
+ ['comments_ibfk_3', 'ALTER TABLE `comments` ADD CONSTRAINT `comments_ibfk_3` FOREIGN KEY (`response_to`) REFERENCES `comments` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'],
+ ['custom_field_values_ibfk_1', 'ALTER TABLE `custom_field_values` ADD CONSTRAINT `custom_field_values_ibfk_1` FOREIGN KEY (`field_id`) REFERENCES `custom_task_fields` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'],
+ ['custom_field_values_ibfk_2', 'ALTER TABLE `custom_field_values` ADD CONSTRAINT `custom_field_values_ibfk_2` FOREIGN KEY (`task_id`) REFERENCES `tasks` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'],
+ ['custom_task_fields_ibfk_1', 'ALTER TABLE `custom_task_fields` ADD CONSTRAINT `custom_task_fields_ibfk_1` FOREIGN KEY (`task_type`) REFERENCES `task_types` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'],
+ ['projects_ibfk_1', 'ALTER TABLE `projects` ADD CONSTRAINT `projects_ibfk_1` FOREIGN KEY (`owner`) REFERENCES `users` (`id`) ON UPDATE CASCADE'],
+ ['remember_tokens_ibfk_1', 'ALTER TABLE `remember_tokens` ADD CONSTRAINT `remember_tokens_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE'],
+ ['tasks_ibfk_1', 'ALTER TABLE `tasks` ADD CONSTRAINT `tasks_ibfk_1` FOREIGN KEY (`type`) REFERENCES `task_types` (`id`) ON UPDATE CASCADE'],
+ ['tasks_ibfk_2', 'ALTER TABLE `tasks` ADD CONSTRAINT `tasks_ibfk_2` FOREIGN KEY (`priority`) REFERENCES `task_priorities` (`id`) ON UPDATE CASCADE'],
+ ['tasks_ibfk_3', 'ALTER TABLE `tasks` ADD CONSTRAINT `tasks_ibfk_3` FOREIGN KEY (`reporter`) REFERENCES `users` (`id`) ON UPDATE CASCADE'],
+ ['tasks_ibfk_4', 'ALTER TABLE `tasks` ADD CONSTRAINT `tasks_ibfk_4` FOREIGN KEY (`assignee`) REFERENCES `users` (`id`) ON UPDATE CASCADE'],
+ ['tasks_ibfk_5', 'ALTER TABLE `tasks` ADD CONSTRAINT `tasks_ibfk_5` FOREIGN KEY (`project`) REFERENCES `projects` (`id`) ON UPDATE CASCADE'],
+ ['tasks_ibfk_6', 'ALTER TABLE `tasks` ADD CONSTRAINT `tasks_ibfk_6` FOREIGN KEY (`fix_version`) REFERENCES `versions` (`id`) ON UPDATE CASCADE'],
+ ['tasks_ibfk_7', 'ALTER TABLE `tasks` ADD CONSTRAINT `tasks_ibfk_7` FOREIGN KEY (`status`) REFERENCES `task_states` (`id`) ON DELETE SET NULL ON UPDATE CASCADE'],
+ ['task_state_transitions_ibfk_1', 'ALTER TABLE `task_state_transitions` ADD CONSTRAINT `task_state_transitions_ibfk_1` FOREIGN KEY (`to_id`) REFERENCES `task_states` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'],
+ ['task_state_transitions_ibfk_2', 'ALTER TABLE `task_state_transitions` ADD CONSTRAINT `task_state_transitions_ibfk_2` FOREIGN KEY (`from_id`) REFERENCES `task_states` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'],
+ ['task_types_ibfk_1', 'ALTER TABLE `task_types` ADD CONSTRAINT `task_types_ibfk_1` FOREIGN KEY (`default_state`) REFERENCES `task_states` (`id`) ON DELETE SET NULL ON UPDATE CASCADE'],
+ ['user_access_ibfk_1', 'ALTER TABLE `user_access` ADD CONSTRAINT `user_access_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'],
+ ['user_access_ibfk_2', 'ALTER TABLE `user_access` ADD CONSTRAINT `user_access_ibfk_2` FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'],
+ ['user_rights_ibfk_1', 'ALTER TABLE `user_rights` ADD CONSTRAINT `user_rights_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'],
+ ['user_rights_ibfk_2', 'ALTER TABLE `user_rights` ADD CONSTRAINT `user_rights_ibfk_2` FOREIGN KEY (`right_id`) REFERENCES `rights` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'],
+ ['user_settings_ibfk_1', 'ALTER TABLE `user_settings` ADD CONSTRAINT `user_settings_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'],
+ ['versions_ibfk_1', 'ALTER TABLE `versions` ADD CONSTRAINT `versions_ibfk_1` FOREIGN KEY (`project`) REFERENCES `projects` (`id`) ON UPDATE CASCADE'],
+ ];
+
+ foreach ($foreignKeys as [$constraint, $definition]) {
+ addForeignKeyIfMissing($pdo, $constraint, $definition);
+ }
+
+ if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
+ header('Location: install.php');
+ exit;
+ }
+} catch (Throwable $exception) {
+ try {
+ $pdo->exec('SET FOREIGN_KEY_CHECKS = 1');
+ } catch (Throwable) {
+ }
+
+ if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '') !== __FILE__) {
+ throw $exception;
+ }
+
+ http_response_code(500);
+ echo 'ProjectKiln install failed';
+ echo 'Database install failed
';
+ echo '' . htmlspecialchars($exception->getMessage(), ENT_QUOTES, 'UTF-8') . '
';
+}