@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 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
- alert(data.detail || data.message || 'Failed to abort all tasks');
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
- alert('Failed to abort all tasks');
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
- alert(res.error);
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
- alert(`Cannot dismiss: ${data.error}`);
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
- alert('Failed to dismiss employee. See console for details.');
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
- alert(data.detail || data.message || 'Failed to stop cron');
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
- alert('Failed to stop cron job');
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
- alert(data.detail || data.message || 'Failed to stop all crons');
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
- alert('Failed to stop all cron jobs');
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
- alert(result.message);
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
- alert(data.message || 'OAuth login failed');
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) { alert('Both Client ID and Client Secret required'); return; }
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 alert(data.message || 'Failed');
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 alert(data.message || 'Failed');
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) { alert('Failed to load template'); return; }
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()) { alert('Template cannot be empty'); return; }
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 alert(data.message || 'Save failed');
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 alert(data.message || 'Delete failed');
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
- alert(`Failed to send message: ${err.message}`);
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
- alert(`Failed to clear history: ${err.message}`);
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
- async _populateProductOwnerDropdown() {
7400
- try {
7401
- const data = await fetch('/api/bootstrap').then(r => r.json());
7402
- const sel = document.getElementById('create-product-owner');
7403
- if (!sel) return;
7404
- sel.innerHTML = '<option value="">Select owner...</option>';
7405
- for (const emp of (data.employees || [])) {
7406
- const opt = document.createElement('option');
7407
- opt.value = emp.id;
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
- fetch('/api/bootstrap').then(r => r.json()).then(d => {
7726
- for (const emp of (d.employees || [])) {
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 || '';
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 = '&times;';
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
- fetch('/api/bootstrap').then(r => r.json()).then(d => {
8167
- for (const emp of (d.employees || [])) {
8168
- const opt = document.createElement('option');
8169
- opt.value = emp.id;
8170
- opt.textContent = emp.name || emp.id;
8171
- assignSel.appendChild(opt);
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
- <input type="text" class="form-input issue-new-sprint" placeholder="Sprint" style="width:80px" />
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">&times;</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">&times;</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">&times;</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 = '&times;';
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 = '&times;';
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;