@1mancompany/onemancompany 0.7.33 → 0.7.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/frontend/app.js +393 -59
- package/frontend/index.html +25 -25
- package/frontend/style.css +152 -13
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/onemancompany/agents/product_tools.py +54 -0
- package/src/onemancompany/api/routes.py +58 -74
- package/src/onemancompany/core/models.py +1 -0
- package/src/onemancompany/core/product.py +32 -8
- package/src/onemancompany/core/product_triggers.py +1 -0
package/frontend/app.js
CHANGED
|
@@ -95,6 +95,7 @@ class AppController {
|
|
|
95
95
|
const { employees, tasks, rooms, tools, activity_log, version, office_layout } = data;
|
|
96
96
|
console.debug(`[bootstrap] /api/bootstrap took ${(performance.now() - t0).toFixed(0)}ms`);
|
|
97
97
|
|
|
98
|
+
this._cachedEmployees = employees || [];
|
|
98
99
|
this.updateRoster(employees);
|
|
99
100
|
this.updateOneononeDropdown(employees);
|
|
100
101
|
this.updateProjectsPanel();
|
|
@@ -201,6 +202,7 @@ class AppController {
|
|
|
201
202
|
|
|
202
203
|
async _fetchAndRenderRoster() {
|
|
203
204
|
const employees = await fetch('/api/employees').then(r => r.json());
|
|
205
|
+
this._cachedEmployees = employees || [];
|
|
204
206
|
this.updateRoster(employees);
|
|
205
207
|
this.updateOneononeDropdown(employees);
|
|
206
208
|
if (window.officeRenderer) {
|
|
@@ -1090,11 +1092,11 @@ class AppController {
|
|
|
1090
1092
|
if (data.status === 'ok') {
|
|
1091
1093
|
console.log('Abort all result:', data);
|
|
1092
1094
|
} else {
|
|
1093
|
-
|
|
1095
|
+
this._showToast(data.detail || data.message || 'Failed to abort all tasks', 'error');
|
|
1094
1096
|
}
|
|
1095
1097
|
} catch (e) {
|
|
1096
1098
|
console.error('Abort all failed:', e);
|
|
1097
|
-
|
|
1099
|
+
this._showToast('Failed to abort all tasks', 'error');
|
|
1098
1100
|
}
|
|
1099
1101
|
});
|
|
1100
1102
|
|
|
@@ -1259,7 +1261,7 @@ class AppController {
|
|
|
1259
1261
|
startBtn.disabled = false;
|
|
1260
1262
|
|
|
1261
1263
|
if (res.error) {
|
|
1262
|
-
|
|
1264
|
+
this._showToast(res.error, 'error');
|
|
1263
1265
|
return;
|
|
1264
1266
|
}
|
|
1265
1267
|
|
|
@@ -1752,7 +1754,7 @@ class AppController {
|
|
|
1752
1754
|
})
|
|
1753
1755
|
.then(data => {
|
|
1754
1756
|
if (data.error) {
|
|
1755
|
-
|
|
1757
|
+
this._showToast(`Cannot dismiss: ${data.error}`, 'error');
|
|
1756
1758
|
} else {
|
|
1757
1759
|
this.closeEmployeeDetail();
|
|
1758
1760
|
this.addLog(`Dismissed ${data.name} (${data.nickname}) — ${data.reason}`);
|
|
@@ -1761,7 +1763,7 @@ class AppController {
|
|
|
1761
1763
|
})
|
|
1762
1764
|
.catch(err => {
|
|
1763
1765
|
console.error('Fire employee error:', err);
|
|
1764
|
-
|
|
1766
|
+
this._showToast('Failed to dismiss employee', 'error');
|
|
1765
1767
|
});
|
|
1766
1768
|
}
|
|
1767
1769
|
|
|
@@ -2027,11 +2029,11 @@ class AppController {
|
|
|
2027
2029
|
if (data.status === 'ok') {
|
|
2028
2030
|
this._fetchCronList(empId);
|
|
2029
2031
|
} else {
|
|
2030
|
-
|
|
2032
|
+
this._showToast(data.detail || data.message || 'Failed to stop cron', 'error');
|
|
2031
2033
|
}
|
|
2032
2034
|
} catch (err) {
|
|
2033
2035
|
console.error('Failed to cancel cron:', err);
|
|
2034
|
-
|
|
2036
|
+
this._showToast('Failed to stop cron job', 'error');
|
|
2035
2037
|
}
|
|
2036
2038
|
}
|
|
2037
2039
|
|
|
@@ -2045,11 +2047,11 @@ class AppController {
|
|
|
2045
2047
|
if (data.status === 'ok') {
|
|
2046
2048
|
this._fetchCronList(empId);
|
|
2047
2049
|
} else {
|
|
2048
|
-
|
|
2050
|
+
this._showToast(data.detail || data.message || 'Failed to stop all crons', 'error');
|
|
2049
2051
|
}
|
|
2050
2052
|
} catch (err) {
|
|
2051
2053
|
console.error('Failed to stop all crons:', err);
|
|
2052
|
-
|
|
2054
|
+
this._showToast('Failed to stop all cron jobs', 'error');
|
|
2053
2055
|
}
|
|
2054
2056
|
}
|
|
2055
2057
|
|
|
@@ -2737,7 +2739,7 @@ class AppController {
|
|
|
2737
2739
|
fetch('/api/ceo/dnd').then(r => r.json()).then(data => {
|
|
2738
2740
|
dndBtn.classList.toggle('active', data.dnd);
|
|
2739
2741
|
if (data.dnd) dndBtn.title = 'Do Not Disturb (ON)';
|
|
2740
|
-
}).catch(
|
|
2742
|
+
}).catch(err => console.warn('[dnd] state load failed:', err));
|
|
2741
2743
|
}
|
|
2742
2744
|
|
|
2743
2745
|
// ===== @Mention Autocomplete ===== //
|
|
@@ -5898,7 +5900,7 @@ class AppController {
|
|
|
5898
5900
|
});
|
|
5899
5901
|
const result = await resp.json();
|
|
5900
5902
|
if (result.status === 'error') {
|
|
5901
|
-
|
|
5903
|
+
this._showToast(result.message, 'error');
|
|
5902
5904
|
} else {
|
|
5903
5905
|
this._renderSystemCrons();
|
|
5904
5906
|
}
|
|
@@ -6679,7 +6681,7 @@ class AppController {
|
|
|
6679
6681
|
window.open(data.auth_url, '_blank', 'width=600,height=700');
|
|
6680
6682
|
setTimeout(() => this.openToolDetail(toolId), 5000);
|
|
6681
6683
|
} else {
|
|
6682
|
-
|
|
6684
|
+
this._showToast(data.message || 'OAuth login failed', 'error');
|
|
6683
6685
|
}
|
|
6684
6686
|
break;
|
|
6685
6687
|
}
|
|
@@ -6692,7 +6694,7 @@ class AppController {
|
|
|
6692
6694
|
case 'credentials': {
|
|
6693
6695
|
const clientId = document.getElementById('tool-oauth-client-id')?.value || '';
|
|
6694
6696
|
const clientSecret = document.getElementById('tool-oauth-client-secret')?.value || '';
|
|
6695
|
-
if (!clientId || !clientSecret) {
|
|
6697
|
+
if (!clientId || !clientSecret) { this._showToast('Both Client ID and Client Secret required', 'error'); return; }
|
|
6696
6698
|
const res = await fetch(`/api/tools/${esc}/oauth/credentials`, {
|
|
6697
6699
|
method: 'POST',
|
|
6698
6700
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -6700,7 +6702,7 @@ class AppController {
|
|
|
6700
6702
|
});
|
|
6701
6703
|
const data = await res.json();
|
|
6702
6704
|
if (data.status === 'ok') this.openToolDetail(toolId);
|
|
6703
|
-
else
|
|
6705
|
+
else this._showToast(data.message || 'Failed', 'error');
|
|
6704
6706
|
break;
|
|
6705
6707
|
}
|
|
6706
6708
|
case 'save_env': {
|
|
@@ -6714,7 +6716,7 @@ class AppController {
|
|
|
6714
6716
|
});
|
|
6715
6717
|
const data = await res.json();
|
|
6716
6718
|
if (data.status === 'ok') this.openToolDetail(toolId);
|
|
6717
|
-
else
|
|
6719
|
+
else this._showToast(data.message || 'Failed', 'error');
|
|
6718
6720
|
break;
|
|
6719
6721
|
}
|
|
6720
6722
|
}
|
|
@@ -6725,7 +6727,7 @@ class AppController {
|
|
|
6725
6727
|
async _templateOpen(toolId, filename) {
|
|
6726
6728
|
const esc = encodeURIComponent;
|
|
6727
6729
|
const res = await fetch(`/api/tools/${esc(toolId)}/templates/${esc(filename)}`);
|
|
6728
|
-
if (!res.ok) {
|
|
6730
|
+
if (!res.ok) { this._showToast('Failed to load template', 'error'); return; }
|
|
6729
6731
|
const data = await res.json();
|
|
6730
6732
|
const body = document.getElementById('tool-list-body');
|
|
6731
6733
|
const escH = (t) => this._escapeHtml(t);
|
|
@@ -6743,7 +6745,7 @@ class AppController {
|
|
|
6743
6745
|
|
|
6744
6746
|
async _templateSave(toolId, filename) {
|
|
6745
6747
|
const content = document.getElementById('template-editor')?.value || '';
|
|
6746
|
-
if (!content.trim()) {
|
|
6748
|
+
if (!content.trim()) { this._showToast('Template cannot be empty', 'error'); return; }
|
|
6747
6749
|
const esc = encodeURIComponent;
|
|
6748
6750
|
const res = await fetch(`/api/tools/${esc(toolId)}/templates/${esc(filename)}`, {
|
|
6749
6751
|
method: 'PUT',
|
|
@@ -6752,7 +6754,7 @@ class AppController {
|
|
|
6752
6754
|
});
|
|
6753
6755
|
const data = await res.json();
|
|
6754
6756
|
if (data.status === 'ok') this.openToolDetail(toolId);
|
|
6755
|
-
else
|
|
6757
|
+
else this._showToast(data.message || 'Save failed', 'error');
|
|
6756
6758
|
}
|
|
6757
6759
|
|
|
6758
6760
|
async _templateDelete(toolId, filename) {
|
|
@@ -6761,7 +6763,7 @@ class AppController {
|
|
|
6761
6763
|
const res = await fetch(`/api/tools/${esc(toolId)}/templates/${esc(filename)}`, { method: 'DELETE' });
|
|
6762
6764
|
const data = await res.json();
|
|
6763
6765
|
if (data.status === 'ok') this.openToolDetail(toolId);
|
|
6764
|
-
else
|
|
6766
|
+
else this._showToast(data.message || 'Delete failed', 'error');
|
|
6765
6767
|
}
|
|
6766
6768
|
|
|
6767
6769
|
_templateNew(toolId, templatesDir) {
|
|
@@ -6961,7 +6963,7 @@ class AppController {
|
|
|
6961
6963
|
}
|
|
6962
6964
|
} catch (err) {
|
|
6963
6965
|
this._chatPanel.showTyping(false);
|
|
6964
|
-
|
|
6966
|
+
this._showToast(`Failed to send message: ${err.message}`, 'error');
|
|
6965
6967
|
}
|
|
6966
6968
|
// Reply arrives via WebSocket conversation_message event
|
|
6967
6969
|
}
|
|
@@ -6987,7 +6989,7 @@ class AppController {
|
|
|
6987
6989
|
const empName = this._resolveEmployeeName(data.employee_id || '');
|
|
6988
6990
|
this.logEntry('SYSTEM', `🧹 Cleared 1-on-1 history for ${empName}.`, 'system');
|
|
6989
6991
|
} catch (err) {
|
|
6990
|
-
|
|
6992
|
+
this._showToast(`Failed to clear history: ${err.message}`, 'error');
|
|
6991
6993
|
}
|
|
6992
6994
|
}
|
|
6993
6995
|
|
|
@@ -7099,6 +7101,7 @@ class AppController {
|
|
|
7099
7101
|
}
|
|
7100
7102
|
|
|
7101
7103
|
_escapeHtml(text) {
|
|
7104
|
+
if (text == null) return '';
|
|
7102
7105
|
const div = document.createElement('div');
|
|
7103
7106
|
div.textContent = text;
|
|
7104
7107
|
return div.innerHTML;
|
|
@@ -7396,20 +7399,15 @@ class AppController {
|
|
|
7396
7399
|
list.appendChild(row);
|
|
7397
7400
|
}
|
|
7398
7401
|
|
|
7399
|
-
|
|
7400
|
-
|
|
7401
|
-
|
|
7402
|
-
|
|
7403
|
-
|
|
7404
|
-
|
|
7405
|
-
|
|
7406
|
-
|
|
7407
|
-
|
|
7408
|
-
opt.textContent = `${emp.name || emp.id} (${emp.role || ''})`;
|
|
7409
|
-
sel.appendChild(opt);
|
|
7410
|
-
}
|
|
7411
|
-
} catch (e) {
|
|
7412
|
-
console.debug('Failed to populate owner dropdown:', e);
|
|
7402
|
+
_populateProductOwnerDropdown() {
|
|
7403
|
+
const sel = document.getElementById('create-product-owner');
|
|
7404
|
+
if (!sel) return;
|
|
7405
|
+
sel.innerHTML = '<option value="">Select owner...</option>';
|
|
7406
|
+
for (const emp of (this._cachedEmployees || [])) {
|
|
7407
|
+
const opt = document.createElement('option');
|
|
7408
|
+
opt.value = emp.id;
|
|
7409
|
+
opt.textContent = `${emp.name || emp.id} (${emp.role || ''})`;
|
|
7410
|
+
sel.appendChild(opt);
|
|
7413
7411
|
}
|
|
7414
7412
|
}
|
|
7415
7413
|
|
|
@@ -7555,6 +7553,7 @@ class AppController {
|
|
|
7555
7553
|
{ id: 'issues', label: `Issues (${issues.length})` },
|
|
7556
7554
|
{ id: 'kanban', label: 'Kanban' },
|
|
7557
7555
|
{ id: 'roadmap', label: 'Roadmap' },
|
|
7556
|
+
{ id: 'reviews', label: `Reviews (${(data.reviews || []).length})` },
|
|
7558
7557
|
{ id: 'activity', label: 'Activity' },
|
|
7559
7558
|
{ id: 'projects', label: `Projects (${projects.length})` },
|
|
7560
7559
|
];
|
|
@@ -7591,6 +7590,8 @@ class AppController {
|
|
|
7591
7590
|
this._renderProductKanban(slug, container, data);
|
|
7592
7591
|
} else if (tabId === 'roadmap') {
|
|
7593
7592
|
this._renderProductRoadmap(slug, container);
|
|
7593
|
+
} else if (tabId === 'reviews') {
|
|
7594
|
+
this._renderProductReviews(data.reviews || [], slug, container);
|
|
7594
7595
|
} else if (tabId === 'activity') {
|
|
7595
7596
|
this._renderProductActivity(slug, container);
|
|
7596
7597
|
} else if (tabId === 'projects') {
|
|
@@ -7722,15 +7723,13 @@ class AppController {
|
|
|
7722
7723
|
ownerEl.className = 'form-input';
|
|
7723
7724
|
ownerEl.style.width = 'auto';
|
|
7724
7725
|
ownerEl.innerHTML = '<option value="">Unassigned</option>';
|
|
7725
|
-
|
|
7726
|
-
|
|
7727
|
-
|
|
7728
|
-
|
|
7729
|
-
|
|
7730
|
-
|
|
7731
|
-
|
|
7732
|
-
ownerEl.value = product.owner_id || '';
|
|
7733
|
-
});
|
|
7726
|
+
for (const emp of (this._cachedEmployees || [])) {
|
|
7727
|
+
const opt = document.createElement('option');
|
|
7728
|
+
opt.value = emp.id;
|
|
7729
|
+
opt.textContent = `${emp.name || emp.id}`;
|
|
7730
|
+
ownerEl.appendChild(opt);
|
|
7731
|
+
}
|
|
7732
|
+
ownerEl.value = product.owner_id || '';
|
|
7734
7733
|
ownerEl.addEventListener('change', () => {
|
|
7735
7734
|
fetch(`/api/product/${encodeURIComponent(slug)}`, {
|
|
7736
7735
|
method: 'PUT',
|
|
@@ -7783,6 +7782,21 @@ class AppController {
|
|
|
7783
7782
|
unitEl.textContent = unit;
|
|
7784
7783
|
this._makeKrFieldEditable(unitEl, slug, kr.id, 'unit');
|
|
7785
7784
|
|
|
7785
|
+
// Delete KR button
|
|
7786
|
+
const delKrBtn = document.createElement('button');
|
|
7787
|
+
delKrBtn.className = 'kr-remove-btn';
|
|
7788
|
+
delKrBtn.innerHTML = '×';
|
|
7789
|
+
delKrBtn.title = 'Delete KR';
|
|
7790
|
+
delKrBtn.addEventListener('click', async () => {
|
|
7791
|
+
if (!confirm(`Delete KR "${kr.title}"?`)) return;
|
|
7792
|
+
try {
|
|
7793
|
+
const r = await fetch(`/api/product/${encodeURIComponent(slug)}/kr/${encodeURIComponent(kr.id)}`, { method: 'DELETE' });
|
|
7794
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
7795
|
+
this._showToast('KR deleted', 'success');
|
|
7796
|
+
this._openProductDetail(slug);
|
|
7797
|
+
} catch (err) { this._showToast(`Failed: ${err.message}`, 'error'); }
|
|
7798
|
+
});
|
|
7799
|
+
|
|
7786
7800
|
krRow.appendChild(titleEl);
|
|
7787
7801
|
krRow.appendChild(document.createTextNode(': '));
|
|
7788
7802
|
krRow.appendChild(currentEl);
|
|
@@ -7791,6 +7805,7 @@ class AppController {
|
|
|
7791
7805
|
krRow.appendChild(unitEl);
|
|
7792
7806
|
krRow.appendChild(document.createTextNode(` (${pct.toFixed(0)}%)`));
|
|
7793
7807
|
krRow.appendChild(progTrack);
|
|
7808
|
+
krRow.appendChild(delKrBtn);
|
|
7794
7809
|
krList.appendChild(krRow);
|
|
7795
7810
|
}
|
|
7796
7811
|
|
|
@@ -7803,11 +7818,12 @@ class AppController {
|
|
|
7803
7818
|
container.appendChild(krList);
|
|
7804
7819
|
|
|
7805
7820
|
// Version History
|
|
7821
|
+
const verLabel = document.createElement('div');
|
|
7822
|
+
verLabel.className = 'product-section-label';
|
|
7823
|
+
verLabel.textContent = 'Version History';
|
|
7824
|
+
container.appendChild(verLabel);
|
|
7825
|
+
|
|
7806
7826
|
if (versions.length > 0) {
|
|
7807
|
-
const verLabel = document.createElement('div');
|
|
7808
|
-
verLabel.className = 'product-section-label';
|
|
7809
|
-
verLabel.textContent = 'Version History';
|
|
7810
|
-
container.appendChild(verLabel);
|
|
7811
7827
|
const verList = document.createElement('div');
|
|
7812
7828
|
verList.className = 'product-version-list';
|
|
7813
7829
|
for (const v of versions) {
|
|
@@ -7826,6 +7842,13 @@ class AppController {
|
|
|
7826
7842
|
}
|
|
7827
7843
|
container.appendChild(verList);
|
|
7828
7844
|
}
|
|
7845
|
+
|
|
7846
|
+
// Release Version button
|
|
7847
|
+
const releaseBtn = document.createElement('button');
|
|
7848
|
+
releaseBtn.className = 'btn-small';
|
|
7849
|
+
releaseBtn.textContent = '+ Release Version';
|
|
7850
|
+
releaseBtn.addEventListener('click', () => this._showReleaseVersionForm(container, slug));
|
|
7851
|
+
container.appendChild(releaseBtn);
|
|
7829
7852
|
}
|
|
7830
7853
|
|
|
7831
7854
|
_makeEditable(el, fieldName, slug) {
|
|
@@ -8152,10 +8175,40 @@ class AppController {
|
|
|
8152
8175
|
${labels ? `<div>Labels: ${labels}</div>` : ''}
|
|
8153
8176
|
${issue.created_by ? `<div>Created by: ${this._escHtml(issue.created_by)}</div>` : ''}
|
|
8154
8177
|
${issue.resolution ? `<div>Resolution: ${this._escHtml(issue.resolution)}</div>` : ''}
|
|
8155
|
-
${issue.sprint ? `<div>Sprint: ${this._escHtml(issue.sprint)}</div>` : ''}
|
|
8156
8178
|
`;
|
|
8157
8179
|
body.appendChild(metaEl);
|
|
8158
8180
|
|
|
8181
|
+
// Sprint picker (dropdown)
|
|
8182
|
+
const sprintRow = document.createElement('div');
|
|
8183
|
+
sprintRow.textContent = 'Sprint: ';
|
|
8184
|
+
const sprintSel = document.createElement('select');
|
|
8185
|
+
sprintSel.className = 'form-input';
|
|
8186
|
+
sprintSel.style.width = 'auto';
|
|
8187
|
+
sprintSel.style.display = 'inline';
|
|
8188
|
+
sprintSel.innerHTML = '<option value="">No Sprint</option>';
|
|
8189
|
+
fetch(`/api/product/${encodeURIComponent(slug)}/sprints`)
|
|
8190
|
+
.then(r => r.json())
|
|
8191
|
+
.then(sprints => {
|
|
8192
|
+
for (const s of sprints.filter(s => s.status !== 'closed')) {
|
|
8193
|
+
const opt = document.createElement('option');
|
|
8194
|
+
opt.value = s.id;
|
|
8195
|
+
opt.textContent = `${s.name}${s.status === 'active' ? ' (active)' : ''}`;
|
|
8196
|
+
sprintSel.appendChild(opt);
|
|
8197
|
+
}
|
|
8198
|
+
sprintSel.value = issue.sprint || '';
|
|
8199
|
+
})
|
|
8200
|
+
.catch(err => console.warn('Failed to load sprints:', err));
|
|
8201
|
+
sprintSel.addEventListener('change', () => {
|
|
8202
|
+
fetch(`/api/product/${encodeURIComponent(slug)}/issue/${encodeURIComponent(issue.id)}`, {
|
|
8203
|
+
method: 'PUT',
|
|
8204
|
+
headers: { 'Content-Type': 'application/json' },
|
|
8205
|
+
body: JSON.stringify({ sprint: sprintSel.value || '' }),
|
|
8206
|
+
}).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); })
|
|
8207
|
+
.catch(err => this._showToast(`Failed: ${err.message}`, 'error'));
|
|
8208
|
+
});
|
|
8209
|
+
sprintRow.appendChild(sprintSel);
|
|
8210
|
+
body.appendChild(sprintRow);
|
|
8211
|
+
|
|
8159
8212
|
const assignRow = document.createElement('div');
|
|
8160
8213
|
assignRow.textContent = 'Assignee: ';
|
|
8161
8214
|
const assignSel = document.createElement('select');
|
|
@@ -8163,15 +8216,13 @@ class AppController {
|
|
|
8163
8216
|
assignSel.style.width = 'auto';
|
|
8164
8217
|
assignSel.style.display = 'inline';
|
|
8165
8218
|
assignSel.innerHTML = '<option value="">Unassigned</option>';
|
|
8166
|
-
|
|
8167
|
-
|
|
8168
|
-
|
|
8169
|
-
|
|
8170
|
-
|
|
8171
|
-
|
|
8172
|
-
|
|
8173
|
-
assignSel.value = issue.assignee_id || '';
|
|
8174
|
-
});
|
|
8219
|
+
for (const emp of (this._cachedEmployees || [])) {
|
|
8220
|
+
const opt = document.createElement('option');
|
|
8221
|
+
opt.value = emp.id;
|
|
8222
|
+
opt.textContent = emp.name || emp.id;
|
|
8223
|
+
assignSel.appendChild(opt);
|
|
8224
|
+
}
|
|
8225
|
+
assignSel.value = issue.assignee_id || '';
|
|
8175
8226
|
assignSel.addEventListener('click', (e) => e.stopPropagation());
|
|
8176
8227
|
assignSel.addEventListener('change', () => {
|
|
8177
8228
|
fetch(`/api/product/${encodeURIComponent(slug)}/issue/${encodeURIComponent(issue.id)}`, {
|
|
@@ -8402,11 +8453,26 @@ class AppController {
|
|
|
8402
8453
|
<option value="P2" selected>P2</option><option value="P3">P3</option>
|
|
8403
8454
|
</select>
|
|
8404
8455
|
<input type="number" class="form-input issue-new-sp" placeholder="Story pts" style="width:60px" />
|
|
8405
|
-
<
|
|
8456
|
+
<select class="form-input issue-new-sprint" style="width:auto">
|
|
8457
|
+
<option value="">No Sprint</option>
|
|
8458
|
+
</select>
|
|
8406
8459
|
<button class="btn-small issue-new-save">Create</button>
|
|
8407
8460
|
<button class="kr-remove-btn issue-new-cancel">×</button>
|
|
8408
8461
|
</div>
|
|
8409
8462
|
`;
|
|
8463
|
+
// Populate sprint picker from available sprints
|
|
8464
|
+
const sprintSel = row.querySelector('.issue-new-sprint');
|
|
8465
|
+
fetch(`/api/product/${encodeURIComponent(slug)}/sprints`)
|
|
8466
|
+
.then(r => r.json())
|
|
8467
|
+
.then(sprints => {
|
|
8468
|
+
for (const s of sprints.filter(s => s.status !== 'closed')) {
|
|
8469
|
+
const opt = document.createElement('option');
|
|
8470
|
+
opt.value = s.id;
|
|
8471
|
+
opt.textContent = `${s.name}${s.status === 'active' ? ' (active)' : ''}`;
|
|
8472
|
+
sprintSel.appendChild(opt);
|
|
8473
|
+
}
|
|
8474
|
+
})
|
|
8475
|
+
.catch(err => console.warn('Failed to load sprints:', err));
|
|
8410
8476
|
row.querySelector('.issue-new-cancel').addEventListener('click', () => row.remove());
|
|
8411
8477
|
row.querySelector('.issue-new-save').addEventListener('click', async () => {
|
|
8412
8478
|
const title = row.querySelector('.issue-new-title').value.trim();
|
|
@@ -8607,6 +8673,11 @@ class AppController {
|
|
|
8607
8673
|
const actions = document.createElement('div');
|
|
8608
8674
|
actions.className = 'sprint-actions';
|
|
8609
8675
|
if (s.status === 'planning') {
|
|
8676
|
+
const editBtn = document.createElement('button');
|
|
8677
|
+
editBtn.className = 'sprint-action-btn';
|
|
8678
|
+
editBtn.textContent = 'Edit';
|
|
8679
|
+
editBtn.addEventListener('click', () => this._showEditSprintForm(bar, slug, s));
|
|
8680
|
+
actions.appendChild(editBtn);
|
|
8610
8681
|
const startBtn = document.createElement('button');
|
|
8611
8682
|
startBtn.className = 'sprint-action-btn';
|
|
8612
8683
|
startBtn.textContent = 'Start';
|
|
@@ -8758,10 +8829,26 @@ class AppController {
|
|
|
8758
8829
|
</div>
|
|
8759
8830
|
<div class="sprint-form-row">
|
|
8760
8831
|
<input type="number" class="form-input sprint-new-capacity" placeholder="Capacity (pts)" style="width:80px" />
|
|
8832
|
+
<span class="sprint-suggested-capacity" style="color:var(--text-dim);font-size:calc(5px + var(--font-boost));margin-left:4px"></span>
|
|
8761
8833
|
<button class="btn-small sprint-save-btn">Create</button>
|
|
8762
8834
|
<button class="kr-remove-btn sprint-cancel-btn">×</button>
|
|
8763
8835
|
</div>
|
|
8764
8836
|
`;
|
|
8837
|
+
// Show suggested capacity if available
|
|
8838
|
+
fetch(`/api/product/${encodeURIComponent(slug)}/sprint/suggest-capacity`)
|
|
8839
|
+
.then(r => r.json())
|
|
8840
|
+
.then(d => {
|
|
8841
|
+
if (d.suggested_capacity != null) {
|
|
8842
|
+
const hint = form.querySelector('.sprint-suggested-capacity');
|
|
8843
|
+
hint.textContent = `(suggested: ${d.suggested_capacity} pts)`;
|
|
8844
|
+
hint.style.cursor = 'pointer';
|
|
8845
|
+
hint.title = 'Click to use suggested capacity';
|
|
8846
|
+
hint.addEventListener('click', () => {
|
|
8847
|
+
form.querySelector('.sprint-new-capacity').value = d.suggested_capacity;
|
|
8848
|
+
});
|
|
8849
|
+
}
|
|
8850
|
+
})
|
|
8851
|
+
.catch(err => console.warn('Failed to load suggested capacity:', err));
|
|
8765
8852
|
// Default dates: today → +14 days
|
|
8766
8853
|
const today = new Date();
|
|
8767
8854
|
const end = new Date(today);
|
|
@@ -8796,6 +8883,51 @@ class AppController {
|
|
|
8796
8883
|
form.querySelector('.sprint-new-name').focus();
|
|
8797
8884
|
}
|
|
8798
8885
|
|
|
8886
|
+
_showEditSprintForm(barEl, slug, sprint) {
|
|
8887
|
+
if (barEl.querySelector('.sprint-inline-add')) return;
|
|
8888
|
+
const form = document.createElement('div');
|
|
8889
|
+
form.className = 'sprint-inline-add';
|
|
8890
|
+
form.innerHTML = `
|
|
8891
|
+
<input type="text" class="form-input sprint-edit-name" value="${this._escHtml(sprint.name)}" />
|
|
8892
|
+
<input type="text" class="form-input sprint-edit-goal" value="${this._escHtml(sprint.goal || '')}" placeholder="Goal" />
|
|
8893
|
+
<div class="sprint-form-row">
|
|
8894
|
+
<label style="color:var(--text-dim);font-size:calc(5px + var(--font-boost))">Start:</label>
|
|
8895
|
+
<input type="date" class="form-input sprint-edit-start" value="${sprint.start_date || ''}" style="width:auto" />
|
|
8896
|
+
<label style="color:var(--text-dim);font-size:calc(5px + var(--font-boost))">End:</label>
|
|
8897
|
+
<input type="date" class="form-input sprint-edit-end" value="${sprint.end_date || ''}" style="width:auto" />
|
|
8898
|
+
</div>
|
|
8899
|
+
<div class="sprint-form-row">
|
|
8900
|
+
<input type="number" class="form-input sprint-edit-capacity" value="${sprint.capacity || ''}" placeholder="Capacity" style="width:80px" />
|
|
8901
|
+
<button class="btn-small sprint-edit-save">Save</button>
|
|
8902
|
+
<button class="kr-remove-btn sprint-edit-cancel">×</button>
|
|
8903
|
+
</div>
|
|
8904
|
+
`;
|
|
8905
|
+
form.querySelector('.sprint-edit-cancel').addEventListener('click', () => form.remove());
|
|
8906
|
+
form.querySelector('.sprint-edit-save').addEventListener('click', async () => {
|
|
8907
|
+
const updates = {
|
|
8908
|
+
name: form.querySelector('.sprint-edit-name').value.trim(),
|
|
8909
|
+
goal: form.querySelector('.sprint-edit-goal').value.trim(),
|
|
8910
|
+
start_date: form.querySelector('.sprint-edit-start').value,
|
|
8911
|
+
end_date: form.querySelector('.sprint-edit-end').value,
|
|
8912
|
+
};
|
|
8913
|
+
const cap = parseInt(form.querySelector('.sprint-edit-capacity').value);
|
|
8914
|
+
if (!isNaN(cap)) updates.capacity = cap;
|
|
8915
|
+
if (!updates.name) { this._showToast('Sprint name is required', 'warning'); return; }
|
|
8916
|
+
try {
|
|
8917
|
+
const r = await fetch(`/api/product/${encodeURIComponent(slug)}/sprint/${encodeURIComponent(sprint.id)}`, {
|
|
8918
|
+
method: 'PUT',
|
|
8919
|
+
headers: { 'Content-Type': 'application/json' },
|
|
8920
|
+
body: JSON.stringify(updates),
|
|
8921
|
+
});
|
|
8922
|
+
if (!r.ok) { const err = await r.json(); throw new Error(err.detail || r.statusText); }
|
|
8923
|
+
this._showToast('Sprint updated', 'success');
|
|
8924
|
+
this._openProductDetail(slug);
|
|
8925
|
+
} catch (err) { this._showToast(`Update failed: ${err.message}`, 'error'); }
|
|
8926
|
+
});
|
|
8927
|
+
barEl.appendChild(form);
|
|
8928
|
+
form.querySelector('.sprint-edit-name').focus();
|
|
8929
|
+
}
|
|
8930
|
+
|
|
8799
8931
|
// ---------------------------------------------------------------------------
|
|
8800
8932
|
// Activity Feed Tab
|
|
8801
8933
|
// ---------------------------------------------------------------------------
|
|
@@ -8874,6 +9006,208 @@ class AppController {
|
|
|
8874
9006
|
.catch(err => { container.innerHTML = `<div class="error-text">Failed to load activity: ${err.message}</div>`; });
|
|
8875
9007
|
}
|
|
8876
9008
|
|
|
9009
|
+
// ---------------------------------------------------------------------------
|
|
9010
|
+
// Reviews Tab
|
|
9011
|
+
// ---------------------------------------------------------------------------
|
|
9012
|
+
|
|
9013
|
+
_renderProductReviews(reviews, slug, container) {
|
|
9014
|
+
container.innerHTML = '';
|
|
9015
|
+
|
|
9016
|
+
// Create Review button
|
|
9017
|
+
const toolbar = document.createElement('div');
|
|
9018
|
+
toolbar.className = 'issue-toolbar';
|
|
9019
|
+
const createBtn = document.createElement('button');
|
|
9020
|
+
createBtn.className = 'btn-small';
|
|
9021
|
+
createBtn.textContent = '+ Create Review';
|
|
9022
|
+
createBtn.addEventListener('click', async () => {
|
|
9023
|
+
try {
|
|
9024
|
+
const r = await fetch(`/api/product/${encodeURIComponent(slug)}/review`, {
|
|
9025
|
+
method: 'POST',
|
|
9026
|
+
headers: { 'Content-Type': 'application/json' },
|
|
9027
|
+
body: JSON.stringify({ trigger: 'manual', owner: '' }),
|
|
9028
|
+
});
|
|
9029
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
9030
|
+
this._showToast('Review created', 'success');
|
|
9031
|
+
this._openProductDetail(slug);
|
|
9032
|
+
} catch (err) { this._showToast(`Failed: ${err.message}`, 'error'); }
|
|
9033
|
+
});
|
|
9034
|
+
toolbar.appendChild(createBtn);
|
|
9035
|
+
container.appendChild(toolbar);
|
|
9036
|
+
|
|
9037
|
+
if (!reviews.length) {
|
|
9038
|
+
const emptyMsg = document.createElement('div');
|
|
9039
|
+
emptyMsg.className = 'task-empty';
|
|
9040
|
+
emptyMsg.textContent = 'No reviews yet.';
|
|
9041
|
+
container.appendChild(emptyMsg);
|
|
9042
|
+
return;
|
|
9043
|
+
}
|
|
9044
|
+
|
|
9045
|
+
// Group: open first, then completed
|
|
9046
|
+
const open = reviews.filter(r => r.status === 'open');
|
|
9047
|
+
const completed = reviews.filter(r => r.status === 'completed');
|
|
9048
|
+
|
|
9049
|
+
for (const group of [{ label: 'Open', items: open }, { label: 'Completed', items: completed }]) {
|
|
9050
|
+
if (!group.items.length) continue;
|
|
9051
|
+
const heading = document.createElement('div');
|
|
9052
|
+
heading.className = 'product-section-label';
|
|
9053
|
+
heading.textContent = `${group.label} (${group.items.length})`;
|
|
9054
|
+
container.appendChild(heading);
|
|
9055
|
+
|
|
9056
|
+
for (const rev of group.items) {
|
|
9057
|
+
const card = document.createElement('div');
|
|
9058
|
+
card.className = `review-card ${rev.status === 'completed' ? 'review-completed' : ''}`;
|
|
9059
|
+
|
|
9060
|
+
const header = document.createElement('div');
|
|
9061
|
+
header.className = 'review-card-header';
|
|
9062
|
+
const trigger = rev.trigger || 'manual';
|
|
9063
|
+
const dateStr = rev.created_at ? new Date(rev.created_at).toLocaleDateString() : '';
|
|
9064
|
+
let headerHtml = `<span class="review-trigger">${this._escHtml(trigger)}</span> <span class="review-date">${dateStr}</span>`;
|
|
9065
|
+
if (rev.owner) headerHtml += ` <span class="review-owner">Owner: ${this._escHtml(rev.owner)}</span>`;
|
|
9066
|
+
header.innerHTML = headerHtml;
|
|
9067
|
+
card.appendChild(header);
|
|
9068
|
+
|
|
9069
|
+
// Checklist items
|
|
9070
|
+
const itemsList = document.createElement('div');
|
|
9071
|
+
itemsList.className = 'review-items';
|
|
9072
|
+
for (const item of (rev.items || [])) {
|
|
9073
|
+
const row = document.createElement('div');
|
|
9074
|
+
row.className = 'review-item-row';
|
|
9075
|
+
const checkbox = document.createElement('input');
|
|
9076
|
+
checkbox.type = 'checkbox';
|
|
9077
|
+
checkbox.checked = !!item.checked;
|
|
9078
|
+
checkbox.disabled = rev.status === 'completed';
|
|
9079
|
+
checkbox.addEventListener('change', async () => {
|
|
9080
|
+
try {
|
|
9081
|
+
const r = await fetch(`/api/product/${encodeURIComponent(slug)}/review/${encodeURIComponent(rev.id)}/item/${encodeURIComponent(item.key)}`, {
|
|
9082
|
+
method: 'PUT',
|
|
9083
|
+
headers: { 'Content-Type': 'application/json' },
|
|
9084
|
+
body: JSON.stringify({ checked: checkbox.checked }),
|
|
9085
|
+
});
|
|
9086
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
9087
|
+
} catch (err) {
|
|
9088
|
+
checkbox.checked = !checkbox.checked;
|
|
9089
|
+
this._showToast(`Failed: ${err.message}`, 'error');
|
|
9090
|
+
}
|
|
9091
|
+
});
|
|
9092
|
+
const label = document.createElement('span');
|
|
9093
|
+
label.className = item.checked ? 'review-item-checked' : '';
|
|
9094
|
+
label.textContent = item.label || item.key;
|
|
9095
|
+
row.appendChild(checkbox);
|
|
9096
|
+
row.appendChild(label);
|
|
9097
|
+
itemsList.appendChild(row);
|
|
9098
|
+
}
|
|
9099
|
+
card.appendChild(itemsList);
|
|
9100
|
+
|
|
9101
|
+
// Complete button for open reviews
|
|
9102
|
+
if (rev.status === 'open') {
|
|
9103
|
+
const completeBtn = document.createElement('button');
|
|
9104
|
+
completeBtn.className = 'btn-small';
|
|
9105
|
+
completeBtn.textContent = 'Complete Review';
|
|
9106
|
+
completeBtn.addEventListener('click', async () => {
|
|
9107
|
+
try {
|
|
9108
|
+
const r = await fetch(`/api/product/${encodeURIComponent(slug)}/review/${encodeURIComponent(rev.id)}/complete`, { method: 'POST' });
|
|
9109
|
+
if (!r.ok) { const err = await r.json(); throw new Error(err.detail || r.statusText); }
|
|
9110
|
+
this._showToast('Review completed', 'success');
|
|
9111
|
+
this._openProductDetail(slug);
|
|
9112
|
+
} catch (err) { this._showToast(`Failed: ${err.message}`, 'error'); }
|
|
9113
|
+
});
|
|
9114
|
+
card.appendChild(completeBtn);
|
|
9115
|
+
}
|
|
9116
|
+
|
|
9117
|
+
container.appendChild(card);
|
|
9118
|
+
}
|
|
9119
|
+
}
|
|
9120
|
+
}
|
|
9121
|
+
|
|
9122
|
+
// ---------------------------------------------------------------------------
|
|
9123
|
+
// Release Version Form
|
|
9124
|
+
// ---------------------------------------------------------------------------
|
|
9125
|
+
|
|
9126
|
+
_showReleaseVersionForm(container, slug) {
|
|
9127
|
+
if (container.querySelector('.release-form')) return;
|
|
9128
|
+
const form = document.createElement('div');
|
|
9129
|
+
form.className = 'release-form sprint-inline-add';
|
|
9130
|
+
|
|
9131
|
+
// Show done issues that can be released
|
|
9132
|
+
fetch(`/api/product/${encodeURIComponent(slug)}/detail`)
|
|
9133
|
+
.then(r => r.json())
|
|
9134
|
+
.then(data => {
|
|
9135
|
+
const doneIssues = (data.issues || []).filter(i => i.status === 'done');
|
|
9136
|
+
if (!doneIssues.length) {
|
|
9137
|
+
form.innerHTML = '<div class="task-empty">No issues in DONE status to release.</div>';
|
|
9138
|
+
const closeBtn = document.createElement('button');
|
|
9139
|
+
closeBtn.className = 'kr-remove-btn';
|
|
9140
|
+
closeBtn.innerHTML = '×';
|
|
9141
|
+
closeBtn.addEventListener('click', () => form.remove());
|
|
9142
|
+
form.appendChild(closeBtn);
|
|
9143
|
+
return;
|
|
9144
|
+
}
|
|
9145
|
+
|
|
9146
|
+
const label = document.createElement('div');
|
|
9147
|
+
label.style.cssText = 'color:var(--text-dim);font-size:calc(5px + var(--font-boost));margin-bottom:4px';
|
|
9148
|
+
label.textContent = `Select issues to include in release (${doneIssues.length} done):`;
|
|
9149
|
+
form.appendChild(label);
|
|
9150
|
+
|
|
9151
|
+
const checkboxes = [];
|
|
9152
|
+
for (const issue of doneIssues) {
|
|
9153
|
+
const row = document.createElement('div');
|
|
9154
|
+
row.className = 'review-item-row';
|
|
9155
|
+
const cb = document.createElement('input');
|
|
9156
|
+
cb.type = 'checkbox';
|
|
9157
|
+
cb.checked = true;
|
|
9158
|
+
cb.dataset.issueId = issue.id;
|
|
9159
|
+
checkboxes.push(cb);
|
|
9160
|
+
const text = document.createElement('span');
|
|
9161
|
+
text.textContent = `[${issue.priority || 'P2'}] ${issue.title}`;
|
|
9162
|
+
row.appendChild(cb);
|
|
9163
|
+
row.appendChild(text);
|
|
9164
|
+
form.appendChild(row);
|
|
9165
|
+
}
|
|
9166
|
+
|
|
9167
|
+
const bumpRow = document.createElement('div');
|
|
9168
|
+
bumpRow.className = 'sprint-form-row';
|
|
9169
|
+
bumpRow.style.marginTop = '6px';
|
|
9170
|
+
const bumpLabel = document.createElement('label');
|
|
9171
|
+
bumpLabel.style.cssText = 'color:var(--text-dim);font-size:calc(5px + var(--font-boost))';
|
|
9172
|
+
bumpLabel.textContent = 'Bump:';
|
|
9173
|
+
const bumpSel = document.createElement('select');
|
|
9174
|
+
bumpSel.className = 'form-input';
|
|
9175
|
+
bumpSel.style.width = 'auto';
|
|
9176
|
+
bumpSel.innerHTML = '<option value="patch">Patch</option><option value="minor">Minor</option><option value="major">Major</option>';
|
|
9177
|
+
bumpRow.appendChild(bumpLabel);
|
|
9178
|
+
bumpRow.appendChild(bumpSel);
|
|
9179
|
+
|
|
9180
|
+
const releaseBtn = document.createElement('button');
|
|
9181
|
+
releaseBtn.className = 'btn-small';
|
|
9182
|
+
releaseBtn.textContent = 'Release';
|
|
9183
|
+
releaseBtn.addEventListener('click', async () => {
|
|
9184
|
+
const selectedIds = checkboxes.filter(cb => cb.checked).map(cb => cb.dataset.issueId);
|
|
9185
|
+
if (!selectedIds.length) { this._showToast('Select at least one issue', 'warning'); return; }
|
|
9186
|
+
try {
|
|
9187
|
+
const r = await fetch(`/api/product/${encodeURIComponent(slug)}/release`, {
|
|
9188
|
+
method: 'POST',
|
|
9189
|
+
headers: { 'Content-Type': 'application/json' },
|
|
9190
|
+
body: JSON.stringify({ resolved_issue_ids: selectedIds, bump: bumpSel.value }),
|
|
9191
|
+
});
|
|
9192
|
+
if (!r.ok) { const err = await r.json(); throw new Error(err.detail || r.statusText); }
|
|
9193
|
+
const result = await r.json();
|
|
9194
|
+
this._showToast(`Released v${result.version}`, 'success');
|
|
9195
|
+
this._openProductDetail(slug);
|
|
9196
|
+
} catch (err) { this._showToast(`Release failed: ${err.message}`, 'error'); }
|
|
9197
|
+
});
|
|
9198
|
+
bumpRow.appendChild(releaseBtn);
|
|
9199
|
+
|
|
9200
|
+
const cancelBtn = document.createElement('button');
|
|
9201
|
+
cancelBtn.className = 'kr-remove-btn';
|
|
9202
|
+
cancelBtn.innerHTML = '×';
|
|
9203
|
+
cancelBtn.addEventListener('click', () => form.remove());
|
|
9204
|
+
bumpRow.appendChild(cancelBtn);
|
|
9205
|
+
form.appendChild(bumpRow);
|
|
9206
|
+
});
|
|
9207
|
+
|
|
9208
|
+
container.appendChild(form);
|
|
9209
|
+
}
|
|
9210
|
+
|
|
8877
9211
|
_doUpdateProjectsPanel() {
|
|
8878
9212
|
const panel = document.getElementById('projects-panel-list');
|
|
8879
9213
|
if (!panel) return;
|