made installer and seperated stuff into diferent files
This commit is contained in:
@@ -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 {
|
.admin-view {
|
||||||
max-width: 1120px;
|
max-width: 1120px;
|
||||||
}
|
}
|
||||||
@@ -812,24 +435,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 700px) {
|
@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 {
|
.admin-layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -854,13 +459,4 @@
|
|||||||
.admin-custom-field-item {
|
.admin-custom-field-item {
|
||||||
grid-template-columns: 1fr;
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
13
ProjectKiln/app/css/viewer/dashboard.css
Normal file
13
ProjectKiln/app/css/viewer/dashboard.css
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
.viewer-placeholder {
|
||||||
|
min-height: 100%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px dashed var(--bs-border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-placeholder h1 {
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
}
|
||||||
81
ProjectKiln/app/css/viewer/profile.css
Normal file
81
ProjectKiln/app/css/viewer/profile.css
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
.profile-view {
|
||||||
|
max-width: 860px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header {
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 240px minmax(0, 1fr);
|
||||||
|
gap: 32px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar-panel,
|
||||||
|
.profile-form-panel {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar-preview {
|
||||||
|
display: grid;
|
||||||
|
width: 132px;
|
||||||
|
height: 132px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
place-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--bs-secondary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar-preview img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar-fallback {
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-size: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-picture-button {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-remove-picture {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-status {
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.profile-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
3
ProjectKiln/app/css/viewer/project.css
Normal file
3
ProjectKiln/app/css/viewer/project.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.project-view {
|
||||||
|
max-width: 1120px;
|
||||||
|
}
|
||||||
150
ProjectKiln/app/css/viewer/shared.css
Normal file
150
ProjectKiln/app/css/viewer/shared.css
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
.viewer-key {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-description {
|
||||||
|
min-height: 72px;
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-title {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin: -4px -6px 0;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 340px;
|
||||||
|
gap: 32px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-panel {
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-inline-field {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 5px 7px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-title:hover,
|
||||||
|
.version-title:focus,
|
||||||
|
.version-inline-field:hover,
|
||||||
|
.version-inline-field:focus {
|
||||||
|
border-color: var(--bs-border-color);
|
||||||
|
background: var(--bs-secondary-bg);
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-title.is-readonly:hover,
|
||||||
|
.version-title.is-readonly:focus,
|
||||||
|
.version-inline-field.is-readonly:hover,
|
||||||
|
.version-inline-field.is-readonly:focus {
|
||||||
|
border-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-inline-field.is-empty {
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-inline-field.is-saving {
|
||||||
|
opacity: 0.65;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-inline-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-inline-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-inline-actions .btn {
|
||||||
|
display: inline-grid;
|
||||||
|
width: 36px;
|
||||||
|
height: 32px;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-inline-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120px minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.version-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-detail-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
168
ProjectKiln/app/css/viewer/task_list.css
Normal file
168
ProjectKiln/app/css/viewer/task_list.css
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
.task-list-view {
|
||||||
|
max-width: 1120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-task-table {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-task-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns:
|
||||||
|
minmax(92px, 0.7fr)
|
||||||
|
minmax(180px, 1.5fr)
|
||||||
|
minmax(120px, 0.9fr)
|
||||||
|
minmax(130px, 0.9fr)
|
||||||
|
minmax(150px, 1.1fr)
|
||||||
|
minmax(120px, 0.85fr);
|
||||||
|
gap: 14px;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-task-head {
|
||||||
|
border-bottom: 1px solid var(--bs-border-color);
|
||||||
|
background: var(--bs-secondary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-task-head button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 7px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
text-align: left;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-task-head button:hover,
|
||||||
|
.version-task-head button:focus,
|
||||||
|
.version-task-head button.is-active {
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-task-head button i {
|
||||||
|
width: 12px;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-task-item {
|
||||||
|
min-height: 50px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid var(--bs-border-color);
|
||||||
|
background: var(--bs-body-bg);
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-task-item:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-task-item:hover,
|
||||||
|
.version-task-item:focus {
|
||||||
|
background: var(--bs-secondary-bg);
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-task-id {
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-task-title {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-task-loading {
|
||||||
|
padding: 14px;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-task-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 26px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--task-status-color, var(--color-accent)) 55%, var(--bs-border-color));
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--task-status-color, var(--color-accent)) 16%, transparent);
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-task-pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 36px;
|
||||||
|
margin-top: 10px;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-task-page-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-task-page-actions .btn {
|
||||||
|
display: inline-grid;
|
||||||
|
width: 32px;
|
||||||
|
height: 30px;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.task-list-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-task-table {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-task-row {
|
||||||
|
grid-template-columns: 100px 190px 130px 140px 170px 130px;
|
||||||
|
min-width: 870px;
|
||||||
|
}
|
||||||
|
}
|
||||||
7
ProjectKiln/app/css/viewer/version.css
Normal file
7
ProjectKiln/app/css/viewer/version.css
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.version-view {
|
||||||
|
max-width: 1120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-task-section {
|
||||||
|
max-width: 1080px;
|
||||||
|
}
|
||||||
@@ -1,12 +1,27 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
$pageStyles[] = 'app/css/home.css';
|
$pageStyles[] = 'app/css/home.css';
|
||||||
$pageStyles[] = 'app/css/viewer.css';
|
$pageStyles[] = 'app/css/viewer/shared.css';
|
||||||
|
$pageStyles[] = 'app/css/viewer/dashboard.css';
|
||||||
|
$pageStyles[] = 'app/css/viewer/project.css';
|
||||||
|
$pageStyles[] = 'app/css/viewer/task_list.css';
|
||||||
$pageStyles[] = 'app/css/viewer/task.css';
|
$pageStyles[] = 'app/css/viewer/task.css';
|
||||||
|
$pageStyles[] = 'app/css/viewer/version.css';
|
||||||
|
$pageStyles[] = 'app/css/viewer/profile.css';
|
||||||
|
$pageStyles[] = 'app/css/viewer/admin.css';
|
||||||
$pageStyles[] = 'app/css/popups.css';
|
$pageStyles[] = 'app/css/popups.css';
|
||||||
|
|
||||||
$pageScripts[] = 'https://unpkg.com/cytoscape@3.34.0/dist/cytoscape.min.js';
|
$pageScripts[] = 'https://unpkg.com/cytoscape@3.34.0/dist/cytoscape.min.js';
|
||||||
$pageScripts[] = 'app/js/home.js';
|
$pageScripts[] = 'app/js/home/core.js';
|
||||||
|
$pageScripts[] = 'app/js/home/dashboard.js';
|
||||||
|
$pageScripts[] = 'app/js/home/project.js';
|
||||||
|
$pageScripts[] = 'app/js/home/task_list.js';
|
||||||
|
$pageScripts[] = 'app/js/home/task.js';
|
||||||
|
$pageScripts[] = 'app/js/home/version.js';
|
||||||
|
$pageScripts[] = 'app/js/home/profile.js';
|
||||||
|
$pageScripts[] = 'app/js/home/admin.js';
|
||||||
|
$pageScripts[] = 'app/js/home/router.js';
|
||||||
|
$pageScripts[] = 'app/js/home/events.js';
|
||||||
$pageScripts[] = 'app/js/popups.js';
|
$pageScripts[] = 'app/js/popups.js';
|
||||||
|
|
||||||
$user = requireLogin();
|
$user = requireLogin();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
745
ProjectKiln/app/js/home/admin.js
Normal file
745
ProjectKiln/app/js/home/admin.js
Normal file
@@ -0,0 +1,745 @@
|
|||||||
|
async function loadWorkflowEditor() {
|
||||||
|
const result = await apiGet('/api/workflow.php', {
|
||||||
|
api: 'WorkflowData',
|
||||||
|
_: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
document.getElementById('workflowGraph').innerHTML = '<div class="alert alert-danger m-3">Could not load workflow data.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
workflowData = result.workflow;
|
||||||
|
if (!selectedWorkflowTaskType && workflowData.task_types.length) {
|
||||||
|
selectedWorkflowTaskType = String(workflowData.task_types[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedWorkflowTaskType && !workflowData.task_types.some((type) => String(type.id) === String(selectedWorkflowTaskType))) {
|
||||||
|
selectedWorkflowTaskType = workflowData.task_types.length ? String(workflowData.task_types[0].id) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderWorkflowEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWorkflowEditor() {
|
||||||
|
if (!workflowData) return;
|
||||||
|
|
||||||
|
populateWorkflowSelects();
|
||||||
|
renderWorkflowLists();
|
||||||
|
renderWorkflowGraph();
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateWorkflowSelects() {
|
||||||
|
const stateOptions = workflowData.states.map((state) => ({
|
||||||
|
id: state.id,
|
||||||
|
name: state.name
|
||||||
|
}));
|
||||||
|
|
||||||
|
populateSelect(document.getElementById('workflowTransitionFrom'), stateOptions, {
|
||||||
|
placeholder: 'From state'
|
||||||
|
});
|
||||||
|
populateSelect(document.getElementById('workflowTransitionTo'), stateOptions, {
|
||||||
|
placeholder: 'To state'
|
||||||
|
});
|
||||||
|
populateSelect(document.getElementById('workflowAssignmentState'), stateOptions, {
|
||||||
|
placeholder: 'State'
|
||||||
|
});
|
||||||
|
populateSelect(document.getElementById('workflowTaskTypeSelect'), workflowData.task_types, {
|
||||||
|
placeholder: 'Task type',
|
||||||
|
selectedValue: selectedWorkflowTaskType ?? ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const assignedStates = getSelectedWorkflowAssignments()
|
||||||
|
.map((assignment) => workflowData.states.find((state) => Number(state.id) === Number(assignment.state)))
|
||||||
|
.filter(Boolean);
|
||||||
|
const selectedType = getSelectedWorkflowTaskType();
|
||||||
|
|
||||||
|
populateSelect(document.getElementById('workflowDefaultStateSelect'), assignedStates, {
|
||||||
|
placeholder: 'No default state',
|
||||||
|
selectedValue: selectedType?.default_state ?? ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWorkflowLists() {
|
||||||
|
const statesById = new Map(workflowData.states.map((state) => [Number(state.id), state]));
|
||||||
|
const selectedAssignments = getSelectedWorkflowAssignments();
|
||||||
|
|
||||||
|
const stateList = document.getElementById('workflowStateList');
|
||||||
|
stateList.innerHTML = workflowData.states.map((state) => `
|
||||||
|
<div class="workflow-list-item">
|
||||||
|
<div class="workflow-list-main">
|
||||||
|
<span class="workflow-color-dot" style="background: ${escapeHtml(state.color)}"></span>
|
||||||
|
<span>${escapeHtml(state.name)}</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" type="button" data-workflow-delete-state="${escapeHtml(state.id)}">
|
||||||
|
<i class="fa-solid fa-trash-can"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`).join('') || '<div class="text-secondary">No states yet.</div>';
|
||||||
|
|
||||||
|
const transitionList = document.getElementById('workflowTransitionList');
|
||||||
|
transitionList.innerHTML = workflowData.transitions.map((transition) => `
|
||||||
|
<div class="workflow-list-item">
|
||||||
|
<div class="workflow-list-main">
|
||||||
|
<span>${escapeHtml(statesById.get(Number(transition.from_id))?.name ?? transition.from_id)}</span>
|
||||||
|
<i class="fa-solid fa-arrow-right"></i>
|
||||||
|
<span>${escapeHtml(statesById.get(Number(transition.to_id))?.name ?? transition.to_id)}</span>
|
||||||
|
<span class="text-secondary">${escapeHtml(transition.action_name)}</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" type="button" data-workflow-delete-transition="${escapeHtml(transition.id)}">
|
||||||
|
<i class="fa-solid fa-trash-can"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`).join('') || '<div class="text-secondary">No transitions yet.</div>';
|
||||||
|
|
||||||
|
const assignmentList = document.getElementById('workflowAssignmentList');
|
||||||
|
assignmentList.innerHTML = selectedAssignments.map((assignment) => `
|
||||||
|
<div class="workflow-list-item">
|
||||||
|
<div class="workflow-list-main">
|
||||||
|
<span>${escapeHtml(statesById.get(Number(assignment.state))?.name ?? assignment.state)}</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" type="button" data-workflow-remove-assignment="${escapeHtml(assignment.id)}">
|
||||||
|
<i class="fa-solid fa-trash-can"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`).join('') || '<div class="text-secondary">No assigned states yet.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedWorkflowTaskType() {
|
||||||
|
return workflowData?.task_types.find((type) => String(type.id) === String(selectedWorkflowTaskType)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedWorkflowAssignments() {
|
||||||
|
if (!selectedWorkflowTaskType) return [];
|
||||||
|
|
||||||
|
return workflowData.assignments.filter((assignment) => String(assignment.task_type) === String(selectedWorkflowTaskType));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWorkflowGraph() {
|
||||||
|
const graph = document.getElementById('workflowGraph');
|
||||||
|
const empty = document.getElementById('workflowGraphEmpty');
|
||||||
|
|
||||||
|
if (!graph || !empty) return;
|
||||||
|
|
||||||
|
const assignedStateIds = new Set(getSelectedWorkflowAssignments().map((assignment) => Number(assignment.state)));
|
||||||
|
const visibleStates = workflowData.states.filter((state) => assignedStateIds.has(Number(state.id)));
|
||||||
|
const visibleTransitions = workflowData.transitions.filter((transition) => (
|
||||||
|
assignedStateIds.has(Number(transition.from_id))
|
||||||
|
&& assignedStateIds.has(Number(transition.to_id))
|
||||||
|
));
|
||||||
|
|
||||||
|
if (!visibleStates.length) {
|
||||||
|
if (workflowGraph) {
|
||||||
|
workflowGraph.destroy();
|
||||||
|
workflowGraph = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
graph.innerHTML = '';
|
||||||
|
empty.hidden = false;
|
||||||
|
empty.textContent = selectedWorkflowTaskType
|
||||||
|
? 'Assign states to this task type to render its workflow graph.'
|
||||||
|
: 'Select a task type to render its workflow graph.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
empty.hidden = true;
|
||||||
|
|
||||||
|
if (typeof cytoscape !== 'function') {
|
||||||
|
graph.innerHTML = '<div class="alert alert-warning m-3">Cytoscape.js could not be loaded.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elements = [
|
||||||
|
...visibleStates.map((state) => ({
|
||||||
|
data: {
|
||||||
|
id: `state-${state.id}`,
|
||||||
|
label: state.name,
|
||||||
|
color: state.color
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
...visibleTransitions.map((transition) => ({
|
||||||
|
data: {
|
||||||
|
id: `transition-${transition.id}`,
|
||||||
|
source: `state-${transition.from_id}`,
|
||||||
|
target: `state-${transition.to_id}`,
|
||||||
|
label: transition.action_name
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
const styles = getComputedStyle(document.documentElement);
|
||||||
|
const borderColor = styles.getPropertyValue('--bs-border-color').trim() || '#3a3f46';
|
||||||
|
const bodyColor = styles.getPropertyValue('--bs-body-color').trim() || '#f3f4f6';
|
||||||
|
const secondaryColor = styles.getPropertyValue('--bs-secondary-color').trim() || '#aeb7c4';
|
||||||
|
|
||||||
|
if (workflowGraph) {
|
||||||
|
workflowGraph.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
workflowGraph = cytoscape({
|
||||||
|
container: graph,
|
||||||
|
elements,
|
||||||
|
style: [
|
||||||
|
{
|
||||||
|
selector: 'node',
|
||||||
|
style: {
|
||||||
|
'background-color': 'data(color)',
|
||||||
|
'border-color': borderColor,
|
||||||
|
'border-width': 1,
|
||||||
|
'color': bodyColor,
|
||||||
|
'font-size': 13,
|
||||||
|
'font-weight': 700,
|
||||||
|
'label': 'data(label)',
|
||||||
|
'text-valign': 'center',
|
||||||
|
'text-halign': 'center',
|
||||||
|
'text-wrap': 'wrap',
|
||||||
|
'text-max-width': 90,
|
||||||
|
'width': 86,
|
||||||
|
'height': 42,
|
||||||
|
'shape': 'round-rectangle'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'edge',
|
||||||
|
style: {
|
||||||
|
'curve-style': 'bezier',
|
||||||
|
'target-arrow-shape': 'triangle',
|
||||||
|
'line-color': secondaryColor,
|
||||||
|
'target-arrow-color': secondaryColor,
|
||||||
|
'color': secondaryColor,
|
||||||
|
'font-size': 11,
|
||||||
|
'label': 'data(label)',
|
||||||
|
'text-rotation': 'autorotate',
|
||||||
|
'text-margin-y': -8,
|
||||||
|
'width': 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
layout: {
|
||||||
|
name: 'breadthfirst',
|
||||||
|
directed: true,
|
||||||
|
padding: 36,
|
||||||
|
spacingFactor: 1.25
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveWorkflowAction(api, body = {}, params = {}) {
|
||||||
|
const result = await apiPost('/api/workflow.php', {
|
||||||
|
api,
|
||||||
|
...params
|
||||||
|
}, body);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
alert(result.error || 'Could not save workflow change.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadWorkflowEditor();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAdminOptions() {
|
||||||
|
let result = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
result = await apiGet('/api/admin_options.php', {
|
||||||
|
api: 'OptionsData',
|
||||||
|
_: Date.now()
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
result = {
|
||||||
|
success: false,
|
||||||
|
error: 'Could not load admin options.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
const typeEditor = document.getElementById('adminSelectedTypeEditor');
|
||||||
|
const priorityList = document.getElementById('adminPriorityList');
|
||||||
|
const userRightsList = document.getElementById('adminUserRightsList');
|
||||||
|
|
||||||
|
if (typeEditor) {
|
||||||
|
typeEditor.innerHTML = '<div class="text-danger">Could not load task types.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (priorityList) {
|
||||||
|
priorityList.innerHTML = '<div class="text-danger">Could not load priorities.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userRightsList) {
|
||||||
|
userRightsList.innerHTML = '<div class="text-danger">Could not load users.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
adminOptionsData = {
|
||||||
|
types: result.options?.types ?? [],
|
||||||
|
priorities: result.options?.priorities ?? [],
|
||||||
|
custom_fields: result.options?.custom_fields ?? [],
|
||||||
|
users: result.options?.users ?? [],
|
||||||
|
rights: result.options?.rights ?? [],
|
||||||
|
user_rights: result.options?.user_rights ?? [],
|
||||||
|
projects: result.options?.projects ?? [],
|
||||||
|
user_access: result.options?.user_access ?? []
|
||||||
|
};
|
||||||
|
if (!selectedAdminTaskType && adminOptionsData.types.length) {
|
||||||
|
selectedAdminTaskType = String(adminOptionsData.types[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedAdminTaskType && !adminOptionsData.types.some((type) => String(type.id) === String(selectedAdminTaskType))) {
|
||||||
|
selectedAdminTaskType = adminOptionsData.types.length ? String(adminOptionsData.types[0].id) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedAdminUser && adminOptionsData.users.length) {
|
||||||
|
selectedAdminUser = String(adminOptionsData.users[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedAdminUser && !adminOptionsData.users.some((user) => String(user.id) === String(selectedAdminUser))) {
|
||||||
|
selectedAdminUser = adminOptionsData.users.length ? String(adminOptionsData.users[0].id) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAdminOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAdminOptions() {
|
||||||
|
if (!adminOptionsData) return;
|
||||||
|
|
||||||
|
const types = adminOptionsData.types ?? [];
|
||||||
|
const priorities = adminOptionsData.priorities ?? [];
|
||||||
|
|
||||||
|
populateSelect(document.getElementById('adminTaskTypeSelect'), types, {
|
||||||
|
placeholder: 'Select task type',
|
||||||
|
selectedValue: selectedAdminTaskType ?? ''
|
||||||
|
});
|
||||||
|
renderSelectedAdminTaskType();
|
||||||
|
renderAdminCustomFields();
|
||||||
|
renderAdminOptionList('priority', priorities, document.getElementById('adminPriorityList'));
|
||||||
|
renderAdminUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedAdminTaskType() {
|
||||||
|
return adminOptionsData?.types.find((type) => String(type.id) === String(selectedAdminTaskType)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSelectedAdminTaskType() {
|
||||||
|
const container = document.getElementById('adminSelectedTypeEditor');
|
||||||
|
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const type = getSelectedAdminTaskType();
|
||||||
|
|
||||||
|
if (!type) {
|
||||||
|
container.innerHTML = '<div class="text-secondary">Create or select a task type first.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<form class="admin-option-item admin-selected-option" data-admin-option-edit="type" data-option-id="${escapeHtml(type.id)}">
|
||||||
|
<div class="admin-option-icon">
|
||||||
|
${type.icon
|
||||||
|
? `<img src="${escapeHtml(type.icon)}" alt="">`
|
||||||
|
: '<i class="fa-solid fa-tag"></i>'}
|
||||||
|
</div>
|
||||||
|
<input class="form-control form-control-sm" name="name" value="${escapeHtml(type.name)}" required maxlength="20">
|
||||||
|
<input class="form-control form-control-sm" type="file" name="logo" accept=".svg,image/svg+xml" aria-label="Logo">
|
||||||
|
<label class="admin-option-remove-logo">
|
||||||
|
<input type="checkbox" name="remove_logo" value="1">
|
||||||
|
<span>Remove logo</span>
|
||||||
|
</label>
|
||||||
|
<div class="admin-option-actions">
|
||||||
|
<button class="btn btn-sm btn-primary" type="submit" title="Save">
|
||||||
|
<i class="fa-solid fa-check"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" type="button" data-admin-option-delete="type" data-option-id="${escapeHtml(type.id)}" title="Delete">
|
||||||
|
<i class="fa-solid fa-trash-can"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAdminCustomFields() {
|
||||||
|
const container = document.getElementById('adminCustomFieldList');
|
||||||
|
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (!selectedAdminTaskType) {
|
||||||
|
container.innerHTML = '<div class="text-secondary">Select a task type first.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = (adminOptionsData.custom_fields ?? [])
|
||||||
|
.filter((field) => String(field.task_type) === String(selectedAdminTaskType));
|
||||||
|
|
||||||
|
container.innerHTML = fields.map((field) => `
|
||||||
|
<form class="admin-custom-field-item" data-admin-custom-field-edit="${escapeHtml(field.id)}">
|
||||||
|
<input class="form-control form-control-sm" name="name" value="${escapeHtml(field.name)}" required maxlength="128">
|
||||||
|
<select class="form-select form-select-sm" name="type" required>
|
||||||
|
${customFieldTypes.map((type) => `
|
||||||
|
<option value="${escapeHtml(type.id)}" ${String(type.id) === String(field.type) ? 'selected' : ''}>${escapeHtml(type.name)}</option>
|
||||||
|
`).join('')}
|
||||||
|
</select>
|
||||||
|
<input class="form-control form-control-sm" name="value" value="${escapeHtml(field.raw_value ?? '')}">
|
||||||
|
<div class="admin-option-actions">
|
||||||
|
<button class="btn btn-sm btn-primary" type="submit" title="Save">
|
||||||
|
<i class="fa-solid fa-check"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" type="button" data-admin-custom-field-delete="${escapeHtml(field.id)}" title="Delete">
|
||||||
|
<i class="fa-solid fa-trash-can"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
`).join('') || '<div class="text-secondary">No custom fields yet.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAdminOptionList(kind, options, container) {
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = options.map((option) => `
|
||||||
|
<form class="admin-option-item" data-admin-option-edit="${escapeHtml(kind)}" data-option-id="${escapeHtml(option.id)}">
|
||||||
|
<div class="admin-option-icon">
|
||||||
|
${option.icon
|
||||||
|
? `<img src="${escapeHtml(option.icon)}" alt="">`
|
||||||
|
: '<i class="fa-solid fa-tag"></i>'}
|
||||||
|
</div>
|
||||||
|
<input class="form-control form-control-sm" name="name" value="${escapeHtml(option.name)}" required maxlength="128">
|
||||||
|
<input class="form-control form-control-sm" type="file" name="logo" accept=".svg,image/svg+xml" aria-label="Logo">
|
||||||
|
<label class="admin-option-remove-logo">
|
||||||
|
<input type="checkbox" name="remove_logo" value="1">
|
||||||
|
<span>Remove logo</span>
|
||||||
|
</label>
|
||||||
|
<div class="admin-option-actions">
|
||||||
|
<button class="btn btn-sm btn-primary" type="submit" title="Save">
|
||||||
|
<i class="fa-solid fa-check"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" type="button" data-admin-option-delete="${escapeHtml(kind)}" data-option-id="${escapeHtml(option.id)}" title="Delete">
|
||||||
|
<i class="fa-solid fa-trash-can"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
`).join('') || '<div class="text-secondary">No entries yet.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedAdminUser() {
|
||||||
|
return adminOptionsData?.users.find((user) => String(user.id) === String(selectedAdminUser)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function userHasAdminRight(userId, rightId) {
|
||||||
|
return (adminOptionsData?.user_rights ?? []).some((entry) => (
|
||||||
|
String(entry.user_id) === String(userId)
|
||||||
|
&& String(entry.right_id) === String(rightId)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAdminUserAccess(userId) {
|
||||||
|
return (adminOptionsData?.user_access ?? []).filter((entry) => (
|
||||||
|
String(entry.user_id) === String(userId)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAdminProject(projectId) {
|
||||||
|
return (adminOptionsData?.projects ?? []).find((project) => (
|
||||||
|
String(project.id) === String(projectId)
|
||||||
|
)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAdminUsers() {
|
||||||
|
const select = document.getElementById('adminUserSelect');
|
||||||
|
const container = document.getElementById('adminUserRightsList');
|
||||||
|
|
||||||
|
if (!select || !container) return;
|
||||||
|
|
||||||
|
const users = adminOptionsData.users ?? [];
|
||||||
|
const rights = adminOptionsData.rights ?? [];
|
||||||
|
const projects = adminOptionsData.projects ?? [];
|
||||||
|
const selectedUser = getSelectedAdminUser();
|
||||||
|
|
||||||
|
populateSelect(select, users, {
|
||||||
|
placeholder: 'Select user',
|
||||||
|
selectedValue: selectedAdminUser ?? ''
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!selectedUser) {
|
||||||
|
container.innerHTML = '<div class="text-secondary">Select a user first.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rights.length) {
|
||||||
|
container.innerHTML = '<div class="text-secondary">No rights configured yet.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const explicitAccess = getAdminUserAccess(selectedUser.id);
|
||||||
|
const explicitProjectIds = new Set(explicitAccess.map((entry) => String(entry.project_id)));
|
||||||
|
const ownerProjects = projects.filter((project) => String(project.owner) === String(selectedUser.id));
|
||||||
|
const ownerProjectIds = new Set(ownerProjects.map((project) => String(project.id)));
|
||||||
|
const addableProjects = projects.filter((project) => (
|
||||||
|
!explicitProjectIds.has(String(project.id))
|
||||||
|
&& !ownerProjectIds.has(String(project.id))
|
||||||
|
));
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="admin-user-summary">
|
||||||
|
${renderUser(selectedUser)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-user-section">
|
||||||
|
<h4>Rights</h4>
|
||||||
|
<div class="admin-right-list">
|
||||||
|
${rights.map((right) => {
|
||||||
|
const checked = userHasAdminRight(selectedUser.id, right.id);
|
||||||
|
const isSelfAdminRight = appFlag('isAdmin')
|
||||||
|
&& String(selectedUser.id) === String(getCurrentUserId())
|
||||||
|
&& String(right.name) === 'Admin';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<label class="admin-right-item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
data-admin-user-right
|
||||||
|
data-user-id="${escapeHtml(selectedUser.id)}"
|
||||||
|
data-right-id="${escapeHtml(right.id)}"
|
||||||
|
${checked ? 'checked' : ''}
|
||||||
|
${isSelfAdminRight ? 'disabled' : ''}
|
||||||
|
>
|
||||||
|
<span>${escapeHtml(right.name)}</span>
|
||||||
|
</label>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-user-section">
|
||||||
|
<h4>Project Access</h4>
|
||||||
|
<form class="admin-project-access-form" id="adminProjectAccessForm">
|
||||||
|
<select class="form-select form-select-sm" name="project_id" ${addableProjects.length ? '' : 'disabled'}>
|
||||||
|
${addableProjects.map((project) => `
|
||||||
|
<option value="${escapeHtml(project.id)}">${escapeHtml(project.name)} (${escapeHtml(project.id)})</option>
|
||||||
|
`).join('')}
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-sm btn-primary" type="submit" ${addableProjects.length ? '' : 'disabled'}>
|
||||||
|
<i class="fa-solid fa-plus me-1"></i>
|
||||||
|
Add Access
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="admin-project-access-list">
|
||||||
|
${explicitAccess.map((access) => {
|
||||||
|
const project = getAdminProject(access.project_id);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="admin-project-access-item">
|
||||||
|
<span>${escapeHtml(project?.name ?? access.project_id)} <small>${escapeHtml(access.project_id)}</small></span>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" type="button" data-admin-project-access-delete="${escapeHtml(access.id)}" title="Remove access">
|
||||||
|
<i class="fa-solid fa-trash-can"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('') || '<div class="text-secondary">No explicit project access yet.</div>'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${ownerProjects.length ? `
|
||||||
|
<div class="admin-project-owner-list">
|
||||||
|
<strong>Owner access</strong>
|
||||||
|
${ownerProjects.map((project) => `
|
||||||
|
<span>${escapeHtml(project.name)} <small>${escapeHtml(project.id)}</small></span>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setAdminUserRight(userId, rightId, enabled) {
|
||||||
|
const result = await apiPost('/api/admin_options.php', {
|
||||||
|
api: 'SetUserRight'
|
||||||
|
}, {
|
||||||
|
user_id: userId,
|
||||||
|
right_id: rightId,
|
||||||
|
enabled: enabled ? 1 : 0
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
alert(result.error || 'Could not update user right.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadAdminOptions();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function grantAdminProjectAccess(userId, projectId) {
|
||||||
|
const result = await apiPost('/api/admin_options.php', {
|
||||||
|
api: 'GrantProjectAccess'
|
||||||
|
}, {
|
||||||
|
user_id: userId,
|
||||||
|
project_id: projectId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
alert(result.error || 'Could not add project access.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadAdminOptions();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeAdminProjectAccess(accessId) {
|
||||||
|
const result = await apiPost('/api/admin_options.php', {
|
||||||
|
api: 'RevokeProjectAccess',
|
||||||
|
access_id: accessId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
alert(result.error || 'Could not remove project access.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadAdminOptions();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAdminOptionForm(kind, form, optionId = null) {
|
||||||
|
const formData = new FormData(form);
|
||||||
|
formData.set('kind', kind);
|
||||||
|
|
||||||
|
if (optionId) {
|
||||||
|
formData.set('id', optionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiPostForm('/api/admin_options.php', {
|
||||||
|
api: optionId ? 'UpdateOption' : 'CreateOption'
|
||||||
|
}, formData);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
alert(result.error || 'Could not save option.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'type' && !optionId && result.option_id) {
|
||||||
|
selectedAdminTaskType = String(result.option_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
taskLookupCache.types = null;
|
||||||
|
taskLookupCache.priorities = null;
|
||||||
|
await populateTaskOptionSelects();
|
||||||
|
await loadAdminOptions();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAdminOption(kind, optionId) {
|
||||||
|
const result = await apiPost('/api/admin_options.php', {
|
||||||
|
api: 'DeleteOption',
|
||||||
|
kind,
|
||||||
|
id: optionId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
alert(result.error || 'Could not delete option.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'type' && String(selectedAdminTaskType) === String(optionId)) {
|
||||||
|
selectedAdminTaskType = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
taskLookupCache.types = null;
|
||||||
|
taskLookupCache.priorities = null;
|
||||||
|
await populateTaskOptionSelects();
|
||||||
|
await loadAdminOptions();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAdminCustomFieldForm(form, fieldId = null) {
|
||||||
|
if (!selectedAdminTaskType) {
|
||||||
|
alert('Select a task type first.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = Object.fromEntries(new FormData(form));
|
||||||
|
data.task_type = selectedAdminTaskType;
|
||||||
|
|
||||||
|
if (fieldId) {
|
||||||
|
data.id = fieldId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiPost('/api/admin_options.php', {
|
||||||
|
api: fieldId ? 'UpdateCustomField' : 'CreateCustomField'
|
||||||
|
}, data);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
alert(result.error || 'Could not save custom field.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadAdminOptions();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAdminCustomField(fieldId) {
|
||||||
|
const result = await apiPost('/api/admin_options.php', {
|
||||||
|
api: 'DeleteCustomField',
|
||||||
|
field_id: fieldId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
alert(result.error || 'Could not delete custom field.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadAdminOptions();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAdmin(section = 'workflows', pushHistory = true) {
|
||||||
|
if (document.querySelector('.kiln-app')?.dataset.isAdmin !== '1') {
|
||||||
|
loadDashboard(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const view = showView('adminView');
|
||||||
|
|
||||||
|
if (!view) return;
|
||||||
|
|
||||||
|
if (!['workflows', 'options', 'users'].includes(section)) {
|
||||||
|
section = 'workflows';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-admin-section]').forEach((button) => {
|
||||||
|
button.classList.toggle('is-active', button.dataset.adminSection === section);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.admin-panel').forEach((panel) => {
|
||||||
|
panel.hidden = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const panelBySection = {
|
||||||
|
workflows: 'adminWorkflowsPanel',
|
||||||
|
options: 'adminOptionsPanel',
|
||||||
|
users: 'adminUsersPanel'
|
||||||
|
};
|
||||||
|
const panel = document.getElementById(panelBySection[section]);
|
||||||
|
if (panel) panel.hidden = false;
|
||||||
|
|
||||||
|
if (pushHistory) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('page', 'home');
|
||||||
|
url.searchParams.set('admin', section);
|
||||||
|
url.searchParams.delete('task');
|
||||||
|
url.searchParams.delete('project');
|
||||||
|
url.searchParams.delete('tasks');
|
||||||
|
url.searchParams.delete('version');
|
||||||
|
url.searchParams.delete('profile');
|
||||||
|
window.history.pushState({}, '', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (section === 'workflows') {
|
||||||
|
loadWorkflowEditor();
|
||||||
|
} else if (section === 'options' || section === 'users') {
|
||||||
|
loadAdminOptions();
|
||||||
|
}
|
||||||
|
}
|
||||||
524
ProjectKiln/app/js/home/core.js
Normal file
524
ProjectKiln/app/js/home/core.js
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
async function apiGet(path, params = {}) {
|
||||||
|
const url = new URL(path, window.location.origin);
|
||||||
|
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
url.searchParams.set(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
cache: 'no-store',
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-cache'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiPost(path, params = {}, body = {}) {
|
||||||
|
const url = new URL(path, window.location.origin);
|
||||||
|
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
url.searchParams.set(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
method: 'POST',
|
||||||
|
cache: 'no-store',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-cache'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API did not return JSON:', text);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'API did not return JSON. Check console for PHP error.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiPostForm(path, params = {}, formData) {
|
||||||
|
const url = new URL(path, window.location.origin);
|
||||||
|
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
url.searchParams.set(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
method: 'POST',
|
||||||
|
cache: 'no-store',
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-cache'
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API did not return JSON:', text);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'API did not return JSON. Check console for PHP error.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentTask = null;
|
||||||
|
let currentVersion = null;
|
||||||
|
let currentProject = null;
|
||||||
|
let currentProfileUser = null;
|
||||||
|
let currentVersionTasks = [];
|
||||||
|
let currentProjectTasks = [];
|
||||||
|
let currentProjectTaskProject = null;
|
||||||
|
let currentTaskComments = [];
|
||||||
|
let workflowData = null;
|
||||||
|
let workflowGraph = null;
|
||||||
|
let selectedWorkflowTaskType = null;
|
||||||
|
let adminOptionsData = null;
|
||||||
|
let selectedAdminTaskType = null;
|
||||||
|
let selectedAdminUser = null;
|
||||||
|
let projectTreePromise = null;
|
||||||
|
|
||||||
|
const versionTaskSort = {
|
||||||
|
field: 'id',
|
||||||
|
direction: 'asc'
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectTaskSort = {
|
||||||
|
field: 'id',
|
||||||
|
direction: 'asc'
|
||||||
|
};
|
||||||
|
|
||||||
|
const taskTablePageSize = 100;
|
||||||
|
let versionTaskPagination = null;
|
||||||
|
let projectTaskPagination = null;
|
||||||
|
|
||||||
|
const themeStyles = {
|
||||||
|
white: 'app/css/white_mode.css',
|
||||||
|
dark: 'app/css/dark_mode.css',
|
||||||
|
purple: 'app/css/purple_mode.css',
|
||||||
|
green: 'app/css/green_mode.css',
|
||||||
|
beige: 'app/css/beige_mode.css'
|
||||||
|
};
|
||||||
|
|
||||||
|
const customFieldTypes = [
|
||||||
|
{ id: 'string', name: 'String' },
|
||||||
|
{ id: 'int', name: 'Int' },
|
||||||
|
{ id: 'float', name: 'Float' },
|
||||||
|
{ id: 'date', name: 'Date' },
|
||||||
|
{ id: 'boolean', name: 'Boolean' },
|
||||||
|
{ id: 'text', name: 'Text' },
|
||||||
|
{ id: 'json', name: 'JSON' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const taskLookupCache = {
|
||||||
|
types: null,
|
||||||
|
priorities: null,
|
||||||
|
users: null,
|
||||||
|
versionsByProject: new Map(),
|
||||||
|
projectsById: new Map(),
|
||||||
|
projectBlocksById: new Map()
|
||||||
|
};
|
||||||
|
|
||||||
|
function appFlag(name) {
|
||||||
|
return document.querySelector('.kiln-app')?.dataset[name] === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function canEditTasks() {
|
||||||
|
return appFlag('canEditTasks');
|
||||||
|
}
|
||||||
|
|
||||||
|
function canEditVersions() {
|
||||||
|
return appFlag('canEditVersions');
|
||||||
|
}
|
||||||
|
|
||||||
|
function canEditProjects() {
|
||||||
|
return appFlag('canEditProjects');
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPermissionVisibility() {
|
||||||
|
const visibility = [
|
||||||
|
['taskEditButton', canEditTasks()],
|
||||||
|
['versionEditButton', canEditVersions()],
|
||||||
|
['projectEditButton', canEditProjects()]
|
||||||
|
];
|
||||||
|
|
||||||
|
visibility.forEach(([id, allowed]) => {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
if (element) element.hidden = !allowed;
|
||||||
|
});
|
||||||
|
|
||||||
|
[
|
||||||
|
['.task-editable', canEditTasks()],
|
||||||
|
['.version-editable', canEditVersions()],
|
||||||
|
['.project-editable', canEditProjects()]
|
||||||
|
].forEach(([selector, allowed]) => {
|
||||||
|
document.querySelectorAll(selector).forEach((element) => {
|
||||||
|
element.classList.toggle('is-readonly', !allowed);
|
||||||
|
|
||||||
|
if (allowed) {
|
||||||
|
element.setAttribute('tabindex', '0');
|
||||||
|
element.removeAttribute('aria-disabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
element.removeAttribute('title');
|
||||||
|
element.removeAttribute('tabindex');
|
||||||
|
element.setAttribute('aria-disabled', 'true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showView(viewId) {
|
||||||
|
document.querySelectorAll('#viewer > section').forEach((section) => {
|
||||||
|
section.hidden = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const view = document.getElementById(viewId);
|
||||||
|
|
||||||
|
if (!view) {
|
||||||
|
console.error(`Missing view: ${viewId}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
view.hidden = false;
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setText(id, value) {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
element.textContent = value ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEditableText(id, displayValue, rawValue = displayValue, emptyText = '-') {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const hasValue = displayValue !== null && displayValue !== undefined && displayValue !== '';
|
||||||
|
element.textContent = hasValue ? displayValue : emptyText;
|
||||||
|
element.dataset.currentValue = rawValue ?? '';
|
||||||
|
element.classList.remove('is-saving');
|
||||||
|
element.classList.toggle('is-empty', !hasValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEditableHtml(id, displayHtml, rawValue, emptyText = '-') {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const hasValue = displayHtml !== null && displayHtml !== undefined && displayHtml !== '-';
|
||||||
|
element.innerHTML = hasValue ? displayHtml : emptyText;
|
||||||
|
element.dataset.currentValue = rawValue ?? '';
|
||||||
|
element.classList.remove('is-saving');
|
||||||
|
element.classList.toggle('is-empty', !hasValue || rawValue === '' || rawValue === null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVersionEditableText(id, displayValue, rawValue = displayValue, emptyText = '-') {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const hasValue = displayValue !== null && displayValue !== undefined && displayValue !== '';
|
||||||
|
element.textContent = hasValue ? displayValue : emptyText;
|
||||||
|
element.dataset.currentValue = rawValue ?? '';
|
||||||
|
element.classList.remove('is-saving');
|
||||||
|
element.classList.toggle('is-empty', !hasValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMetaBadge(item) {
|
||||||
|
if (!item) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
const icon = item.icon
|
||||||
|
? `<img src="${item.icon}" alt="" class="meta-icon">`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<span class="meta-badge">
|
||||||
|
${icon}
|
||||||
|
<span>${escapeHtml(item.name)}</span>
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTaskTypes() {
|
||||||
|
if (taskLookupCache.types) {
|
||||||
|
return taskLookupCache.types;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiGet('/api/task.php', {
|
||||||
|
api: 'ListTypes'
|
||||||
|
});
|
||||||
|
|
||||||
|
taskLookupCache.types = result.success ? result.types : [];
|
||||||
|
return taskLookupCache.types;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTaskPriorities() {
|
||||||
|
if (taskLookupCache.priorities) {
|
||||||
|
return taskLookupCache.priorities;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiGet('/api/task.php', {
|
||||||
|
api: 'ListPriorities'
|
||||||
|
});
|
||||||
|
|
||||||
|
taskLookupCache.priorities = result.success ? result.priorities : [];
|
||||||
|
return taskLookupCache.priorities;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUsers() {
|
||||||
|
if (taskLookupCache.users) {
|
||||||
|
return taskLookupCache.users;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiGet('/api/user.php', {
|
||||||
|
api: 'ListUsers'
|
||||||
|
});
|
||||||
|
|
||||||
|
taskLookupCache.users = result.success ? result.users : [];
|
||||||
|
return taskLookupCache.users;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProjectVersions(projectId) {
|
||||||
|
if (taskLookupCache.versionsByProject.has(projectId)) {
|
||||||
|
return taskLookupCache.versionsByProject.get(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiGet('/api/version.php', {
|
||||||
|
api: 'ListVersions',
|
||||||
|
project_id: projectId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
taskLookupCache.versionsByProject.set(projectId, []);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const versions = [];
|
||||||
|
|
||||||
|
for (const versionId of result.versions) {
|
||||||
|
const versionInfo = await apiGet('/api/version.php', {
|
||||||
|
api: 'VersionInfo',
|
||||||
|
version_id: versionId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (versionInfo.success && versionInfo.version) {
|
||||||
|
versions.push(versionInfo.version);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
taskLookupCache.versionsByProject.set(projectId, versions);
|
||||||
|
return versions;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function populateTaskOptionSelects() {
|
||||||
|
const [types, priorities] = await Promise.all([
|
||||||
|
getTaskTypes(),
|
||||||
|
getTaskPriorities()
|
||||||
|
]);
|
||||||
|
|
||||||
|
populateSelect(document.getElementById('createTaskType'), types, {
|
||||||
|
placeholder: 'Select type'
|
||||||
|
});
|
||||||
|
|
||||||
|
populateSelect(document.getElementById('createTaskPriority'), priorities, {
|
||||||
|
placeholder: 'Select priority'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function populateTaskProjectSelect(selectedProjectId = '') {
|
||||||
|
const result = await apiGet('/api/project.php', {
|
||||||
|
api: 'ListProjects'
|
||||||
|
});
|
||||||
|
|
||||||
|
const projects = [];
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
for (const projectId of result.projects) {
|
||||||
|
const project = await getProjectInfo(projectId);
|
||||||
|
|
||||||
|
projects.push({
|
||||||
|
id: projectId,
|
||||||
|
name: project?.name ?? projectId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
populateSelect(document.getElementById('taskFormProjectId'), projects, {
|
||||||
|
placeholder: 'Select project',
|
||||||
|
selectedValue: selectedProjectId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function populateTaskRelationSelects(projectId = '', selected = {}) {
|
||||||
|
const users = await getUsers();
|
||||||
|
|
||||||
|
populateSelect(document.getElementById('taskFormAssignee'), users, {
|
||||||
|
placeholder: 'Unassigned',
|
||||||
|
selectedValue: selected.assignee ?? ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const versions = projectId ? await getProjectVersions(projectId.toUpperCase()) : [];
|
||||||
|
|
||||||
|
populateSelect(document.getElementById('taskFormFixVersion'), versions, {
|
||||||
|
placeholder: 'No fix version',
|
||||||
|
selectedValue: selected.fix_version ?? ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetTaskPopup() {
|
||||||
|
const form = document.getElementById('createTaskForm');
|
||||||
|
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
form.dataset.mode = 'create';
|
||||||
|
document.getElementById('taskPopupTitle').textContent = 'Create Task';
|
||||||
|
document.getElementById('taskPopupSubmit').textContent = 'Create Task';
|
||||||
|
document.getElementById('taskFormTaskId').value = '';
|
||||||
|
document.getElementById('taskFormProjectId').disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetVersionPopup() {
|
||||||
|
const form = document.getElementById('createVersionForm');
|
||||||
|
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
form.dataset.mode = 'create';
|
||||||
|
document.getElementById('versionPopupTitle').textContent = 'Create Version';
|
||||||
|
document.getElementById('versionPopupSubmit').textContent = 'Create Version';
|
||||||
|
document.getElementById('versionFormVersionId').value = '';
|
||||||
|
document.getElementById('versionFormProjectId').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateSelect(select, options, settings = {}) {
|
||||||
|
if (!select) return;
|
||||||
|
|
||||||
|
const placeholder = settings.placeholder ?? 'Select value';
|
||||||
|
const includeEmpty = settings.includeEmpty ?? true;
|
||||||
|
const selectedValue = settings.selectedValue ?? '';
|
||||||
|
|
||||||
|
select.innerHTML = '';
|
||||||
|
|
||||||
|
if (includeEmpty) {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = '';
|
||||||
|
option.textContent = placeholder;
|
||||||
|
select.appendChild(option);
|
||||||
|
}
|
||||||
|
|
||||||
|
options.forEach((item) => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = item.id;
|
||||||
|
option.textContent = item.name ?? item.email ?? item.id;
|
||||||
|
option.selected = String(option.value) === String(selectedValue);
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUserInfo(userId) {
|
||||||
|
if (!userId || userId === '-') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiGet('/api/user.php', {
|
||||||
|
api: 'UserInfo',
|
||||||
|
user_id: userId
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.success ? result.user : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUser(user) {
|
||||||
|
if (!user) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatar = user.picture
|
||||||
|
? `<img src="${user.picture}" alt="" class="user-avatar">`
|
||||||
|
: `
|
||||||
|
<span class="user-avatar user-avatar-fallback">
|
||||||
|
<i class="fa-solid fa-user"></i>
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<span class="meta-user">
|
||||||
|
${avatar}
|
||||||
|
<span>${escapeHtml(user.name)}</span>
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentUserId() {
|
||||||
|
return document.querySelector('.kiln-app')?.dataset.currentUserId ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAccountProfile(user) {
|
||||||
|
setText('accountName', user.name ?? '');
|
||||||
|
setText('accountEmail', user.email ?? '');
|
||||||
|
|
||||||
|
const accountButton = document.querySelector('.kiln-account-btn');
|
||||||
|
const accountName = document.getElementById('accountName');
|
||||||
|
|
||||||
|
if (!accountButton || !accountName) return;
|
||||||
|
|
||||||
|
document.getElementById('accountAvatarImage')?.remove();
|
||||||
|
document.getElementById('accountAvatarFallback')?.remove();
|
||||||
|
|
||||||
|
const avatar = document.createElement(user.picture ? 'img' : 'span');
|
||||||
|
avatar.id = user.picture ? 'accountAvatarImage' : 'accountAvatarFallback';
|
||||||
|
avatar.className = user.picture ? 'user-avatar' : 'user-avatar user-avatar-fallback';
|
||||||
|
|
||||||
|
if (user.picture) {
|
||||||
|
avatar.src = user.picture;
|
||||||
|
avatar.alt = '';
|
||||||
|
} else {
|
||||||
|
avatar.innerHTML = '<i class="fa-solid fa-user"></i>';
|
||||||
|
}
|
||||||
|
|
||||||
|
accountButton.insertBefore(avatar, accountName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(theme) {
|
||||||
|
const stylesheet = document.getElementById('themeStylesheet');
|
||||||
|
|
||||||
|
if (!stylesheet || !themeStyles[theme]) return;
|
||||||
|
|
||||||
|
stylesheet.href = themeStyles[theme];
|
||||||
|
stylesheet.dataset.theme = theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
15
ProjectKiln/app/js/home/dashboard.js
Normal file
15
ProjectKiln/app/js/home/dashboard.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
function loadDashboard(pushHistory = true) {
|
||||||
|
showView('dashboardView');
|
||||||
|
|
||||||
|
if (!pushHistory) return;
|
||||||
|
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('page', 'home');
|
||||||
|
url.searchParams.delete('task');
|
||||||
|
url.searchParams.delete('project');
|
||||||
|
url.searchParams.delete('tasks');
|
||||||
|
url.searchParams.delete('version');
|
||||||
|
url.searchParams.delete('profile');
|
||||||
|
url.searchParams.delete('admin');
|
||||||
|
window.history.pushState({}, '', url);
|
||||||
|
}
|
||||||
623
ProjectKiln/app/js/home/events.js
Normal file
623
ProjectKiln/app/js/home/events.js
Normal file
@@ -0,0 +1,623 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
projectTreePromise = loadProjectTree();
|
||||||
|
populateTaskOptionSelects();
|
||||||
|
applyPermissionVisibility();
|
||||||
|
initTaskInlineEditing();
|
||||||
|
initVersionInlineEditing();
|
||||||
|
initProjectInlineEditing();
|
||||||
|
|
||||||
|
loadRouteFromUrl();
|
||||||
|
|
||||||
|
window.addEventListener('popstate', () => {
|
||||||
|
loadRouteFromUrl();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-view="dashboard"]').forEach((button) => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
loadDashboard();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('adminMenuButton')?.addEventListener('click', () => {
|
||||||
|
loadAdmin('workflows');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-admin-section]').forEach((button) => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
loadAdmin(button.dataset.adminSection);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('workflowRefreshButton')?.addEventListener('click', () => {
|
||||||
|
loadWorkflowEditor();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('workflowTaskTypeSelect')?.addEventListener('change', (event) => {
|
||||||
|
selectedWorkflowTaskType = event.target.value || null;
|
||||||
|
renderWorkflowEditor();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('workflowDefaultStateSelect')?.addEventListener('change', (event) => {
|
||||||
|
if (!selectedWorkflowTaskType) return;
|
||||||
|
|
||||||
|
saveWorkflowAction('SetDefaultState', {
|
||||||
|
task_type: selectedWorkflowTaskType,
|
||||||
|
state: event.target.value
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('workflowStateForm')?.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const form = event.currentTarget;
|
||||||
|
const data = Object.fromEntries(new FormData(form));
|
||||||
|
const saved = await saveWorkflowAction('CreateState', data);
|
||||||
|
|
||||||
|
if (saved) form.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('workflowTransitionForm')?.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const form = event.currentTarget;
|
||||||
|
const data = Object.fromEntries(new FormData(form));
|
||||||
|
const saved = await saveWorkflowAction('CreateTransition', data);
|
||||||
|
|
||||||
|
if (saved) form.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('workflowAssignmentForm')?.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const form = event.currentTarget;
|
||||||
|
const data = Object.fromEntries(new FormData(form));
|
||||||
|
data.task_type = selectedWorkflowTaskType;
|
||||||
|
|
||||||
|
if (!data.task_type) {
|
||||||
|
alert('Select a task type first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved = await saveWorkflowAction('AssignState', data);
|
||||||
|
|
||||||
|
if (saved) form.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('adminWorkflowsPanel')?.addEventListener('click', (event) => {
|
||||||
|
const stateButton = event.target.closest('[data-workflow-delete-state]');
|
||||||
|
const transitionButton = event.target.closest('[data-workflow-delete-transition]');
|
||||||
|
const assignmentButton = event.target.closest('[data-workflow-remove-assignment]');
|
||||||
|
|
||||||
|
if (stateButton) {
|
||||||
|
if (!window.confirm('Delete this state and related transitions/assignments?')) return;
|
||||||
|
|
||||||
|
saveWorkflowAction('DeleteState', {}, {
|
||||||
|
state_id: stateButton.dataset.workflowDeleteState
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transitionButton) {
|
||||||
|
saveWorkflowAction('DeleteTransition', {}, {
|
||||||
|
transition_id: transitionButton.dataset.workflowDeleteTransition
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assignmentButton) {
|
||||||
|
saveWorkflowAction('RemoveAssignedState', {}, {
|
||||||
|
assignment_id: assignmentButton.dataset.workflowRemoveAssignment
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-admin-option-form]').forEach((form) => {
|
||||||
|
form.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const saved = await saveAdminOptionForm(form.dataset.adminOptionForm, form);
|
||||||
|
|
||||||
|
if (saved) form.reset();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('adminTaskTypeSelect')?.addEventListener('change', (event) => {
|
||||||
|
selectedAdminTaskType = event.target.value || null;
|
||||||
|
renderAdminOptions();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('adminUserSelect')?.addEventListener('change', (event) => {
|
||||||
|
selectedAdminUser = event.target.value || null;
|
||||||
|
renderAdminOptions();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('adminCustomFieldForm')?.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const saved = await saveAdminCustomFieldForm(event.currentTarget);
|
||||||
|
|
||||||
|
if (saved) event.currentTarget.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('adminOptionsPanel')?.addEventListener('submit', async (event) => {
|
||||||
|
const form = event.target.closest('[data-admin-option-edit]');
|
||||||
|
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
await saveAdminOptionForm(form.dataset.adminOptionEdit, form, form.dataset.optionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('adminOptionsPanel')?.addEventListener('submit', async (event) => {
|
||||||
|
const form = event.target.closest('[data-admin-custom-field-edit]');
|
||||||
|
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
await saveAdminCustomFieldForm(form, form.dataset.adminCustomFieldEdit);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('adminOptionsPanel')?.addEventListener('click', (event) => {
|
||||||
|
const deleteButton = event.target.closest('[data-admin-option-delete]');
|
||||||
|
|
||||||
|
if (!deleteButton) return;
|
||||||
|
|
||||||
|
if (!window.confirm('Delete this option?')) return;
|
||||||
|
|
||||||
|
deleteAdminOption(deleteButton.dataset.adminOptionDelete, deleteButton.dataset.optionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('adminOptionsPanel')?.addEventListener('click', (event) => {
|
||||||
|
const deleteButton = event.target.closest('[data-admin-custom-field-delete]');
|
||||||
|
|
||||||
|
if (!deleteButton) return;
|
||||||
|
|
||||||
|
if (!window.confirm('Delete this custom field?')) return;
|
||||||
|
|
||||||
|
deleteAdminCustomField(deleteButton.dataset.adminCustomFieldDelete);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('adminUsersPanel')?.addEventListener('change', async (event) => {
|
||||||
|
const checkbox = event.target.closest('[data-admin-user-right]');
|
||||||
|
|
||||||
|
if (!checkbox) return;
|
||||||
|
|
||||||
|
checkbox.disabled = true;
|
||||||
|
const saved = await setAdminUserRight(
|
||||||
|
checkbox.dataset.userId,
|
||||||
|
checkbox.dataset.rightId,
|
||||||
|
checkbox.checked
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!saved) {
|
||||||
|
checkbox.checked = !checkbox.checked;
|
||||||
|
checkbox.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('adminUsersPanel')?.addEventListener('submit', async (event) => {
|
||||||
|
const form = event.target.closest('#adminProjectAccessForm');
|
||||||
|
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!selectedAdminUser) return;
|
||||||
|
|
||||||
|
const data = Object.fromEntries(new FormData(form));
|
||||||
|
if (!data.project_id) return;
|
||||||
|
|
||||||
|
await grantAdminProjectAccess(selectedAdminUser, data.project_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('adminUsersPanel')?.addEventListener('click', async (event) => {
|
||||||
|
const deleteButton = event.target.closest('[data-admin-project-access-delete]');
|
||||||
|
|
||||||
|
if (!deleteButton) return;
|
||||||
|
|
||||||
|
await revokeAdminProjectAccess(deleteButton.dataset.adminProjectAccessDelete);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('profileMenuButton')?.addEventListener('click', () => {
|
||||||
|
loadProfile();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('taskEditButton')?.addEventListener('click', () => {
|
||||||
|
if (!canEditTasks()) return;
|
||||||
|
openTaskEditPopup();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('taskView')?.addEventListener('click', (event) => {
|
||||||
|
const customField = event.target.closest('[data-custom-field-id]');
|
||||||
|
|
||||||
|
if (customField) {
|
||||||
|
if (!canEditTasks()) return;
|
||||||
|
openCustomFieldInlineEditor(customField);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusButton = event.target.closest('#taskStatusButton');
|
||||||
|
|
||||||
|
if (statusButton) {
|
||||||
|
const menu = document.getElementById('taskStatusMenu');
|
||||||
|
if (menu) menu.hidden = !menu.hidden;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transitionButton = event.target.closest('[data-task-transition]');
|
||||||
|
|
||||||
|
if (transitionButton) {
|
||||||
|
transitionCurrentTask(transitionButton.dataset.taskTransition);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('taskView')?.addEventListener('keydown', (event) => {
|
||||||
|
if (['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON'].includes(event.target.tagName)) return;
|
||||||
|
|
||||||
|
const customField = event.target.closest('[data-custom-field-id]');
|
||||||
|
|
||||||
|
if (!customField || !['Enter', ' '].includes(event.key)) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
if (!canEditTasks()) return;
|
||||||
|
openCustomFieldInlineEditor(customField);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('taskCommentButton')?.addEventListener('click', () => {
|
||||||
|
document.getElementById('taskCommentInput')?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('versionEditButton')?.addEventListener('click', () => {
|
||||||
|
if (!canEditVersions()) return;
|
||||||
|
openVersionEditPopup();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('projectEditButton')?.addEventListener('click', () => {
|
||||||
|
if (!canEditProjects()) return;
|
||||||
|
openProjectEditPopup();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-version-task-sort]').forEach((button) => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
const field = button.dataset.versionTaskSort;
|
||||||
|
|
||||||
|
if (versionTaskSort.field === field) {
|
||||||
|
versionTaskSort.direction = versionTaskSort.direction === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
versionTaskSort.field = field;
|
||||||
|
versionTaskSort.direction = 'asc';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentVersion) {
|
||||||
|
loadVersionTasks(currentVersion.id, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-project-task-sort]').forEach((button) => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
const field = button.dataset.projectTaskSort;
|
||||||
|
|
||||||
|
if (projectTaskSort.field === field) {
|
||||||
|
projectTaskSort.direction = projectTaskSort.direction === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
projectTaskSort.field = field;
|
||||||
|
projectTaskSort.direction = 'asc';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentProjectTaskProject) {
|
||||||
|
loadProjectTasks(currentProjectTaskProject, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', (event) => {
|
||||||
|
const pageButton = event.target.closest('[data-task-page]');
|
||||||
|
|
||||||
|
if (!pageButton || pageButton.disabled) return;
|
||||||
|
|
||||||
|
const page = Number(pageButton.dataset.page);
|
||||||
|
|
||||||
|
if (!Number.isFinite(page) || page < 1) return;
|
||||||
|
|
||||||
|
if (pageButton.dataset.taskPage === 'version' && currentVersion) {
|
||||||
|
loadVersionTasks(currentVersion.id, page);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageButton.dataset.taskPage === 'project' && currentProjectTaskProject) {
|
||||||
|
loadProjectTasks(currentProjectTaskProject, page);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const createProjectForm = document.getElementById('createProjectForm');
|
||||||
|
|
||||||
|
if (createProjectForm) {
|
||||||
|
createProjectForm.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const data = Object.fromEntries(new FormData(createProjectForm));
|
||||||
|
const mode = createProjectForm.dataset.mode ?? 'create';
|
||||||
|
const projectId = document.getElementById('projectFormId').value;
|
||||||
|
|
||||||
|
const result = await apiPost('/api/project.php', {
|
||||||
|
api: mode === 'edit' ? 'Edit' : 'Create',
|
||||||
|
project_id: mode === 'edit' ? projectId : undefined
|
||||||
|
}, data);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
taskLookupCache.projectsById.delete(result.project_id);
|
||||||
|
closePopups();
|
||||||
|
createProjectForm.reset();
|
||||||
|
projectTreePromise = loadProjectTree();
|
||||||
|
|
||||||
|
if (mode === 'edit') {
|
||||||
|
await loadProject(projectId, false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert(result.error || 'Could not create project.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const createVersionForm = document.getElementById('createVersionForm');
|
||||||
|
|
||||||
|
if (createVersionForm) {
|
||||||
|
createVersionForm.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const data = Object.fromEntries(new FormData(createVersionForm));
|
||||||
|
const mode = createVersionForm.dataset.mode ?? 'create';
|
||||||
|
const versionId = data.version_id;
|
||||||
|
const projectId = data.project_id;
|
||||||
|
delete data.version_id;
|
||||||
|
|
||||||
|
if (mode === 'edit') {
|
||||||
|
delete data.project_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiPost('/api/version.php', {
|
||||||
|
api: mode === 'edit' ? 'Edit' : 'Create',
|
||||||
|
version_id: mode === 'edit' ? versionId : undefined
|
||||||
|
}, data);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
taskLookupCache.versionsByProject.delete(projectId);
|
||||||
|
closePopups();
|
||||||
|
projectTreePromise = loadProjectTree();
|
||||||
|
|
||||||
|
if (mode === 'edit') {
|
||||||
|
await loadVersion(versionId, false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert(result.error || 'Could not save version.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileForm = document.getElementById('profileForm');
|
||||||
|
|
||||||
|
if (profileForm) {
|
||||||
|
document.getElementById('profilePictureInput')?.addEventListener('change', (event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
renderProfileAvatar(currentProfileUser?.picture ?? null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('profileRemovePicture').checked = false;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.addEventListener('load', () => renderProfileAvatar(reader.result));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('profileRemovePicture')?.addEventListener('change', (event) => {
|
||||||
|
if (event.target.checked) {
|
||||||
|
document.getElementById('profilePictureInput').value = '';
|
||||||
|
renderProfileAvatar(null);
|
||||||
|
} else {
|
||||||
|
renderProfileAvatar(currentProfileUser?.picture ?? null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
profileForm.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const password = document.getElementById('profilePassword').value;
|
||||||
|
const passwordConfirm = document.getElementById('profilePasswordConfirm').value;
|
||||||
|
const status = document.getElementById('profileStatus');
|
||||||
|
const submitButton = document.getElementById('profileSubmitButton');
|
||||||
|
|
||||||
|
if (password !== passwordConfirm) {
|
||||||
|
status.textContent = 'Passwords do not match.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = new FormData(profileForm);
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
data.delete('password');
|
||||||
|
}
|
||||||
|
|
||||||
|
submitButton.disabled = true;
|
||||||
|
status.textContent = 'Saving...';
|
||||||
|
|
||||||
|
const result = await apiPostForm('/api/user.php', {
|
||||||
|
api: 'Edit',
|
||||||
|
user_id: getCurrentUserId()
|
||||||
|
}, data);
|
||||||
|
|
||||||
|
submitButton.disabled = false;
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
status.textContent = result.error || 'Could not save profile.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userResult = await apiGet('/api/user.php', {
|
||||||
|
api: 'UserInfo',
|
||||||
|
user_id: getCurrentUserId(),
|
||||||
|
_: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (userResult.success) {
|
||||||
|
taskLookupCache.users = null;
|
||||||
|
updateAccountProfile(userResult.user);
|
||||||
|
applyTheme(userResult.user.settings?.theme ?? 'dark');
|
||||||
|
renderProfile(userResult.user);
|
||||||
|
status.textContent = 'Saved.';
|
||||||
|
} else {
|
||||||
|
status.textContent = 'Saved. Refresh to see all changes.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const createTaskForm = document.getElementById('createTaskForm');
|
||||||
|
|
||||||
|
if (createTaskForm) {
|
||||||
|
document.getElementById('taskFormProjectId')?.addEventListener('change', (event) => {
|
||||||
|
populateTaskRelationSelects(event.target.value.trim());
|
||||||
|
});
|
||||||
|
|
||||||
|
createTaskForm.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const data = Object.fromEntries(new FormData(createTaskForm));
|
||||||
|
const mode = createTaskForm.dataset.mode ?? 'create';
|
||||||
|
const taskId = data.task_id;
|
||||||
|
delete data.task_id;
|
||||||
|
|
||||||
|
if (mode === 'edit') {
|
||||||
|
delete data.project_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiPost('/api/task.php', {
|
||||||
|
api: mode === 'edit' ? 'Edit' : 'Create',
|
||||||
|
task_id: mode === 'edit' ? taskId : undefined
|
||||||
|
}, data);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
closePopups();
|
||||||
|
projectTreePromise = loadProjectTree();
|
||||||
|
loadTask(mode === 'edit' ? taskId : result.task_id, mode !== 'edit');
|
||||||
|
} else {
|
||||||
|
alert(result.error || 'Could not save task.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskCommentForm = document.getElementById('taskCommentForm');
|
||||||
|
|
||||||
|
if (taskCommentForm) {
|
||||||
|
taskCommentForm.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const input = document.getElementById('taskCommentInput');
|
||||||
|
const status = document.getElementById('taskCommentStatus');
|
||||||
|
const comment = input.value.trim();
|
||||||
|
|
||||||
|
if (!comment) {
|
||||||
|
status.textContent = 'Comment cannot be empty.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
status.textContent = 'Saving...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await submitTaskComment(comment);
|
||||||
|
input.value = '';
|
||||||
|
status.textContent = 'Saved.';
|
||||||
|
} catch (error) {
|
||||||
|
status.textContent = error.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('taskCommentList')?.addEventListener('click', (event) => {
|
||||||
|
const replyButton = event.target.closest('[data-comment-reply]');
|
||||||
|
const editButton = event.target.closest('[data-comment-edit]');
|
||||||
|
const deleteButton = event.target.closest('[data-comment-delete]');
|
||||||
|
const cancelButton = event.target.closest('[data-comment-reply-cancel]');
|
||||||
|
const editCancelButton = event.target.closest('[data-comment-edit-cancel]');
|
||||||
|
|
||||||
|
if (replyButton) {
|
||||||
|
const commentCard = replyButton.closest('.task-comment');
|
||||||
|
const slot = commentCard?.querySelector('.task-comment-reply-slot');
|
||||||
|
|
||||||
|
if (!slot || slot.querySelector('form')) return;
|
||||||
|
|
||||||
|
slot.innerHTML = `
|
||||||
|
<form class="task-comment-reply-form" data-comment-reply-form="${replyButton.dataset.commentReply}">
|
||||||
|
<textarea class="form-control" name="comment" rows="2" placeholder="Write a reply..."></textarea>
|
||||||
|
<div class="task-comment-reply-actions">
|
||||||
|
<button class="btn btn-sm btn-primary" type="submit">Reply</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" data-comment-reply-cancel>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
`;
|
||||||
|
slot.querySelector('textarea')?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editButton) {
|
||||||
|
const commentCard = editButton.closest('.task-comment');
|
||||||
|
const slot = commentCard?.querySelector('.task-comment-edit-slot');
|
||||||
|
|
||||||
|
if (!slot || slot.querySelector('form')) return;
|
||||||
|
|
||||||
|
slot.innerHTML = `
|
||||||
|
<form class="task-comment-edit-form" data-comment-edit-form="${editButton.dataset.commentEdit}">
|
||||||
|
<textarea class="form-control" name="comment" rows="3">${escapeHtml(commentCard.dataset.commentText ?? '')}</textarea>
|
||||||
|
<div class="task-comment-reply-actions">
|
||||||
|
<button class="btn btn-sm btn-primary" type="submit">Save</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" data-comment-edit-cancel>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
`;
|
||||||
|
slot.querySelector('textarea')?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteButton) {
|
||||||
|
if (!window.confirm('Delete this comment?')) return;
|
||||||
|
|
||||||
|
deleteTaskComment(deleteButton.dataset.commentDelete).catch((error) => {
|
||||||
|
alert(error.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelButton) {
|
||||||
|
cancelButton.closest('.task-comment-reply-slot').innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editCancelButton) {
|
||||||
|
editCancelButton.closest('.task-comment-edit-slot').innerHTML = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('taskCommentList')?.addEventListener('submit', async (event) => {
|
||||||
|
const form = event.target.closest('[data-comment-reply-form], [data-comment-edit-form]');
|
||||||
|
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const textarea = form.querySelector('textarea');
|
||||||
|
const button = form.querySelector('button[type="submit"]');
|
||||||
|
const comment = textarea.value.trim();
|
||||||
|
const responseTo = form.dataset.commentReplyForm;
|
||||||
|
const editCommentId = form.dataset.commentEditForm;
|
||||||
|
|
||||||
|
if (!comment) return;
|
||||||
|
|
||||||
|
button.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editCommentId) {
|
||||||
|
await updateTaskComment(editCommentId, comment);
|
||||||
|
} else {
|
||||||
|
await submitTaskComment(comment, responseTo);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message);
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
63
ProjectKiln/app/js/home/profile.js
Normal file
63
ProjectKiln/app/js/home/profile.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
async function loadProfile(pushHistory = true) {
|
||||||
|
const view = showView('profileView');
|
||||||
|
|
||||||
|
if (!view) return;
|
||||||
|
|
||||||
|
if (pushHistory) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('page', 'home');
|
||||||
|
url.searchParams.set('profile', '1');
|
||||||
|
url.searchParams.delete('task');
|
||||||
|
url.searchParams.delete('project');
|
||||||
|
url.searchParams.delete('tasks');
|
||||||
|
url.searchParams.delete('version');
|
||||||
|
url.searchParams.delete('admin');
|
||||||
|
window.history.pushState({}, '', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = getCurrentUserId();
|
||||||
|
const result = await apiGet('/api/user.php', {
|
||||||
|
api: 'UserInfo',
|
||||||
|
user_id: userId,
|
||||||
|
_: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
view.innerHTML = '<div class="alert alert-danger">Could not load profile.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderProfile(result.user);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProfile(user) {
|
||||||
|
currentProfileUser = user;
|
||||||
|
|
||||||
|
setText('profileStatus', '');
|
||||||
|
document.getElementById('profileName').value = user.name ?? '';
|
||||||
|
document.getElementById('profileEmail').value = user.email ?? '';
|
||||||
|
document.getElementById('profilePassword').value = '';
|
||||||
|
document.getElementById('profilePasswordConfirm').value = '';
|
||||||
|
document.getElementById('profileTheme').value = user.settings?.theme ?? 'dark';
|
||||||
|
document.getElementById('profileRemovePicture').checked = false;
|
||||||
|
document.getElementById('profilePictureInput').value = '';
|
||||||
|
renderProfileAvatar(user.picture);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProfileAvatar(picture) {
|
||||||
|
const preview = document.getElementById('profileAvatarPreview');
|
||||||
|
|
||||||
|
if (!preview) return;
|
||||||
|
|
||||||
|
if (picture) {
|
||||||
|
preview.innerHTML = `<img src="${picture}" alt="">`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
preview.innerHTML = `
|
||||||
|
<span class="profile-avatar-fallback">
|
||||||
|
<i class="fa-solid fa-user"></i>
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
397
ProjectKiln/app/js/home/project.js
Normal file
397
ProjectKiln/app/js/home/project.js
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
async function loadProjectTree() {
|
||||||
|
const tree = document.getElementById('projectTree');
|
||||||
|
|
||||||
|
if (!tree) return;
|
||||||
|
|
||||||
|
tree.innerHTML = '<div class="kiln-tree-muted">Loading projects...</div>';
|
||||||
|
|
||||||
|
const result = await apiGet('/api/project.php', {
|
||||||
|
api: 'ListProjects'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
tree.innerHTML = '<div class="text-danger">Could not load projects.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.projects.length) {
|
||||||
|
tree.innerHTML = '<div class="kiln-tree-muted">No projects yet.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tree.innerHTML = '';
|
||||||
|
taskLookupCache.projectBlocksById.clear();
|
||||||
|
|
||||||
|
for (const projectId of result.projects) {
|
||||||
|
const project = await getProjectInfo(projectId);
|
||||||
|
const projectBlock = document.createElement('div');
|
||||||
|
projectBlock.dataset.projectId = projectId;
|
||||||
|
|
||||||
|
const projectButton = document.createElement('button');
|
||||||
|
projectButton.className = 'kiln-tree-project';
|
||||||
|
projectButton.innerHTML = `
|
||||||
|
<i class="fa-solid fa-caret-right me-1" data-project-caret></i>
|
||||||
|
<span>${escapeHtml(project?.name ?? projectId)}</span>
|
||||||
|
`;
|
||||||
|
projectButton.addEventListener('click', (event) => {
|
||||||
|
if (event.target.closest('[data-project-caret]')) {
|
||||||
|
toggleProject(projectId, projectBlock);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadProject(projectId);
|
||||||
|
|
||||||
|
if (!projectBlock.querySelector('.kiln-tree-group')) {
|
||||||
|
toggleProject(projectId, projectBlock, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
projectBlock.appendChild(projectButton);
|
||||||
|
tree.appendChild(projectBlock);
|
||||||
|
taskLookupCache.projectBlocksById.set(projectId, projectBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentTask?.project) {
|
||||||
|
const projectBlock = taskLookupCache.projectBlocksById.get(currentTask.project);
|
||||||
|
|
||||||
|
if (projectBlock && !projectBlock.querySelector('.kiln-tree-group')) {
|
||||||
|
await toggleProject(currentTask.project, projectBlock, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProjectInfo(projectId) {
|
||||||
|
if (taskLookupCache.projectsById.has(projectId)) {
|
||||||
|
return taskLookupCache.projectsById.get(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiGet('/api/project.php', {
|
||||||
|
api: 'ProjectInfo',
|
||||||
|
project_id: projectId
|
||||||
|
});
|
||||||
|
|
||||||
|
const project = result.success ? result.project : null;
|
||||||
|
taskLookupCache.projectsById.set(projectId, project);
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureProjectOpen(projectId) {
|
||||||
|
await projectTreePromise;
|
||||||
|
|
||||||
|
const projectBlock = taskLookupCache.projectBlocksById.get(projectId);
|
||||||
|
|
||||||
|
if (!projectBlock || projectBlock.querySelector('.kiln-tree-group')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await toggleProject(projectId, projectBlock, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleProject(projectId, projectBlock, forceOpen = false) {
|
||||||
|
let group = projectBlock.querySelector('.kiln-tree-group');
|
||||||
|
|
||||||
|
if (group) {
|
||||||
|
if (forceOpen) return;
|
||||||
|
|
||||||
|
group.remove();
|
||||||
|
setProjectCaret(projectBlock, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProjectCaret(projectBlock, true);
|
||||||
|
|
||||||
|
group = document.createElement('div');
|
||||||
|
group.className = 'kiln-tree-group';
|
||||||
|
group.innerHTML = '<div class="kiln-tree-muted">Loading...</div>';
|
||||||
|
projectBlock.appendChild(group);
|
||||||
|
|
||||||
|
const versions = await apiGet('/api/version.php', {
|
||||||
|
api: 'ListVersions',
|
||||||
|
project_id: projectId
|
||||||
|
});
|
||||||
|
|
||||||
|
group.innerHTML = '';
|
||||||
|
|
||||||
|
const versionGroup = document.createElement('div');
|
||||||
|
versionGroup.className = 'kiln-tree-version-group';
|
||||||
|
group.appendChild(versionGroup);
|
||||||
|
|
||||||
|
const versionLabel = document.createElement('div');
|
||||||
|
versionLabel.className = 'kiln-tree-label';
|
||||||
|
versionLabel.textContent = 'Versions';
|
||||||
|
versionGroup.appendChild(versionLabel);
|
||||||
|
|
||||||
|
if (document.querySelector('.kiln-app')?.dataset.canCreateVersions === '1') {
|
||||||
|
const createVersion = document.createElement('button');
|
||||||
|
createVersion.className = 'kiln-tree-version';
|
||||||
|
createVersion.innerHTML = '<i class="fa-solid fa-plus me-1"></i>Create new version';
|
||||||
|
createVersion.addEventListener('click', () => openVersionCreatePopup(projectId));
|
||||||
|
versionGroup.appendChild(createVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (versions.success && versions.versions.length) {
|
||||||
|
for (const versionId of versions.versions) {
|
||||||
|
const versionInfo = await apiGet('/api/version.php', {
|
||||||
|
api: 'VersionInfo',
|
||||||
|
version_id: versionId
|
||||||
|
});
|
||||||
|
|
||||||
|
const versionButton = document.createElement('button');
|
||||||
|
versionButton.className = 'kiln-tree-version';
|
||||||
|
|
||||||
|
if (versionInfo.success && versionInfo.version) {
|
||||||
|
versionButton.textContent = versionInfo.version.name;
|
||||||
|
} else {
|
||||||
|
versionButton.textContent = `Version ${versionId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
versionButton.addEventListener('click', () => loadVersion(versionId));
|
||||||
|
versionGroup.appendChild(versionButton);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showTasksButton = document.createElement('button');
|
||||||
|
showTasksButton.className = 'kiln-tree-version';
|
||||||
|
showTasksButton.innerHTML = '<i class="fa-solid fa-list-check me-1"></i>Show all tasks';
|
||||||
|
showTasksButton.addEventListener('click', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
loadProjectTasks(projectId);
|
||||||
|
});
|
||||||
|
|
||||||
|
group.appendChild(showTasksButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setProjectCaret(projectBlock, isOpen) {
|
||||||
|
const caret = projectBlock.querySelector('[data-project-caret]');
|
||||||
|
|
||||||
|
if (!caret) return;
|
||||||
|
|
||||||
|
caret.classList.toggle('fa-caret-right', !isOpen);
|
||||||
|
caret.classList.toggle('fa-caret-down', isOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProject(projectId, pushHistory = true) {
|
||||||
|
const view = showView('projectView');
|
||||||
|
|
||||||
|
if (!view) return;
|
||||||
|
|
||||||
|
if (pushHistory) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('page', 'home');
|
||||||
|
url.searchParams.set('project', projectId);
|
||||||
|
url.searchParams.delete('tasks');
|
||||||
|
url.searchParams.delete('task');
|
||||||
|
url.searchParams.delete('version');
|
||||||
|
url.searchParams.delete('profile');
|
||||||
|
url.searchParams.delete('admin');
|
||||||
|
window.history.pushState({}, '', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiGet('/api/project.php', {
|
||||||
|
api: 'ProjectInfo',
|
||||||
|
project_id: projectId,
|
||||||
|
_: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
view.innerHTML = '<div class="alert alert-danger">Project not found.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await renderProject(result.project);
|
||||||
|
await ensureProjectOpen(result.project.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderProject(project) {
|
||||||
|
currentProject = project;
|
||||||
|
taskLookupCache.projectsById.set(project.id, project);
|
||||||
|
|
||||||
|
const owner = project.owner ? await getUserInfo(project.owner) : null;
|
||||||
|
|
||||||
|
setVersionEditableText('projectKey', project.id);
|
||||||
|
setVersionEditableText('projectName', project.name, project.name);
|
||||||
|
setVersionEditableText('projectInlineName', project.name, project.name);
|
||||||
|
setText('projectId', project.id);
|
||||||
|
setEditableHtml('projectOwner', renderUser(owner), project.owner ?? '', 'No owner');
|
||||||
|
setText('projectCreated', project.created_date || '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function populateProjectOwnerSelect(selectedOwner = '') {
|
||||||
|
populateSelect(document.getElementById('projectFormOwner'), await getUsers(), {
|
||||||
|
includeEmpty: false,
|
||||||
|
selectedValue: selectedOwner
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetProjectPopup() {
|
||||||
|
const form = document.getElementById('createProjectForm');
|
||||||
|
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
form.dataset.mode = 'create';
|
||||||
|
document.getElementById('projectPopupTitle').textContent = 'Create Project';
|
||||||
|
document.getElementById('projectPopupSubmit').textContent = 'Create Project';
|
||||||
|
document.getElementById('projectFormId').disabled = false;
|
||||||
|
document.getElementById('projectFormId').value = '';
|
||||||
|
document.getElementById('projectFormName').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openProjectCreatePopup() {
|
||||||
|
resetProjectPopup();
|
||||||
|
await populateProjectOwnerSelect(getCurrentUserId());
|
||||||
|
openPopup('createProject');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openProjectEditPopup() {
|
||||||
|
if (!currentProject) return;
|
||||||
|
|
||||||
|
resetProjectPopup();
|
||||||
|
|
||||||
|
const form = document.getElementById('createProjectForm');
|
||||||
|
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
form.dataset.mode = 'edit';
|
||||||
|
document.getElementById('projectPopupTitle').textContent = `Edit ${currentProject.id}`;
|
||||||
|
document.getElementById('projectPopupSubmit').textContent = 'Update Project';
|
||||||
|
document.getElementById('projectFormId').value = currentProject.id;
|
||||||
|
document.getElementById('projectFormId').disabled = true;
|
||||||
|
document.getElementById('projectFormName').value = currentProject.name ?? '';
|
||||||
|
await populateProjectOwnerSelect(currentProject.owner ?? '');
|
||||||
|
|
||||||
|
openPopup('createProject');
|
||||||
|
}
|
||||||
|
|
||||||
|
function initProjectInlineEditing() {
|
||||||
|
if (!canEditProjects()) return;
|
||||||
|
|
||||||
|
document.querySelectorAll('.project-editable').forEach((element) => {
|
||||||
|
if (element.dataset.inlineReady === 'true') return;
|
||||||
|
|
||||||
|
element.dataset.inlineReady = 'true';
|
||||||
|
element.title = 'Click to edit';
|
||||||
|
|
||||||
|
element.addEventListener('click', () => openProjectInlineEditor(element));
|
||||||
|
element.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
openProjectInlineEditor(element);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openProjectInlineEditor(element) {
|
||||||
|
if (!canEditProjects() || !currentProject || element.querySelector('.version-inline-form')) return;
|
||||||
|
|
||||||
|
const field = element.dataset.projectField;
|
||||||
|
const currentValue = element.dataset.currentValue ?? '';
|
||||||
|
const originalHtml = element.innerHTML;
|
||||||
|
const originalClasses = Array.from(element.classList);
|
||||||
|
|
||||||
|
if (!['name', 'owner'].includes(field)) return;
|
||||||
|
|
||||||
|
element.classList.remove('is-empty');
|
||||||
|
element.innerHTML = '';
|
||||||
|
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.className = 'version-inline-form';
|
||||||
|
|
||||||
|
let input;
|
||||||
|
|
||||||
|
if (field === 'owner') {
|
||||||
|
input = document.createElement('select');
|
||||||
|
input.className = 'form-select version-inline-input';
|
||||||
|
populateSelect(input, await getUsers(), {
|
||||||
|
includeEmpty: false,
|
||||||
|
selectedValue: currentValue
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
input = document.createElement('input');
|
||||||
|
input.className = 'form-control version-inline-input';
|
||||||
|
input.type = 'text';
|
||||||
|
input.required = true;
|
||||||
|
input.maxLength = 128;
|
||||||
|
input.value = currentValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.appendChild(input);
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'version-inline-actions';
|
||||||
|
actions.innerHTML = `
|
||||||
|
<button class="btn btn-sm btn-primary" type="submit">
|
||||||
|
<i class="fa-solid fa-check"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" data-project-inline-cancel>
|
||||||
|
<i class="fa-solid fa-xmark"></i>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
form.appendChild(actions);
|
||||||
|
|
||||||
|
element.appendChild(form);
|
||||||
|
input.focus();
|
||||||
|
if (input.select && input.tagName !== 'SELECT') {
|
||||||
|
input.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
let closed = false;
|
||||||
|
const restore = () => {
|
||||||
|
if (closed) return;
|
||||||
|
|
||||||
|
closed = true;
|
||||||
|
element.innerHTML = originalHtml;
|
||||||
|
element.className = originalClasses.join(' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
const nextValue = field === 'name' ? input.value.trim() : input.value;
|
||||||
|
|
||||||
|
if (nextValue === currentValue) {
|
||||||
|
restore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiPost('/api/project.php', {
|
||||||
|
api: 'Edit',
|
||||||
|
project_id: currentProject.id
|
||||||
|
}, {
|
||||||
|
[field]: nextValue
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
alert(result.error || 'Could not update project.');
|
||||||
|
restore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
taskLookupCache.projectsById.delete(currentProject.id);
|
||||||
|
closed = true;
|
||||||
|
await loadProject(currentProject.id, false);
|
||||||
|
projectTreePromise = loadProjectTree();
|
||||||
|
};
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
await save();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (input.tagName === 'SELECT') {
|
||||||
|
input.addEventListener('change', save);
|
||||||
|
}
|
||||||
|
|
||||||
|
form.querySelector('[data-project-inline-cancel]')?.addEventListener('click', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('keydown', async (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
42
ProjectKiln/app/js/home/router.js
Normal file
42
ProjectKiln/app/js/home/router.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
function loadRouteFromUrl() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const taskId = params.get('task');
|
||||||
|
const versionId = params.get('version');
|
||||||
|
const projectId = params.get('project');
|
||||||
|
const projectTasks = params.get('tasks');
|
||||||
|
const profile = params.get('profile');
|
||||||
|
const adminSection = params.get('admin');
|
||||||
|
|
||||||
|
closePopups();
|
||||||
|
|
||||||
|
if (adminSection) {
|
||||||
|
loadAdmin(adminSection, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile) {
|
||||||
|
loadProfile(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (taskId) {
|
||||||
|
loadTask(taskId, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (versionId) {
|
||||||
|
loadVersion(versionId, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projectId) {
|
||||||
|
if (projectTasks) {
|
||||||
|
loadProjectTasks(projectId, 1, false);
|
||||||
|
} else {
|
||||||
|
loadProject(projectId, false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDashboard(false);
|
||||||
|
}
|
||||||
685
ProjectKiln/app/js/home/task.js
Normal file
685
ProjectKiln/app/js/home/task.js
Normal file
@@ -0,0 +1,685 @@
|
|||||||
|
async function loadTask(taskId, pushHistory = true) {
|
||||||
|
const view = showView('taskView');
|
||||||
|
|
||||||
|
if (!view) return;
|
||||||
|
|
||||||
|
if (pushHistory) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('page', 'home');
|
||||||
|
url.searchParams.set('task', taskId);
|
||||||
|
url.searchParams.delete('project');
|
||||||
|
url.searchParams.delete('tasks');
|
||||||
|
url.searchParams.delete('version');
|
||||||
|
url.searchParams.delete('profile');
|
||||||
|
url.searchParams.delete('admin');
|
||||||
|
window.history.pushState({}, '', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiGet('/api/task.php', {
|
||||||
|
api: 'TaskInfo',
|
||||||
|
task_id: taskId,
|
||||||
|
_: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
view.innerHTML = '<div class="alert alert-danger">Task not found.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await renderTask(result.task);
|
||||||
|
await loadTaskComments(result.task.id);
|
||||||
|
await ensureProjectOpen(result.task.project);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderTask(task) {
|
||||||
|
currentTask = task;
|
||||||
|
|
||||||
|
const [types, priorities, versions, reporter, assignee] = await Promise.all([
|
||||||
|
getTaskTypes(),
|
||||||
|
getTaskPriorities(),
|
||||||
|
getProjectVersions(task.project),
|
||||||
|
getUserInfo(task.reporter),
|
||||||
|
task.assignee ? getUserInfo(task.assignee) : null
|
||||||
|
]);
|
||||||
|
|
||||||
|
const type = types.find((item) => String(item.id) === String(task.type)) ?? null;
|
||||||
|
const priority = priorities.find((item) => String(item.id) === String(task.priority)) ?? null;
|
||||||
|
const fixVersion = versions.find((item) => String(item.id) === String(task.fix_version)) ?? null;
|
||||||
|
|
||||||
|
setEditableText('taskKey', task.id);
|
||||||
|
setEditableText('taskTitle', task.title, task.title);
|
||||||
|
setEditableText('taskDescription', task.description, task.description, 'No description provided.');
|
||||||
|
setText('taskProject', task.project);
|
||||||
|
document.getElementById('taskReporter').innerHTML = renderUser(reporter);
|
||||||
|
setEditableHtml('taskAssignee', renderUser(assignee), task.assignee ?? '', 'Unassigned');
|
||||||
|
setEditableText('taskFixVersion', fixVersion ? fixVersion.name : null, task.fix_version ?? '', 'No fix version');
|
||||||
|
setText('taskCreated', task.created_date);
|
||||||
|
setText('taskUpdated', task.last_changed);
|
||||||
|
|
||||||
|
setEditableHtml('taskType', renderMetaBadge(type), task.type);
|
||||||
|
setEditableHtml('taskPriority', renderMetaBadge(priority), task.priority);
|
||||||
|
renderTaskStatus(task);
|
||||||
|
renderTaskCustomFields(task.custom_fields ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTaskStatus(task) {
|
||||||
|
const slot = document.getElementById('taskStatusSlot');
|
||||||
|
|
||||||
|
if (!slot) return;
|
||||||
|
|
||||||
|
slot.innerHTML = '';
|
||||||
|
|
||||||
|
if (!task.status_state) return;
|
||||||
|
|
||||||
|
const isCurrentAssignee = String(task.assignee ?? '') === String(getCurrentUserId());
|
||||||
|
const canTransition = (canEditTasks() || appFlag('isAdmin') || isCurrentAssignee)
|
||||||
|
&& (task.status_transitions ?? []).length > 0;
|
||||||
|
const statusStyle = `--task-status-color: ${escapeHtml(task.status_state.color ?? '#6c757d')}`;
|
||||||
|
|
||||||
|
if (!canTransition) {
|
||||||
|
slot.innerHTML = `
|
||||||
|
<span class="task-status-badge" style="${statusStyle}">
|
||||||
|
${escapeHtml(task.status_state.name)}
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
slot.innerHTML = `
|
||||||
|
<div class="task-status-control">
|
||||||
|
<button class="task-status-badge task-status-button" type="button" style="${statusStyle}" id="taskStatusButton">
|
||||||
|
<span>${escapeHtml(task.status_state.name)}</span>
|
||||||
|
<i class="fa-solid fa-chevron-down"></i>
|
||||||
|
</button>
|
||||||
|
<div class="task-status-menu" id="taskStatusMenu" hidden>
|
||||||
|
${(task.status_transitions ?? []).map((transition) => `
|
||||||
|
<button type="button" data-task-transition="${escapeHtml(transition.id)}">
|
||||||
|
<span>${escapeHtml(transition.action_name)}</span>
|
||||||
|
<small>${escapeHtml(transition.to_state?.name ?? '')}</small>
|
||||||
|
</button>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTaskCustomFields(fields) {
|
||||||
|
const panel = document.getElementById('taskCustomFieldsPanel');
|
||||||
|
const list = document.getElementById('taskCustomFieldList');
|
||||||
|
|
||||||
|
if (!panel || !list) return;
|
||||||
|
|
||||||
|
const canEdit = canEditTasks();
|
||||||
|
|
||||||
|
panel.hidden = fields.length === 0;
|
||||||
|
list.innerHTML = fields.map((field) => `
|
||||||
|
<div class="task-custom-field-row">
|
||||||
|
<span>${escapeHtml(field.name)}</span>
|
||||||
|
<strong
|
||||||
|
class="task-custom-field-value ${field.raw_value ? '' : 'is-empty'} ${canEdit ? '' : 'is-readonly'}"
|
||||||
|
${canEdit ? 'tabindex="0"' : 'aria-disabled="true"'}
|
||||||
|
data-custom-field-id="${escapeHtml(field.id)}"
|
||||||
|
data-custom-field-type="${escapeHtml(field.type)}"
|
||||||
|
data-current-value="${escapeHtml(field.raw_value ?? '')}"
|
||||||
|
>${field.raw_value ? escapeHtml(field.raw_value) : 'No value'}</strong>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTaskComments(taskId) {
|
||||||
|
const list = document.getElementById('taskCommentList');
|
||||||
|
const empty = document.getElementById('taskCommentEmpty');
|
||||||
|
|
||||||
|
if (!list || !empty) return;
|
||||||
|
|
||||||
|
list.innerHTML = '';
|
||||||
|
empty.hidden = true;
|
||||||
|
currentTaskComments = [];
|
||||||
|
setText('taskCommentStatus', '');
|
||||||
|
|
||||||
|
const result = await apiGet('/api/task.php', {
|
||||||
|
api: 'ListComments',
|
||||||
|
task_id: taskId,
|
||||||
|
_: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
list.innerHTML = '<div class="alert alert-danger mb-0">Could not load comments.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTaskComments = result.comments ?? [];
|
||||||
|
renderTaskComments();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTaskComments() {
|
||||||
|
const list = document.getElementById('taskCommentList');
|
||||||
|
const empty = document.getElementById('taskCommentEmpty');
|
||||||
|
|
||||||
|
if (!list || !empty) return;
|
||||||
|
|
||||||
|
list.innerHTML = '';
|
||||||
|
empty.hidden = currentTaskComments.length > 0;
|
||||||
|
|
||||||
|
const commentsByParent = groupCommentsByParent(currentTaskComments);
|
||||||
|
const renderComment = (comment, depth = 0) => {
|
||||||
|
const user = {
|
||||||
|
id: comment.commenter,
|
||||||
|
name: comment.commenter_name,
|
||||||
|
email: comment.commenter_email,
|
||||||
|
picture: comment.commenter_picture
|
||||||
|
};
|
||||||
|
const article = document.createElement('article');
|
||||||
|
const canManageComment = String(comment.commenter) === String(getCurrentUserId());
|
||||||
|
const manageActions = canManageComment
|
||||||
|
? `
|
||||||
|
<button class="task-comment-action" type="button" data-comment-edit="${escapeHtml(comment.id)}">Edit</button>
|
||||||
|
<button class="task-comment-action text-danger" type="button" data-comment-delete="${escapeHtml(comment.id)}">Delete</button>
|
||||||
|
`
|
||||||
|
: '';
|
||||||
|
article.className = `task-comment${comment.response_to ? ' is-reply' : ''}`;
|
||||||
|
article.style.setProperty('--comment-depth', String(Math.min(depth, 8)));
|
||||||
|
article.dataset.commentId = comment.id;
|
||||||
|
article.dataset.commentText = comment.comment;
|
||||||
|
article.innerHTML = `
|
||||||
|
<div class="task-comment-header">
|
||||||
|
${renderUser(user)}
|
||||||
|
<span class="task-comment-id">#${escapeHtml(comment.id)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="task-comment-body">${escapeHtml(comment.comment)}</div>
|
||||||
|
<div class="task-comment-action-row">
|
||||||
|
<button class="task-comment-action" type="button" data-comment-reply="${escapeHtml(comment.id)}">Reply</button>
|
||||||
|
${manageActions}
|
||||||
|
</div>
|
||||||
|
<div class="task-comment-reply-slot"></div>
|
||||||
|
<div class="task-comment-edit-slot"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
list.appendChild(article);
|
||||||
|
|
||||||
|
const children = commentsByParent.get(Number(comment.id)) ?? [];
|
||||||
|
children.forEach((child) => renderComment(child, depth + 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const rootComments = commentsByParent.get(null) ?? [];
|
||||||
|
rootComments.forEach((comment) => renderComment(comment));
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupCommentsByParent(comments) {
|
||||||
|
const knownIds = new Set(comments.map((comment) => Number(comment.id)));
|
||||||
|
const groups = new Map([[null, []]]);
|
||||||
|
|
||||||
|
[...comments]
|
||||||
|
.sort((first, second) => Number(first.id) - Number(second.id))
|
||||||
|
.forEach((comment) => {
|
||||||
|
const parentId = comment.response_to && knownIds.has(Number(comment.response_to))
|
||||||
|
? Number(comment.response_to)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!groups.has(parentId)) {
|
||||||
|
groups.set(parentId, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.get(parentId).push(comment);
|
||||||
|
});
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitTaskComment(comment, responseTo = null) {
|
||||||
|
if (!currentTask) return;
|
||||||
|
|
||||||
|
const result = await apiPost('/api/task.php', {
|
||||||
|
api: 'CreateComment',
|
||||||
|
task_id: currentTask.id
|
||||||
|
}, {
|
||||||
|
comment,
|
||||||
|
response_to: responseTo
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Could not save comment.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadTaskComments(currentTask.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateTaskComment(commentId, comment) {
|
||||||
|
if (!currentTask) return;
|
||||||
|
|
||||||
|
const result = await apiPost('/api/task.php', {
|
||||||
|
api: 'EditComment',
|
||||||
|
task_id: currentTask.id,
|
||||||
|
comment_id: commentId
|
||||||
|
}, {
|
||||||
|
comment
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Could not update comment.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadTaskComments(currentTask.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTaskComment(commentId) {
|
||||||
|
if (!currentTask) return;
|
||||||
|
|
||||||
|
const result = await apiPost('/api/task.php', {
|
||||||
|
api: 'DeleteComment',
|
||||||
|
task_id: currentTask.id,
|
||||||
|
comment_id: commentId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Could not delete comment.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadTaskComments(currentTask.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openTaskCreatePopup() {
|
||||||
|
const form = document.getElementById('createTaskForm');
|
||||||
|
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
await populateTaskOptionSelects();
|
||||||
|
await populateTaskProjectSelect();
|
||||||
|
await populateTaskRelationSelects();
|
||||||
|
|
||||||
|
resetTaskPopup();
|
||||||
|
|
||||||
|
openPopup('createTask');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openTaskEditPopup() {
|
||||||
|
if (!currentTask) return;
|
||||||
|
|
||||||
|
const form = document.getElementById('createTaskForm');
|
||||||
|
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
await populateTaskOptionSelects();
|
||||||
|
await populateTaskProjectSelect(currentTask.project);
|
||||||
|
await populateTaskRelationSelects(currentTask.project, {
|
||||||
|
assignee: currentTask.assignee ?? '',
|
||||||
|
fix_version: currentTask.fix_version ?? ''
|
||||||
|
});
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
form.dataset.mode = 'edit';
|
||||||
|
document.getElementById('taskPopupTitle').textContent = `Edit ${currentTask.id}`;
|
||||||
|
document.getElementById('taskPopupSubmit').textContent = 'Update Task';
|
||||||
|
document.getElementById('taskFormTaskId').value = currentTask.id;
|
||||||
|
document.getElementById('taskFormProjectId').value = currentTask.project;
|
||||||
|
document.getElementById('taskFormProjectId').disabled = true;
|
||||||
|
document.getElementById('taskFormTitle').value = currentTask.title ?? '';
|
||||||
|
document.getElementById('taskFormDescription').value = currentTask.description ?? '';
|
||||||
|
document.getElementById('createTaskType').value = currentTask.type ?? '';
|
||||||
|
document.getElementById('createTaskPriority').value = currentTask.priority ?? '';
|
||||||
|
document.getElementById('taskFormFixVersion').value = currentTask.fix_version ?? '';
|
||||||
|
document.getElementById('taskFormAssignee').value = currentTask.assignee ?? '';
|
||||||
|
|
||||||
|
openPopup('createTask');
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTaskInlineEditing() {
|
||||||
|
if (!canEditTasks()) return;
|
||||||
|
|
||||||
|
document.querySelectorAll('.task-editable').forEach((element) => {
|
||||||
|
if (element.dataset.inlineReady === 'true') return;
|
||||||
|
|
||||||
|
element.dataset.inlineReady = 'true';
|
||||||
|
element.title = 'Click to edit';
|
||||||
|
|
||||||
|
element.addEventListener('click', () => openTaskInlineEditor(element));
|
||||||
|
element.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
openTaskInlineEditor(element);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openTaskInlineEditor(element) {
|
||||||
|
if (!canEditTasks() || !currentTask || element.querySelector('.task-inline-form')) return;
|
||||||
|
|
||||||
|
const field = element.dataset.taskField;
|
||||||
|
const currentValue = element.dataset.currentValue ?? '';
|
||||||
|
const originalHtml = element.innerHTML;
|
||||||
|
const originalClasses = Array.from(element.classList);
|
||||||
|
|
||||||
|
element.classList.remove('is-empty');
|
||||||
|
element.innerHTML = '';
|
||||||
|
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.className = 'task-inline-form';
|
||||||
|
|
||||||
|
const input = await createTaskInlineInput(field, currentValue);
|
||||||
|
form.appendChild(input);
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'task-inline-actions';
|
||||||
|
actions.innerHTML = `
|
||||||
|
<button class="btn btn-sm btn-primary" type="submit">
|
||||||
|
<i class="fa-solid fa-check"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" data-inline-cancel>
|
||||||
|
<i class="fa-solid fa-xmark"></i>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (input.tagName !== 'SELECT') {
|
||||||
|
form.appendChild(actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
element.appendChild(form);
|
||||||
|
input.focus();
|
||||||
|
|
||||||
|
if (input.select && input.tagName !== 'SELECT') {
|
||||||
|
input.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
let editorClosed = false;
|
||||||
|
|
||||||
|
const restore = () => {
|
||||||
|
if (editorClosed) return;
|
||||||
|
|
||||||
|
editorClosed = true;
|
||||||
|
element.innerHTML = originalHtml;
|
||||||
|
element.className = originalClasses.join(' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
let saving = false;
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (saving) return;
|
||||||
|
|
||||||
|
const nextValue = input.value;
|
||||||
|
|
||||||
|
if (String(nextValue) === String(currentValue)) {
|
||||||
|
restore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saving = true;
|
||||||
|
element.classList.add('is-saving');
|
||||||
|
|
||||||
|
const result = await apiPost('/api/task.php', {
|
||||||
|
api: 'Edit',
|
||||||
|
task_id: currentTask.id
|
||||||
|
}, {
|
||||||
|
[field]: nextValue
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
alert(result.error || 'Could not update task.');
|
||||||
|
saving = false;
|
||||||
|
restore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editorClosed = true;
|
||||||
|
element.classList.remove('is-saving');
|
||||||
|
currentTask = {
|
||||||
|
...currentTask,
|
||||||
|
[field]: nextValue === '' ? null : nextValue
|
||||||
|
};
|
||||||
|
|
||||||
|
if (field === 'type') {
|
||||||
|
await loadTask(currentTask.id, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await renderTask(currentTask);
|
||||||
|
};
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
await save();
|
||||||
|
});
|
||||||
|
|
||||||
|
form.querySelector('[data-inline-cancel]')?.addEventListener('click', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('keydown', async (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter' && input.tagName === 'TEXTAREA' && (event.ctrlKey || event.metaKey)) {
|
||||||
|
event.preventDefault();
|
||||||
|
await save();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (input.tagName === 'SELECT') {
|
||||||
|
input.addEventListener('change', save);
|
||||||
|
input.addEventListener('blur', () => {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (editorClosed) return;
|
||||||
|
if (element.classList.contains('is-saving')) return;
|
||||||
|
if (element.contains(document.activeElement)) return;
|
||||||
|
restore();
|
||||||
|
}, 120);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTaskInlineInput(field, currentValue) {
|
||||||
|
if (field === 'description') {
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.className = 'form-control task-inline-input';
|
||||||
|
textarea.rows = 5;
|
||||||
|
textarea.value = currentValue;
|
||||||
|
return textarea;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field === 'type') {
|
||||||
|
const select = document.createElement('select');
|
||||||
|
select.className = 'form-select task-inline-select';
|
||||||
|
populateSelect(select, await getTaskTypes(), {
|
||||||
|
includeEmpty: false,
|
||||||
|
selectedValue: currentValue
|
||||||
|
});
|
||||||
|
return select;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field === 'priority') {
|
||||||
|
const select = document.createElement('select');
|
||||||
|
select.className = 'form-select task-inline-select';
|
||||||
|
populateSelect(select, await getTaskPriorities(), {
|
||||||
|
includeEmpty: false,
|
||||||
|
selectedValue: currentValue
|
||||||
|
});
|
||||||
|
return select;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field === 'fix_version') {
|
||||||
|
const select = document.createElement('select');
|
||||||
|
select.className = 'form-select task-inline-select';
|
||||||
|
populateSelect(select, await getProjectVersions(currentTask.project), {
|
||||||
|
placeholder: 'No fix version',
|
||||||
|
selectedValue: currentValue
|
||||||
|
});
|
||||||
|
return select;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field === 'assignee') {
|
||||||
|
const select = document.createElement('select');
|
||||||
|
select.className = 'form-select task-inline-select';
|
||||||
|
populateSelect(select, await getUsers(), {
|
||||||
|
placeholder: 'Unassigned',
|
||||||
|
selectedValue: currentValue
|
||||||
|
});
|
||||||
|
return select;
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.className = 'form-control task-inline-input';
|
||||||
|
input.type = 'text';
|
||||||
|
input.value = currentValue;
|
||||||
|
input.required = field === 'title';
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCustomFieldInput(type, currentValue) {
|
||||||
|
if (type === 'boolean') {
|
||||||
|
const select = document.createElement('select');
|
||||||
|
select.className = 'form-select task-inline-select';
|
||||||
|
[
|
||||||
|
{ id: '', name: 'No value' },
|
||||||
|
{ id: 'true', name: 'True' },
|
||||||
|
{ id: 'false', name: 'False' }
|
||||||
|
].forEach((item) => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = item.id;
|
||||||
|
option.textContent = item.name;
|
||||||
|
option.selected = String(item.id) === String(currentValue);
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
return select;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'text' || type === 'json') {
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.className = 'form-control task-inline-input';
|
||||||
|
textarea.rows = type === 'json' ? 4 : 3;
|
||||||
|
textarea.value = currentValue;
|
||||||
|
return textarea;
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.className = 'form-control task-inline-input';
|
||||||
|
input.type = type === 'date' ? 'date' : 'text';
|
||||||
|
input.inputMode = ['int', 'float'].includes(type) ? 'decimal' : '';
|
||||||
|
input.value = currentValue;
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCustomFieldInlineEditor(element) {
|
||||||
|
if (!canEditTasks() || !currentTask || element.querySelector('.task-inline-form')) return;
|
||||||
|
|
||||||
|
const fieldId = element.dataset.customFieldId;
|
||||||
|
const fieldType = element.dataset.customFieldType ?? 'string';
|
||||||
|
const currentValue = element.dataset.currentValue ?? '';
|
||||||
|
const originalHtml = element.innerHTML;
|
||||||
|
const originalClasses = Array.from(element.classList);
|
||||||
|
|
||||||
|
element.classList.remove('is-empty');
|
||||||
|
element.innerHTML = '';
|
||||||
|
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.className = 'task-inline-form';
|
||||||
|
|
||||||
|
const input = createCustomFieldInput(fieldType, currentValue);
|
||||||
|
form.appendChild(input);
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'task-inline-actions';
|
||||||
|
actions.innerHTML = `
|
||||||
|
<button class="btn btn-sm btn-primary" type="submit">
|
||||||
|
<i class="fa-solid fa-check"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" data-inline-cancel>
|
||||||
|
<i class="fa-solid fa-xmark"></i>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
form.appendChild(actions);
|
||||||
|
|
||||||
|
element.appendChild(form);
|
||||||
|
input.focus();
|
||||||
|
|
||||||
|
if (input.select && input.tagName !== 'SELECT') {
|
||||||
|
input.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
let closed = false;
|
||||||
|
const restore = () => {
|
||||||
|
if (closed) return;
|
||||||
|
|
||||||
|
closed = true;
|
||||||
|
element.innerHTML = originalHtml;
|
||||||
|
element.className = originalClasses.join(' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
const nextValue = input.value;
|
||||||
|
|
||||||
|
if (String(nextValue) === String(currentValue)) {
|
||||||
|
restore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
element.classList.add('is-saving');
|
||||||
|
|
||||||
|
const result = await apiPost('/api/task.php', {
|
||||||
|
api: 'SetCustomFieldValue',
|
||||||
|
task_id: currentTask.id
|
||||||
|
}, {
|
||||||
|
field_id: fieldId,
|
||||||
|
value: nextValue
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
alert(result.error || 'Could not update custom field.');
|
||||||
|
restore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closed = true;
|
||||||
|
await loadTask(currentTask.id, false);
|
||||||
|
};
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
await save();
|
||||||
|
});
|
||||||
|
|
||||||
|
form.querySelector('[data-inline-cancel]')?.addEventListener('click', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('keydown', async (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter' && input.tagName !== 'TEXTAREA') {
|
||||||
|
event.preventDefault();
|
||||||
|
await save();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter' && input.tagName === 'TEXTAREA' && (event.ctrlKey || event.metaKey)) {
|
||||||
|
event.preventDefault();
|
||||||
|
await save();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transitionCurrentTask(transitionId) {
|
||||||
|
if (!currentTask || !transitionId) return;
|
||||||
|
|
||||||
|
const result = await apiPost('/api/task.php', {
|
||||||
|
api: 'TransitionStatus',
|
||||||
|
task_id: currentTask.id
|
||||||
|
}, {
|
||||||
|
transition_id: transitionId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
alert(result.error || 'Could not transition task.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadTask(currentTask.id, false);
|
||||||
|
}
|
||||||
|
|
||||||
181
ProjectKiln/app/js/home/task_list.js
Normal file
181
ProjectKiln/app/js/home/task_list.js
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
async function loadProjectTasks(projectId, page = 1, pushHistory = true) {
|
||||||
|
const view = showView('taskListView');
|
||||||
|
|
||||||
|
if (!view) return;
|
||||||
|
|
||||||
|
if (pushHistory) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('page', 'home');
|
||||||
|
url.searchParams.set('project', projectId);
|
||||||
|
url.searchParams.set('tasks', '1');
|
||||||
|
url.searchParams.delete('task');
|
||||||
|
url.searchParams.delete('version');
|
||||||
|
url.searchParams.delete('profile');
|
||||||
|
url.searchParams.delete('admin');
|
||||||
|
window.history.pushState({}, '', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentProjectTaskProject = projectId;
|
||||||
|
setText('taskListProjectKey', projectId);
|
||||||
|
setText('taskListTitle', `${projectId} Tasks`);
|
||||||
|
|
||||||
|
const container = document.getElementById('taskListContainer');
|
||||||
|
const empty = document.getElementById('taskListEmpty');
|
||||||
|
|
||||||
|
container.innerHTML = '<div class="version-task-loading">Loading tasks...</div>';
|
||||||
|
empty.hidden = true;
|
||||||
|
currentProjectTasks = [];
|
||||||
|
projectTaskPagination = null;
|
||||||
|
|
||||||
|
const result = await apiGet('/api/task.php', {
|
||||||
|
api: 'ListTasksByProject',
|
||||||
|
project_id: projectId,
|
||||||
|
page,
|
||||||
|
per_page: taskTablePageSize,
|
||||||
|
sort: projectTaskSort.field,
|
||||||
|
direction: projectTaskSort.direction
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
container.innerHTML = '<div class="alert alert-danger mb-0">Could not load tasks.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.tasks.length) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
empty.hidden = false;
|
||||||
|
renderTaskTablePagination('project');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentProjectTasks = await normalizeTaskTableRows(result.tasks);
|
||||||
|
projectTaskPagination = result.pagination ?? null;
|
||||||
|
renderTaskTable('project');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function normalizeTaskTableRows(tasks) {
|
||||||
|
const [types, priorities] = await Promise.all([
|
||||||
|
getTaskTypes(),
|
||||||
|
getTaskPriorities()
|
||||||
|
]);
|
||||||
|
|
||||||
|
return tasks.map((task) => {
|
||||||
|
const normalizedTask = typeof task === 'string'
|
||||||
|
? { id: task, title: '', type: null, priority: null }
|
||||||
|
: task;
|
||||||
|
|
||||||
|
const type = types.find((item) => String(item.id) === String(normalizedTask.type)) ?? null;
|
||||||
|
const priority = priorities.find((item) => String(item.id) === String(normalizedTask.priority)) ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...normalizedTask,
|
||||||
|
typeOption: type,
|
||||||
|
priorityOption: priority,
|
||||||
|
typeName: type?.name ?? '',
|
||||||
|
priorityName: priority?.name ?? '',
|
||||||
|
statusName: normalizedTask.status_state?.name ?? '',
|
||||||
|
statusColor: normalizedTask.status_state?.color ?? '',
|
||||||
|
assigneeUser: normalizedTask.assignee
|
||||||
|
? {
|
||||||
|
id: normalizedTask.assignee,
|
||||||
|
name: normalizedTask.assignee_name ?? `User ${normalizedTask.assignee}`,
|
||||||
|
picture: normalizedTask.assignee_picture ?? null
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
assigneeName: normalizedTask.assignee_name ?? ''
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTaskTable(kind) {
|
||||||
|
const isVersion = kind === 'version';
|
||||||
|
const list = document.getElementById(isVersion ? 'versionTaskList' : 'taskListContainer');
|
||||||
|
const empty = document.getElementById(isVersion ? 'versionTaskEmpty' : 'taskListEmpty');
|
||||||
|
const tasks = isVersion ? currentVersionTasks : currentProjectTasks;
|
||||||
|
|
||||||
|
if (!list || !empty) return;
|
||||||
|
|
||||||
|
updateTaskTableSortButtons(kind);
|
||||||
|
|
||||||
|
list.innerHTML = '';
|
||||||
|
empty.hidden = tasks.length > 0;
|
||||||
|
|
||||||
|
tasks.forEach((task) => {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.className = 'version-task-row version-task-item';
|
||||||
|
button.innerHTML = `
|
||||||
|
<span class="version-task-id">${escapeHtml(task.id)}</span>
|
||||||
|
<span class="version-task-title">${escapeHtml(task.title || '-')}</span>
|
||||||
|
<span>${renderMetaBadge(task.typeOption)}</span>
|
||||||
|
<span>${renderMetaBadge(task.priorityOption)}</span>
|
||||||
|
<span>${renderUser(task.assigneeUser)}</span>
|
||||||
|
<span>${renderTaskTableStatus(task)}</span>
|
||||||
|
`;
|
||||||
|
button.addEventListener('click', () => loadTask(task.id));
|
||||||
|
|
||||||
|
list.appendChild(button);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderTaskTablePagination(kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTaskTableStatus(task) {
|
||||||
|
if (!task.statusName) return '-';
|
||||||
|
|
||||||
|
const color = task.statusColor || '#6c757d';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<span class="version-task-status" style="--task-status-color: ${escapeHtml(color)}">
|
||||||
|
${escapeHtml(task.statusName)}
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTaskTablePagination(kind) {
|
||||||
|
const isVersion = kind === 'version';
|
||||||
|
const pagination = isVersion ? versionTaskPagination : projectTaskPagination;
|
||||||
|
const container = document.getElementById(isVersion ? 'versionTaskPagination' : 'projectTaskPagination');
|
||||||
|
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (!pagination || pagination.total <= pagination.per_page) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = ((pagination.page - 1) * pagination.per_page) + 1;
|
||||||
|
const end = Math.min(pagination.total, pagination.page * pagination.per_page);
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<span>${escapeHtml(start)}-${escapeHtml(end)} of ${escapeHtml(pagination.total)}</span>
|
||||||
|
<div class="version-task-page-actions">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" data-task-page="${escapeHtml(kind)}" data-page="${escapeHtml(pagination.page - 1)}" ${pagination.page <= 1 ? 'disabled' : ''}>
|
||||||
|
<i class="fa-solid fa-chevron-left"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" data-task-page="${escapeHtml(kind)}" data-page="${escapeHtml(pagination.page + 1)}" ${pagination.page >= pagination.total_pages ? 'disabled' : ''}>
|
||||||
|
<i class="fa-solid fa-chevron-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTaskTableSortButtons(kind) {
|
||||||
|
const isVersion = kind === 'version';
|
||||||
|
const selector = isVersion ? '[data-version-task-sort]' : '[data-project-task-sort]';
|
||||||
|
const state = isVersion ? versionTaskSort : projectTaskSort;
|
||||||
|
|
||||||
|
document.querySelectorAll(selector).forEach((button) => {
|
||||||
|
const field = isVersion ? button.dataset.versionTaskSort : button.dataset.projectTaskSort;
|
||||||
|
const isActive = field === state.field;
|
||||||
|
const icon = button.querySelector('i');
|
||||||
|
|
||||||
|
button.classList.toggle('is-active', isActive);
|
||||||
|
|
||||||
|
if (!icon) return;
|
||||||
|
|
||||||
|
icon.className = isActive
|
||||||
|
? `fa-solid ${state.direction === 'asc' ? 'fa-sort-up' : 'fa-sort-down'}`
|
||||||
|
: 'fa-solid fa-sort';
|
||||||
|
});
|
||||||
|
}
|
||||||
258
ProjectKiln/app/js/home/version.js
Normal file
258
ProjectKiln/app/js/home/version.js
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
function openVersionCreatePopup(projectId) {
|
||||||
|
resetVersionPopup();
|
||||||
|
document.getElementById('versionFormProjectId').value = projectId;
|
||||||
|
openPopup('createVersion');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openVersionEditPopup() {
|
||||||
|
if (!currentVersion) return;
|
||||||
|
|
||||||
|
resetVersionPopup();
|
||||||
|
|
||||||
|
const form = document.getElementById('createVersionForm');
|
||||||
|
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
form.dataset.mode = 'edit';
|
||||||
|
document.getElementById('versionPopupTitle').textContent = `Edit ${currentVersion.name}`;
|
||||||
|
document.getElementById('versionPopupSubmit').textContent = 'Update Version';
|
||||||
|
document.getElementById('versionFormVersionId').value = currentVersion.id;
|
||||||
|
document.getElementById('versionFormProjectId').value = currentVersion.project;
|
||||||
|
document.getElementById('versionFormName').value = currentVersion.name ?? '';
|
||||||
|
document.getElementById('versionFormDescription').value = currentVersion.description ?? '';
|
||||||
|
document.getElementById('versionFormDueDate').value = currentVersion.due_date ?? '';
|
||||||
|
document.getElementById('versionFormReleasedDate').value = currentVersion.released_date ?? '';
|
||||||
|
|
||||||
|
openPopup('createVersion');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVersion(versionId, pushHistory = true) {
|
||||||
|
const view = showView('versionView');
|
||||||
|
|
||||||
|
if (!view) return;
|
||||||
|
|
||||||
|
if (pushHistory) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('page', 'home');
|
||||||
|
url.searchParams.set('version', versionId);
|
||||||
|
url.searchParams.delete('task');
|
||||||
|
url.searchParams.delete('project');
|
||||||
|
url.searchParams.delete('tasks');
|
||||||
|
url.searchParams.delete('profile');
|
||||||
|
url.searchParams.delete('admin');
|
||||||
|
window.history.pushState({}, '', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiGet('/api/version.php', {
|
||||||
|
api: 'VersionInfo',
|
||||||
|
version_id: versionId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
setText('versionName', 'Version not found');
|
||||||
|
setText('versionDescription', '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await renderVersion(result.version);
|
||||||
|
await loadVersionTasks(versionId);
|
||||||
|
await ensureProjectOpen(result.version.project);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderVersion(version) {
|
||||||
|
currentVersion = version;
|
||||||
|
|
||||||
|
setVersionEditableText('versionKey', `Version ${version.id}`);
|
||||||
|
setVersionEditableText('versionName', version.name, version.name);
|
||||||
|
setVersionEditableText('versionDescription', version.description, version.description, 'No description provided.');
|
||||||
|
setText('versionProject', version.project);
|
||||||
|
setText('versionCreated', version.created_date || '-');
|
||||||
|
setVersionEditableText('versionDueDate', version.due_date, version.due_date, 'No due date');
|
||||||
|
setVersionEditableText('versionReleasedDate', version.released_date, version.released_date, 'Not released');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVersionTasks(versionId, page = 1) {
|
||||||
|
const list = document.getElementById('versionTaskList');
|
||||||
|
const empty = document.getElementById('versionTaskEmpty');
|
||||||
|
|
||||||
|
if (!list || !empty) return;
|
||||||
|
|
||||||
|
list.innerHTML = '<div class="version-task-loading">Loading tasks...</div>';
|
||||||
|
empty.hidden = true;
|
||||||
|
currentVersionTasks = [];
|
||||||
|
versionTaskPagination = null;
|
||||||
|
|
||||||
|
const result = await apiGet('/api/task.php', {
|
||||||
|
api: 'ListTasksByVersion',
|
||||||
|
version_id: versionId,
|
||||||
|
page,
|
||||||
|
per_page: taskTablePageSize,
|
||||||
|
sort: versionTaskSort.field,
|
||||||
|
direction: versionTaskSort.direction
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
list.innerHTML = '<div class="alert alert-danger mb-0">Could not load tasks.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.tasks.length) {
|
||||||
|
list.innerHTML = '';
|
||||||
|
empty.hidden = false;
|
||||||
|
renderTaskTablePagination('version');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentVersionTasks = await normalizeTaskTableRows(result.tasks);
|
||||||
|
versionTaskPagination = result.pagination ?? null;
|
||||||
|
renderTaskTable('version');
|
||||||
|
}
|
||||||
|
|
||||||
|
function initVersionInlineEditing() {
|
||||||
|
if (!canEditVersions()) return;
|
||||||
|
|
||||||
|
document.querySelectorAll('.version-editable').forEach((element) => {
|
||||||
|
if (element.dataset.inlineReady === 'true') return;
|
||||||
|
|
||||||
|
element.dataset.inlineReady = 'true';
|
||||||
|
element.title = 'Click to edit';
|
||||||
|
|
||||||
|
element.addEventListener('click', () => openVersionInlineEditor(element));
|
||||||
|
element.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
openVersionInlineEditor(element);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openVersionInlineEditor(element) {
|
||||||
|
if (!canEditVersions() || !currentVersion || element.querySelector('.version-inline-form')) return;
|
||||||
|
|
||||||
|
const field = element.dataset.versionField;
|
||||||
|
const currentValue = element.dataset.currentValue ?? '';
|
||||||
|
const originalHtml = element.innerHTML;
|
||||||
|
const originalClasses = Array.from(element.classList);
|
||||||
|
|
||||||
|
element.classList.remove('is-empty');
|
||||||
|
element.innerHTML = '';
|
||||||
|
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.className = 'version-inline-form';
|
||||||
|
|
||||||
|
const input = createVersionInlineInput(field, currentValue);
|
||||||
|
form.appendChild(input);
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'version-inline-actions';
|
||||||
|
actions.innerHTML = `
|
||||||
|
<button class="btn btn-sm btn-primary" type="submit">
|
||||||
|
<i class="fa-solid fa-check"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" data-version-inline-cancel>
|
||||||
|
<i class="fa-solid fa-xmark"></i>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
form.appendChild(actions);
|
||||||
|
|
||||||
|
element.appendChild(form);
|
||||||
|
input.focus();
|
||||||
|
|
||||||
|
if (input.select && input.type !== 'date') {
|
||||||
|
input.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
let editorClosed = false;
|
||||||
|
|
||||||
|
const restore = () => {
|
||||||
|
if (editorClosed) return;
|
||||||
|
|
||||||
|
editorClosed = true;
|
||||||
|
element.innerHTML = originalHtml;
|
||||||
|
element.className = originalClasses.join(' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
let saving = false;
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (saving) return;
|
||||||
|
|
||||||
|
const nextValue = input.value;
|
||||||
|
|
||||||
|
if (String(nextValue) === String(currentValue)) {
|
||||||
|
restore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saving = true;
|
||||||
|
element.classList.add('is-saving');
|
||||||
|
|
||||||
|
const result = await apiPost('/api/version.php', {
|
||||||
|
api: 'Edit',
|
||||||
|
version_id: currentVersion.id
|
||||||
|
}, {
|
||||||
|
[field]: nextValue
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
alert(result.error || 'Could not update version.');
|
||||||
|
saving = false;
|
||||||
|
restore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editorClosed = true;
|
||||||
|
element.classList.remove('is-saving');
|
||||||
|
currentVersion = {
|
||||||
|
...currentVersion,
|
||||||
|
[field]: nextValue === '' ? null : nextValue
|
||||||
|
};
|
||||||
|
|
||||||
|
taskLookupCache.versionsByProject.delete(currentVersion.project);
|
||||||
|
await renderVersion(currentVersion);
|
||||||
|
projectTreePromise = loadProjectTree();
|
||||||
|
await projectTreePromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
await save();
|
||||||
|
});
|
||||||
|
|
||||||
|
form.querySelector('[data-version-inline-cancel]')?.addEventListener('click', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('keydown', async (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter' && input.tagName === 'TEXTAREA' && (event.ctrlKey || event.metaKey)) {
|
||||||
|
event.preventDefault();
|
||||||
|
await save();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createVersionInlineInput(field, currentValue) {
|
||||||
|
if (field === 'description') {
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.className = 'form-control version-inline-input';
|
||||||
|
textarea.rows = 5;
|
||||||
|
textarea.value = currentValue;
|
||||||
|
return textarea;
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.className = 'form-control version-inline-input';
|
||||||
|
input.type = field === 'due_date' || field === 'released_date' ? 'date' : 'text';
|
||||||
|
input.value = currentValue;
|
||||||
|
input.required = field === 'name';
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -16,6 +16,30 @@ const THEME_STYLES = [
|
|||||||
'beige' => 'app/css/beige_mode.css',
|
'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
|
function currentTheme(): string
|
||||||
{
|
{
|
||||||
$theme = 'dark';
|
$theme = 'dark';
|
||||||
@@ -43,24 +67,34 @@ function currentTheme(): string
|
|||||||
return array_key_exists($storedTheme, THEME_STYLES) ? $storedTheme : $theme;
|
return array_key_exists($storedTheme, THEME_STYLES) ? $storedTheme : $theme;
|
||||||
}
|
}
|
||||||
|
|
||||||
$page = $_GET['page'] ?? 'home';
|
$isInstalled = projectKilnIsInstalled();
|
||||||
$theme = currentTheme();
|
$theme = $isInstalled ? currentTheme() : 'dark';
|
||||||
|
|
||||||
$routes = [
|
if (!$isInstalled) {
|
||||||
|
define('PROJECTKILN_INSTALL_EMBEDDED', true);
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
require __DIR__ . '/install/install.php';
|
||||||
|
$pageContent = ob_get_clean();
|
||||||
|
} else {
|
||||||
|
$page = $_GET['page'] ?? 'home';
|
||||||
|
|
||||||
|
$routes = [
|
||||||
'home' => 'home.php',
|
'home' => 'home.php',
|
||||||
'login' => 'login.php',
|
'login' => 'login.php',
|
||||||
'logout' => 'logout.php',
|
'logout' => 'logout.php',
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!isset($routes[$page])) {
|
if (!isset($routes[$page])) {
|
||||||
$pageFile = __DIR__ . '/app/404.php';
|
$pageFile = __DIR__ . '/app/404.php';
|
||||||
} else {
|
} else {
|
||||||
$pageFile = __DIR__ . '/app/' . $routes[$page];
|
$pageFile = __DIR__ . '/app/' . $routes[$page];
|
||||||
}
|
}
|
||||||
|
|
||||||
ob_start();
|
ob_start();
|
||||||
require $pageFile;
|
require $pageFile;
|
||||||
$pageContent = ob_get_clean();
|
$pageContent = ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
|
|||||||
251
ProjectKiln/install/install.php
Normal file
251
ProjectKiln/install/install.php
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../db.php';
|
||||||
|
|
||||||
|
function installerEmbedded(): bool {
|
||||||
|
return defined('PROJECTKILN_INSTALL_EMBEDDED') && PROJECTKILN_INSTALL_EMBEDDED === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function installerTableExists(PDO $pdo, string $table): bool {
|
||||||
|
$stmt = $pdo->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');
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<?php if (!installerEmbedded()): ?>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>ProjectKiln Install</title>
|
||||||
|
<link rel="stylesheet" href="../app/bootstrap/css/bootstrap.min.css">
|
||||||
|
<link rel="stylesheet" href="../app/fontawesome/css/all.min.css">
|
||||||
|
<link rel="stylesheet" href="../app/css/main.css">
|
||||||
|
<link rel="stylesheet" href="../app/css/dark_mode.css">
|
||||||
|
<link rel="stylesheet" href="../app/css/login.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<?php endif; ?>
|
||||||
|
<main class="container auth-container d-flex align-items-center justify-content-center">
|
||||||
|
<div class="card auth-card shadow-lg border">
|
||||||
|
<div class="row g-0 h-100">
|
||||||
|
<div class="col-lg-6 auth-brand p-5 d-flex flex-column justify-content-center">
|
||||||
|
<div class="auth-logo mb-4">
|
||||||
|
<i class="fa-solid fa-fire-flame-simple"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="display-5 fw-bold mb-3">
|
||||||
|
ProjectKiln Setup
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="text-secondary mb-0">
|
||||||
|
Create the first administrator account to finish the installation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-6 p-5 d-flex flex-column justify-content-center">
|
||||||
|
<?php if ($installError !== null): ?>
|
||||||
|
<div class="alert alert-danger mb-4">
|
||||||
|
<i class="fa-solid fa-circle-exclamation me-2"></i>
|
||||||
|
Database setup failed: <?= htmlspecialchars($installError, ENT_QUOTES, 'UTF-8') ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($formError !== null): ?>
|
||||||
|
<div class="alert alert-danger mb-4">
|
||||||
|
<i class="fa-solid fa-circle-exclamation me-2"></i>
|
||||||
|
<?= htmlspecialchars($formError, ENT_QUOTES, 'UTF-8') ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Username</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">
|
||||||
|
<i class="fa-solid fa-user"></i>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
class="form-control"
|
||||||
|
value="<?= oldInput('username') ?>"
|
||||||
|
autocomplete="username"
|
||||||
|
required
|
||||||
|
<?= $installError !== null ? 'disabled' : '' ?>
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Email Address</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">
|
||||||
|
<i class="fa-solid fa-envelope"></i>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
class="form-control"
|
||||||
|
value="<?= oldInput('email') ?>"
|
||||||
|
autocomplete="email"
|
||||||
|
required
|
||||||
|
<?= $installError !== null ? 'disabled' : '' ?>
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Password</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">
|
||||||
|
<i class="fa-solid fa-lock"></i>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
class="form-control"
|
||||||
|
autocomplete="new-password"
|
||||||
|
minlength="8"
|
||||||
|
required
|
||||||
|
<?= $installError !== null ? 'disabled' : '' ?>
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label">Confirm Password</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">
|
||||||
|
<i class="fa-solid fa-key"></i>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password_confirm"
|
||||||
|
class="form-control"
|
||||||
|
autocomplete="new-password"
|
||||||
|
minlength="8"
|
||||||
|
required
|
||||||
|
<?= $installError !== null ? 'disabled' : '' ?>
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary w-100 auth-submit"
|
||||||
|
<?= $installError !== null ? 'disabled' : '' ?>
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-user-shield me-2"></i>
|
||||||
|
Create Admin
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<?php if (!installerEmbedded()): ?>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
<?php endif; ?>
|
||||||
495
ProjectKiln/install/install_db.php
Normal file
495
ProjectKiln/install/install_db.php
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../db.php';
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
function quoteIdentifier(string $identifier): string {
|
||||||
|
if (!preg_match('/^[A-Za-z0-9_]+$/', $identifier)) {
|
||||||
|
throw new InvalidArgumentException('Invalid identifier: ' . $identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '`' . $identifier . '`';
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableExists(PDO $pdo, string $table): bool {
|
||||||
|
$stmt = $pdo->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 '<!doctype html><meta charset="utf-8"><title>ProjectKiln install failed</title>';
|
||||||
|
echo '<h1>Database install failed</h1>';
|
||||||
|
echo '<pre>' . htmlspecialchars($exception->getMessage(), ENT_QUOTES, 'UTF-8') . '</pre>';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user