@unbrained/pm-web 1.0.0
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/CHANGELOG.md +7 -0
- package/README.md +107 -0
- package/dist/auth.js +20 -0
- package/dist/auth.js.map +1 -0
- package/dist/crypto.js +42 -0
- package/dist/crypto.js.map +1 -0
- package/dist/db.js +111 -0
- package/dist/db.js.map +1 -0
- package/dist/index.js +88 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/auth.js +16 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/routes/admin.js +207 -0
- package/dist/routes/admin.js.map +1 -0
- package/dist/routes/auth.js +163 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/github.js +354 -0
- package/dist/routes/github.js.map +1 -0
- package/dist/routes/groups.js +180 -0
- package/dist/routes/groups.js.map +1 -0
- package/dist/routes/pm.js +2446 -0
- package/dist/routes/pm.js.map +1 -0
- package/dist/routes/projects.js +151 -0
- package/dist/routes/projects.js.map +1 -0
- package/dist/routes/sharing.js +155 -0
- package/dist/routes/sharing.js.map +1 -0
- package/dist/server.js +64 -0
- package/dist/server.js.map +1 -0
- package/dist/services/pm-runner.js +190 -0
- package/dist/services/pm-runner.js.map +1 -0
- package/dist/services/sse.js +111 -0
- package/dist/services/sse.js.map +1 -0
- package/manifest.json +15 -0
- package/package.json +111 -0
- package/public/icons/icon-192.png +0 -0
- package/public/icons/icon-512.png +0 -0
- package/public/index.html +265 -0
- package/public/manifest.json +66 -0
- package/public/src/api.js +28 -0
- package/public/src/api.js.map +1 -0
- package/public/src/api.ts +29 -0
- package/public/src/app.js +926 -0
- package/public/src/app.js.map +1 -0
- package/public/src/app.ts +929 -0
- package/public/src/components/modals.js +62 -0
- package/public/src/components/modals.js.map +1 -0
- package/public/src/components/modals.ts +73 -0
- package/public/src/components/toast.js +10 -0
- package/public/src/components/toast.js.map +1 -0
- package/public/src/components/toast.ts +13 -0
- package/public/src/constants.js +30 -0
- package/public/src/constants.js.map +1 -0
- package/public/src/constants.ts +41 -0
- package/public/src/state.js +15 -0
- package/public/src/state.js.map +1 -0
- package/public/src/state.ts +19 -0
- package/public/src/types.js +5 -0
- package/public/src/types.js.map +1 -0
- package/public/src/types.ts +253 -0
- package/public/src/utils.js +57 -0
- package/public/src/utils.js.map +1 -0
- package/public/src/utils.ts +56 -0
- package/public/src/views/activity.js +47 -0
- package/public/src/views/activity.js.map +1 -0
- package/public/src/views/activity.ts +41 -0
- package/public/src/views/admin.js +435 -0
- package/public/src/views/admin.js.map +1 -0
- package/public/src/views/admin.ts +504 -0
- package/public/src/views/auth.js +81 -0
- package/public/src/views/auth.js.map +1 -0
- package/public/src/views/auth.ts +74 -0
- package/public/src/views/calendar.js +133 -0
- package/public/src/views/calendar.js.map +1 -0
- package/public/src/views/calendar.ts +129 -0
- package/public/src/views/comments-audit.js +109 -0
- package/public/src/views/comments-audit.js.map +1 -0
- package/public/src/views/comments-audit.ts +108 -0
- package/public/src/views/config.js +322 -0
- package/public/src/views/config.js.map +1 -0
- package/public/src/views/config.ts +344 -0
- package/public/src/views/context.js +98 -0
- package/public/src/views/context.js.map +1 -0
- package/public/src/views/context.ts +100 -0
- package/public/src/views/create.js +293 -0
- package/public/src/views/create.js.map +1 -0
- package/public/src/views/create.ts +246 -0
- package/public/src/views/dedupe.js +51 -0
- package/public/src/views/dedupe.js.map +1 -0
- package/public/src/views/dedupe.ts +43 -0
- package/public/src/views/export.js +300 -0
- package/public/src/views/export.js.map +1 -0
- package/public/src/views/export.ts +274 -0
- package/public/src/views/github.js +360 -0
- package/public/src/views/github.js.map +1 -0
- package/public/src/views/github.ts +308 -0
- package/public/src/views/graph-canvas.js +1986 -0
- package/public/src/views/graph-canvas.js.map +1 -0
- package/public/src/views/graph-canvas.ts +2218 -0
- package/public/src/views/graph.js +1824 -0
- package/public/src/views/graph.js.map +1 -0
- package/public/src/views/graph.ts +1891 -0
- package/public/src/views/groups.js +186 -0
- package/public/src/views/groups.js.map +1 -0
- package/public/src/views/groups.ts +172 -0
- package/public/src/views/guide.js +151 -0
- package/public/src/views/guide.js.map +1 -0
- package/public/src/views/guide.ts +162 -0
- package/public/src/views/health.js +105 -0
- package/public/src/views/health.js.map +1 -0
- package/public/src/views/health.ts +102 -0
- package/public/src/views/items.js +1306 -0
- package/public/src/views/items.js.map +1 -0
- package/public/src/views/items.ts +1196 -0
- package/public/src/views/normalize.js +67 -0
- package/public/src/views/normalize.js.map +1 -0
- package/public/src/views/normalize.ts +58 -0
- package/public/src/views/plan.js +454 -0
- package/public/src/views/plan.js.map +1 -0
- package/public/src/views/plan.ts +496 -0
- package/public/src/views/projects.js +204 -0
- package/public/src/views/projects.js.map +1 -0
- package/public/src/views/projects.ts +196 -0
- package/public/src/views/router.js +227 -0
- package/public/src/views/router.js.map +1 -0
- package/public/src/views/router.ts +188 -0
- package/public/src/views/search.js +103 -0
- package/public/src/views/search.js.map +1 -0
- package/public/src/views/search.ts +94 -0
- package/public/src/views/settings.js +272 -0
- package/public/src/views/settings.js.map +1 -0
- package/public/src/views/settings.ts +190 -0
- package/public/src/views/shared.js +49 -0
- package/public/src/views/shared.js.map +1 -0
- package/public/src/views/shared.ts +49 -0
- package/public/src/views/sharing.js +152 -0
- package/public/src/views/sharing.js.map +1 -0
- package/public/src/views/sharing.ts +139 -0
- package/public/src/views/stats.js +92 -0
- package/public/src/views/stats.js.map +1 -0
- package/public/src/views/stats.ts +88 -0
- package/public/src/views/templates.js +117 -0
- package/public/src/views/templates.js.map +1 -0
- package/public/src/views/templates.ts +113 -0
- package/public/src/views/validate.js +54 -0
- package/public/src/views/validate.js.map +1 -0
- package/public/src/views/validate.ts +48 -0
- package/public/styles.css +2231 -0
- package/public/sw.js +318 -0
- package/public/tsconfig.json +20 -0
- package/sql/schema.sql +105 -0
|
@@ -0,0 +1,1196 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2
|
+
// ITEMS VIEW
|
|
3
|
+
// ═══════════════════════════════════════════════════════════════
|
|
4
|
+
import { state } from '../state.js';
|
|
5
|
+
import { api } from '../api.js';
|
|
6
|
+
import { escHtml, statusBadge, priorityDot, typeIcon } from '../utils.js';
|
|
7
|
+
import { showModal, hideModal, createModal, confirmDialog } from '../components/modals.js';
|
|
8
|
+
import { toast } from '../components/toast.js';
|
|
9
|
+
import { getTypes, getStatuses, TYPE_ICONS, PRIORITY_LABELS } from '../constants.js';
|
|
10
|
+
import { showView } from './router.js';
|
|
11
|
+
import { loadItemsBadge } from './projects.js';
|
|
12
|
+
import { renderLocalGraph, destroyLocalGraph } from './graph.js';
|
|
13
|
+
import type { Item } from '../types.js';
|
|
14
|
+
|
|
15
|
+
type RawDependency = {
|
|
16
|
+
targetId?: string;
|
|
17
|
+
id?: string;
|
|
18
|
+
rel?: string;
|
|
19
|
+
relationship?: string;
|
|
20
|
+
kind?: string;
|
|
21
|
+
type?: string;
|
|
22
|
+
target?: string;
|
|
23
|
+
targetTitle?: string;
|
|
24
|
+
title?: string;
|
|
25
|
+
[key: string]: unknown;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const DEP_REL_OPTIONS = [
|
|
29
|
+
{ value: 'blocked_by', label: 'Blocked by / depends on' },
|
|
30
|
+
{ value: 'blocks', label: 'Blocks' },
|
|
31
|
+
{ value: 'parent', label: 'Parent' },
|
|
32
|
+
{ value: 'child', label: 'Child' },
|
|
33
|
+
{ value: 'related', label: 'Related' },
|
|
34
|
+
] as const;
|
|
35
|
+
|
|
36
|
+
function normalizeDepRelation(raw?: string): string {
|
|
37
|
+
const aliases: Record<string, string> = {
|
|
38
|
+
blockedby: 'blocked_by',
|
|
39
|
+
blocked_by: 'blocked_by',
|
|
40
|
+
blockedbyid: 'blocked_by',
|
|
41
|
+
depends_on: 'blocked_by',
|
|
42
|
+
dependson: 'blocked_by',
|
|
43
|
+
dependency: 'blocked_by',
|
|
44
|
+
depends: 'blocked_by',
|
|
45
|
+
blocked: 'blocked_by',
|
|
46
|
+
parent_of: 'parent',
|
|
47
|
+
child_of: 'child',
|
|
48
|
+
relates_to: 'related',
|
|
49
|
+
related_to: 'related',
|
|
50
|
+
related: 'related',
|
|
51
|
+
blocks: 'blocks',
|
|
52
|
+
};
|
|
53
|
+
const normalized = (raw || '').trim().toLowerCase().replace(/-/g, '_');
|
|
54
|
+
return aliases[normalized] ?? normalized;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function renderDependencyOptions(selected?: string): string {
|
|
58
|
+
const current = normalizeDepRelation(selected);
|
|
59
|
+
return DEP_REL_OPTIONS
|
|
60
|
+
.map((option) => `<option value="${option.value}"${option.value === current ? ' selected' : ''}>${option.label}</option>`)
|
|
61
|
+
.join('');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function depLabel(rel: string): string {
|
|
65
|
+
const labels: Record<string, string> = {
|
|
66
|
+
blocked_by: 'Blocked by',
|
|
67
|
+
blocks: 'Blocks',
|
|
68
|
+
parent: 'Parent',
|
|
69
|
+
child: 'Child',
|
|
70
|
+
related: 'Related',
|
|
71
|
+
};
|
|
72
|
+
return labels[normalizeDepRelation(rel)] || rel;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function depTargetId(dep: RawDependency): string {
|
|
76
|
+
return String(dep.targetId || dep.id || dep.target || '').trim();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function depRelation(dep: RawDependency): string {
|
|
80
|
+
return normalizeDepRelation(String(dep.rel || dep.relationship || dep.type || dep.kind || 'blocked_by'));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ═══════════════════════════════════════════════════════════════
|
|
84
|
+
// BULK UPDATE
|
|
85
|
+
// ═══════════════════════════════════════════════════════════════
|
|
86
|
+
export function showBulkUpdateModal(): void {
|
|
87
|
+
if (!state.currentProject) { toast('Select a project first', 'info'); return; }
|
|
88
|
+
createModal('bulk-update-modal', 'Bulk Update Items', `
|
|
89
|
+
<div style="margin-bottom:16px">
|
|
90
|
+
<div style="font-size:11px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:10px">Filter Items</div>
|
|
91
|
+
<div class="two-col">
|
|
92
|
+
<div class="form-group">
|
|
93
|
+
<label class="form-label">Status</label>
|
|
94
|
+
<select class="form-select" id="bu-filter-status">
|
|
95
|
+
<option value="">Any status</option>
|
|
96
|
+
${getStatuses(state.schema).map(s=>`<option value="${s}">${s.replace('_',' ')}</option>`).join('')}
|
|
97
|
+
</select>
|
|
98
|
+
</div>
|
|
99
|
+
<div class="form-group">
|
|
100
|
+
<label class="form-label">Type</label>
|
|
101
|
+
<select class="form-select" id="bu-filter-type">
|
|
102
|
+
<option value="">Any type</option>
|
|
103
|
+
${getTypes(state.schema).map(t=>`<option value="${t}">${t}</option>`).join('')}
|
|
104
|
+
</select>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
<div class="two-col">
|
|
108
|
+
<div class="form-group">
|
|
109
|
+
<label class="form-label">Sprint</label>
|
|
110
|
+
<input class="form-input" id="bu-filter-sprint" type="text" placeholder="Filter by sprint…">
|
|
111
|
+
</div>
|
|
112
|
+
<div class="form-group">
|
|
113
|
+
<label class="form-label">Assignee</label>
|
|
114
|
+
<input class="form-input" id="bu-filter-assignee" type="text" placeholder="Filter by assignee…">
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
<hr class="section-divider">
|
|
119
|
+
<div style="font-size:11px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:10px">Fields to Update</div>
|
|
120
|
+
<div class="two-col">
|
|
121
|
+
<div class="form-group">
|
|
122
|
+
<label class="form-label">Set Priority</label>
|
|
123
|
+
<select class="form-select" id="bu-set-priority">
|
|
124
|
+
<option value="">— don't change —</option>
|
|
125
|
+
${[0,1,2,3,4].map(p=>`<option value="${p}">P${p}: ${PRIORITY_LABELS[p]}</option>`).join('')}
|
|
126
|
+
</select>
|
|
127
|
+
</div>
|
|
128
|
+
<div class="form-group">
|
|
129
|
+
<label class="form-label">Set Status</label>
|
|
130
|
+
<select class="form-select" id="bu-set-status">
|
|
131
|
+
<option value="">— don't change —</option>
|
|
132
|
+
${getStatuses(state.schema).map(s=>`<option value="${s}">${s.replace('_',' ')}</option>`).join('')}
|
|
133
|
+
</select>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
<div class="two-col">
|
|
137
|
+
<div class="form-group">
|
|
138
|
+
<label class="form-label">Set Sprint</label>
|
|
139
|
+
<input class="form-input" id="bu-set-sprint" type="text" placeholder="New sprint value…">
|
|
140
|
+
</div>
|
|
141
|
+
<div class="form-group">
|
|
142
|
+
<label class="form-label">Set Release</label>
|
|
143
|
+
<input class="form-input" id="bu-set-release" type="text" placeholder="New release value…">
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
<div class="form-group">
|
|
147
|
+
<label class="form-label">Set Assignee</label>
|
|
148
|
+
<input class="form-input" id="bu-set-assignee" type="text" placeholder="New assignee…">
|
|
149
|
+
</div>
|
|
150
|
+
<div id="bu-preview" style="margin-top:12px"></div>
|
|
151
|
+
<div style="display:flex;gap:8px;margin-top:12px">
|
|
152
|
+
<button class="btn btn-secondary" onclick="window.__app.previewBulkUpdate()">Preview</button>
|
|
153
|
+
<button class="btn btn-primary" id="bu-apply-btn" onclick="window.__app.applyBulkUpdate()" disabled>Apply Update</button>
|
|
154
|
+
</div>`, '');
|
|
155
|
+
showModal('bulk-update-modal');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function previewBulkUpdate(): Promise<void> {
|
|
159
|
+
const pid = state.currentProject?.id;
|
|
160
|
+
if (!pid) return;
|
|
161
|
+
const previewEl = document.getElementById('bu-preview');
|
|
162
|
+
if (previewEl) previewEl.innerHTML = '<div class="loading-state" style="padding:12px 0"><div class="loading-spinner"></div></div>';
|
|
163
|
+
|
|
164
|
+
const fStatus = (document.getElementById('bu-filter-status') as HTMLSelectElement | null)?.value || '';
|
|
165
|
+
const fType = (document.getElementById('bu-filter-type') as HTMLSelectElement | null)?.value || '';
|
|
166
|
+
const fSprint = (document.getElementById('bu-filter-sprint') as HTMLInputElement | null)?.value.trim() || '';
|
|
167
|
+
const fAssignee = (document.getElementById('bu-filter-assignee') as HTMLInputElement | null)?.value.trim() || '';
|
|
168
|
+
|
|
169
|
+
const uPriority = (document.getElementById('bu-set-priority') as HTMLSelectElement | null)?.value || '';
|
|
170
|
+
const uStatus = (document.getElementById('bu-set-status') as HTMLSelectElement | null)?.value || '';
|
|
171
|
+
const uSprint = (document.getElementById('bu-set-sprint') as HTMLInputElement | null)?.value.trim() || '';
|
|
172
|
+
const uRelease = (document.getElementById('bu-set-release') as HTMLInputElement | null)?.value.trim() || '';
|
|
173
|
+
const uAssignee = (document.getElementById('bu-set-assignee') as HTMLInputElement | null)?.value.trim() || '';
|
|
174
|
+
|
|
175
|
+
const hasUpdate = uPriority || uStatus || uSprint || uRelease || uAssignee;
|
|
176
|
+
if (!hasUpdate) {
|
|
177
|
+
if (previewEl) previewEl.innerHTML = '<div style="color:var(--status-open);font-size:13px">Select at least one field to update.</div>';
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Build payload matching the backend's flat field format
|
|
182
|
+
const payload: Record<string, string> = {};
|
|
183
|
+
if (fStatus) payload.filterStatus = fStatus;
|
|
184
|
+
if (fType) payload.filterType = fType;
|
|
185
|
+
if (fSprint) payload.filterSprint = fSprint;
|
|
186
|
+
if (fAssignee) payload.filterAssignee = fAssignee;
|
|
187
|
+
if (uPriority) payload.priority = uPriority;
|
|
188
|
+
if (uStatus) payload.status = uStatus;
|
|
189
|
+
if (uSprint) payload.sprint = uSprint;
|
|
190
|
+
if (uRelease) payload.release = uRelease;
|
|
191
|
+
if (uAssignee) payload.assignee = uAssignee;
|
|
192
|
+
payload.dryRun = 'true';
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const data = await api('POST', `/projects/${pid}/pm/update-many`, payload);
|
|
196
|
+
const matched: any[] = (data as any).item_plans || (data as any).items || (data as any).matched || [];
|
|
197
|
+
const count = (data as any).matched_count ?? (data as any).count ?? (data as any).total ?? matched.length;
|
|
198
|
+
const applyBtn = document.getElementById('bu-apply-btn') as HTMLButtonElement | null;
|
|
199
|
+
if (previewEl) {
|
|
200
|
+
const updateParts: string[] = [];
|
|
201
|
+
if (uPriority) updateParts.push(`priority → P${uPriority}`);
|
|
202
|
+
if (uStatus) updateParts.push(`status → ${uStatus}`);
|
|
203
|
+
if (uSprint) updateParts.push(`sprint → ${uSprint}`);
|
|
204
|
+
if (uRelease) updateParts.push(`release → ${uRelease}`);
|
|
205
|
+
if (uAssignee) updateParts.push(`assignee → ${uAssignee}`);
|
|
206
|
+
const updateDesc = updateParts.map(p=>`<strong>${escHtml(p)}</strong>`).join(', ');
|
|
207
|
+
|
|
208
|
+
if (count === 0 && matched.length === 0) {
|
|
209
|
+
previewEl.innerHTML = `<div style="color:var(--text-muted);font-size:13px;padding:10px 0">No items match the filter criteria.</div>`;
|
|
210
|
+
if (applyBtn) applyBtn.disabled = true;
|
|
211
|
+
} else {
|
|
212
|
+
const displayCount = count || matched.length;
|
|
213
|
+
previewEl.innerHTML = `
|
|
214
|
+
<div style="background:rgba(99,102,241,0.08);border:1px solid rgba(99,102,241,0.25);border-radius:var(--radius);padding:10px 14px;font-size:13px">
|
|
215
|
+
<div style="margin-bottom:8px">Will update <strong>${displayCount}</strong> item${displayCount!==1?'s':''}: ${updateDesc}</div>
|
|
216
|
+
${matched.slice(0,8).map((it: any)=>`<div style="color:var(--text-secondary);font-size:12px">· ${escHtml(it.id||'')} ${escHtml(it.title||'')}</div>`).join('')}
|
|
217
|
+
${displayCount > 8 ? `<div style="color:var(--text-muted);font-size:12px;margin-top:4px">… and ${displayCount - 8} more</div>` : ''}
|
|
218
|
+
</div>`;
|
|
219
|
+
if (applyBtn) applyBtn.disabled = false;
|
|
220
|
+
// Store payload for apply (without dryRun)
|
|
221
|
+
const applyPayload = { ...payload };
|
|
222
|
+
delete applyPayload.dryRun;
|
|
223
|
+
(applyBtn as any)._bulkPayload = applyPayload;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
} catch(err: unknown) {
|
|
227
|
+
if (previewEl) previewEl.innerHTML = `<div style="color:var(--status-open);font-size:13px">Error: ${escHtml(err instanceof Error ? err.message : String(err))}</div>`;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export async function applyBulkUpdate(): Promise<void> {
|
|
232
|
+
const pid = state.currentProject?.id;
|
|
233
|
+
if (!pid) return;
|
|
234
|
+
const applyBtn = document.getElementById('bu-apply-btn') as HTMLButtonElement | null;
|
|
235
|
+
const payload = (applyBtn as any)?._bulkPayload;
|
|
236
|
+
if (!payload) { toast('Run Preview first', 'info'); return; }
|
|
237
|
+
if (applyBtn) { applyBtn.disabled = true; applyBtn.textContent = 'Applying…'; }
|
|
238
|
+
try {
|
|
239
|
+
const data = await api('POST', `/projects/${pid}/pm/update-many`, payload);
|
|
240
|
+
const updated = (data as any).updated_count ?? (data as any).updated ?? (data as any).count ?? (data as any).total ?? 'some';
|
|
241
|
+
const failed = (data as any).failed_count ?? 0;
|
|
242
|
+
if (failed > 0) {
|
|
243
|
+
toast(`Updated ${updated} item${updated!==1?'s':''} (${failed} failed)`, 'info');
|
|
244
|
+
} else {
|
|
245
|
+
toast(`Updated ${updated} item${updated!==1?'s':''}`, 'success');
|
|
246
|
+
}
|
|
247
|
+
hideModal('bulk-update-modal');
|
|
248
|
+
if (state.currentView === 'items') fetchAndRenderItems();
|
|
249
|
+
loadItemsBadge();
|
|
250
|
+
} catch(err: unknown) {
|
|
251
|
+
toast(err instanceof Error ? err.message : String(err), 'error');
|
|
252
|
+
if (applyBtn) { applyBtn.disabled = false; applyBtn.textContent = 'Apply Update'; }
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ═══════════════════════════════════════════════════════════════
|
|
257
|
+
// BULK CLOSE
|
|
258
|
+
// ═══════════════════════════════════════════════════════════════
|
|
259
|
+
export function showBulkCloseModal(): void {
|
|
260
|
+
if (!state.currentProject) { toast('Select a project first', 'info'); return; }
|
|
261
|
+
createModal('bulk-close-modal', 'Bulk Close Items', `
|
|
262
|
+
<div style="margin-bottom:16px">
|
|
263
|
+
<div style="font-size:11px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:10px">Filter Items to Close</div>
|
|
264
|
+
<div class="two-col">
|
|
265
|
+
<div class="form-group">
|
|
266
|
+
<label class="form-label">Current Status</label>
|
|
267
|
+
<select class="form-select" id="bc-filter-status">
|
|
268
|
+
<option value="">Any active status</option>
|
|
269
|
+
${getStatuses(state.schema).filter(s => s !== 'closed' && s !== 'canceled').map(s=>`<option value="${s}">${s.replace('_',' ')}</option>`).join('')}
|
|
270
|
+
</select>
|
|
271
|
+
</div>
|
|
272
|
+
<div class="form-group">
|
|
273
|
+
<label class="form-label">Type</label>
|
|
274
|
+
<select class="form-select" id="bc-filter-type">
|
|
275
|
+
<option value="">Any type</option>
|
|
276
|
+
${getTypes(state.schema).map(t=>`<option value="${t}">${t}</option>`).join('')}
|
|
277
|
+
</select>
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
<div class="two-col">
|
|
281
|
+
<div class="form-group">
|
|
282
|
+
<label class="form-label">Sprint</label>
|
|
283
|
+
<input class="form-input" id="bc-filter-sprint" type="text" placeholder="Filter by sprint…">
|
|
284
|
+
</div>
|
|
285
|
+
<div class="form-group">
|
|
286
|
+
<label class="form-label">Assignee</label>
|
|
287
|
+
<input class="form-input" id="bc-filter-assignee" type="text" placeholder="Filter by assignee…">
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
<hr class="section-divider">
|
|
292
|
+
<div style="font-size:11px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:10px">Close Action</div>
|
|
293
|
+
<div class="two-col">
|
|
294
|
+
<div class="form-group">
|
|
295
|
+
<label class="form-label">Target Status</label>
|
|
296
|
+
<select class="form-select" id="bc-target-status">
|
|
297
|
+
<option value="closed">closed</option>
|
|
298
|
+
<option value="canceled">canceled</option>
|
|
299
|
+
</select>
|
|
300
|
+
</div>
|
|
301
|
+
<div class="form-group">
|
|
302
|
+
<label class="form-label">Reason *</label>
|
|
303
|
+
<input class="form-input" id="bc-reason" type="text" placeholder="Why are these items being closed?">
|
|
304
|
+
</div>
|
|
305
|
+
</div>
|
|
306
|
+
<div id="bc-preview" style="margin-top:12px"></div>
|
|
307
|
+
<div style="display:flex;gap:8px;margin-top:12px">
|
|
308
|
+
<button class="btn btn-secondary" onclick="window.__app.previewBulkClose()">Preview</button>
|
|
309
|
+
<button class="btn btn-danger" id="bc-apply-btn" onclick="window.__app.applyBulkClose()" disabled>Close Items</button>
|
|
310
|
+
</div>`, '');
|
|
311
|
+
showModal('bulk-close-modal');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export async function previewBulkClose(): Promise<void> {
|
|
315
|
+
const pid = state.currentProject?.id;
|
|
316
|
+
if (!pid) return;
|
|
317
|
+
const previewEl = document.getElementById('bc-preview');
|
|
318
|
+
if (previewEl) previewEl.innerHTML = '<div class="loading-state" style="padding:12px 0"><div class="loading-spinner"></div></div>';
|
|
319
|
+
|
|
320
|
+
const fStatus = (document.getElementById('bc-filter-status') as HTMLSelectElement | null)?.value || '';
|
|
321
|
+
const fType = (document.getElementById('bc-filter-type') as HTMLSelectElement | null)?.value || '';
|
|
322
|
+
const fSprint = (document.getElementById('bc-filter-sprint') as HTMLInputElement | null)?.value.trim() || '';
|
|
323
|
+
const fAssignee = (document.getElementById('bc-filter-assignee') as HTMLInputElement | null)?.value.trim() || '';
|
|
324
|
+
const targetStatus = (document.getElementById('bc-target-status') as HTMLSelectElement | null)?.value || 'closed';
|
|
325
|
+
const reason = (document.getElementById('bc-reason') as HTMLInputElement | null)?.value.trim() || '';
|
|
326
|
+
|
|
327
|
+
if (!reason) {
|
|
328
|
+
if (previewEl) previewEl.innerHTML = '<div style="color:var(--status-blocked);font-size:13px">A close reason is required.</div>';
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const payload: Record<string, string> = { reason: reason, targetStatus };
|
|
333
|
+
if (fStatus) payload.filterStatus = fStatus;
|
|
334
|
+
if (fType) payload.filterType = fType;
|
|
335
|
+
if (fSprint) payload.filterSprint = fSprint;
|
|
336
|
+
if (fAssignee) payload.filterAssignee = fAssignee;
|
|
337
|
+
|
|
338
|
+
try {
|
|
339
|
+
// Use update-many dry-run to preview which items will be closed/canceled.
|
|
340
|
+
// Dry-run accepts status=closed for preview purposes; actual close uses close-many.
|
|
341
|
+
const previewPayload: Record<string, string> = { status: targetStatus, dryRun: 'true' };
|
|
342
|
+
if (fStatus) previewPayload.filterStatus = fStatus;
|
|
343
|
+
if (fType) previewPayload.filterType = fType;
|
|
344
|
+
if (fSprint) previewPayload.filterSprint = fSprint;
|
|
345
|
+
if (fAssignee) previewPayload.filterAssignee = fAssignee;
|
|
346
|
+
const data = await api('POST', `/projects/${pid}/pm/update-many`, previewPayload);
|
|
347
|
+
const matched: any[] = (data as any).item_plans || (data as any).items || (data as any).matched || [];
|
|
348
|
+
const count = (data as any).matched_count ?? (data as any).count ?? (data as any).total ?? matched.length;
|
|
349
|
+
const applyBtn = document.getElementById('bc-apply-btn') as HTMLButtonElement | null;
|
|
350
|
+
if (previewEl) {
|
|
351
|
+
if (count === 0 && matched.length === 0) {
|
|
352
|
+
previewEl.innerHTML = `<div style="color:var(--text-muted);font-size:13px;padding:10px 0">No items match the filter criteria.</div>`;
|
|
353
|
+
if (applyBtn) applyBtn.disabled = true;
|
|
354
|
+
} else {
|
|
355
|
+
const displayCount = count || matched.length;
|
|
356
|
+
previewEl.innerHTML = `
|
|
357
|
+
<div style="background:rgba(248,113,113,0.08);border:1px solid rgba(248,113,113,0.25);border-radius:var(--radius);padding:10px 14px;font-size:13px">
|
|
358
|
+
<div style="margin-bottom:8px">Will <strong>${targetStatus}</strong> <strong>${displayCount}</strong> item${displayCount!==1?'s':''}. Reason: <em>${escHtml(reason)}</em></div>
|
|
359
|
+
${matched.slice(0,8).map((it: any)=>`<div style="color:var(--text-secondary);font-size:12px">· ${escHtml(it.id||'')} ${escHtml(it.title||'')}</div>`).join('')}
|
|
360
|
+
${displayCount > 8 ? `<div style="color:var(--text-muted);font-size:12px;margin-top:4px">… and ${displayCount - 8} more</div>` : ''}
|
|
361
|
+
</div>`;
|
|
362
|
+
if (applyBtn) {
|
|
363
|
+
applyBtn.disabled = false;
|
|
364
|
+
(applyBtn as any)._bulkClosePayload = { fStatus, fType, fSprint, fAssignee, targetStatus, reason };
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
} catch(err: unknown) {
|
|
369
|
+
if (previewEl) previewEl.innerHTML = `<div style="color:var(--status-blocked);font-size:13px">Error: ${escHtml(err instanceof Error ? err.message : String(err))}</div>`;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export async function applyBulkClose(): Promise<void> {
|
|
374
|
+
const pid = state.currentProject?.id;
|
|
375
|
+
if (!pid) return;
|
|
376
|
+
const applyBtn = document.getElementById('bc-apply-btn') as HTMLButtonElement | null;
|
|
377
|
+
const bulkClosePayload = (applyBtn as any)?._bulkClosePayload as Record<string, string> | undefined;
|
|
378
|
+
if (!bulkClosePayload) { toast('Run Preview first', 'info'); return; }
|
|
379
|
+
if (applyBtn) { applyBtn.disabled = true; applyBtn.textContent = 'Closing…'; }
|
|
380
|
+
|
|
381
|
+
// Use the close-many endpoint which calls pm close <id> <reason> for each matched item.
|
|
382
|
+
// update-many --status closed is rejected by pm CLI; close-many handles it correctly.
|
|
383
|
+
const payload: Record<string, string> = {
|
|
384
|
+
reason: bulkClosePayload.reason,
|
|
385
|
+
targetStatus: bulkClosePayload.targetStatus,
|
|
386
|
+
};
|
|
387
|
+
if (bulkClosePayload.fStatus) payload.filterStatus = bulkClosePayload.fStatus;
|
|
388
|
+
if (bulkClosePayload.fType) payload.filterType = bulkClosePayload.fType;
|
|
389
|
+
if (bulkClosePayload.fSprint) payload.filterSprint = bulkClosePayload.fSprint;
|
|
390
|
+
if (bulkClosePayload.fAssignee) payload.filterAssignee = bulkClosePayload.fAssignee;
|
|
391
|
+
|
|
392
|
+
try {
|
|
393
|
+
const data = await api('POST', `/projects/${pid}/pm/close-many`, payload);
|
|
394
|
+
const closed = (data as any).closed_count ?? 'some';
|
|
395
|
+
const failed = (data as any).failed_count ?? 0;
|
|
396
|
+
if (failed > 0) {
|
|
397
|
+
toast(`Closed ${closed} item${closed!==1?'s':''} (${failed} failed)`, 'info');
|
|
398
|
+
} else {
|
|
399
|
+
toast(`Closed ${closed} item${closed!==1?'s':''}`, 'success');
|
|
400
|
+
}
|
|
401
|
+
hideModal('bulk-close-modal');
|
|
402
|
+
if (state.currentView === 'items') fetchAndRenderItems();
|
|
403
|
+
loadItemsBadge();
|
|
404
|
+
} catch(err: unknown) {
|
|
405
|
+
toast(err instanceof Error ? err.message : String(err), 'error');
|
|
406
|
+
if (applyBtn) { applyBtn.disabled = false; applyBtn.textContent = 'Close Items'; }
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export async function renderItemsView(): Promise<void> {
|
|
411
|
+
const el = document.getElementById('content-items');
|
|
412
|
+
if (!el) return;
|
|
413
|
+
if (!state.currentProject) {
|
|
414
|
+
el.innerHTML = '<div class="empty-state"><div class="empty-state-text">No project selected</div></div>';
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
el.innerHTML = `
|
|
419
|
+
<div class="page-header">
|
|
420
|
+
<div>
|
|
421
|
+
<div class="page-title">Items</div>
|
|
422
|
+
<div class="page-subtitle" id="items-subtitle">Loading…</div>
|
|
423
|
+
</div>
|
|
424
|
+
<div class="page-actions">
|
|
425
|
+
<button class="btn btn-secondary btn-sm" onclick="window.__app.renderItemsView()" title="Refresh">↺ Refresh</button>
|
|
426
|
+
<button class="btn btn-ghost btn-sm" onclick="window.__app.showBulkUpdateModal()">⊞ Bulk Update</button>
|
|
427
|
+
<button class="btn btn-ghost btn-sm" onclick="window.__app.showBulkCloseModal()" title="Close or cancel many items at once">⊘ Bulk Close</button>
|
|
428
|
+
<button class="btn btn-primary" onclick="window.__app.showView('create')">+ New Item</button>
|
|
429
|
+
</div>
|
|
430
|
+
</div>
|
|
431
|
+
<div class="status-tabs" style="display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap;">
|
|
432
|
+
${['','open','in_progress','blocked','draft','closed','canceled'].map(s=>`<button class="btn btn-sm ${state.itemFilters.status===s?'btn-primary':'btn-ghost'}" onclick="window.__app.setStatusFilter('${s}')">${s===''?'All':s.replace('_',' ')}</button>`).join('')}
|
|
433
|
+
</div>
|
|
434
|
+
<div class="filter-bar">
|
|
435
|
+
<select class="filter-select" id="filter-status" onchange="window.__app.applyItemFilters()">
|
|
436
|
+
<option value="">All Statuses</option>
|
|
437
|
+
${getStatuses(state.schema).map(s=>`<option value="${s}"${state.itemFilters.status===s?' selected':''}>${s.replace('_',' ')}</option>`).join('')}
|
|
438
|
+
</select>
|
|
439
|
+
<select class="filter-select" id="filter-type" onchange="window.__app.applyItemFilters()">
|
|
440
|
+
<option value="">All Types</option>
|
|
441
|
+
${getTypes(state.schema).map(t=>`<option value="${t}"${state.itemFilters.type===t?' selected':''}>${t}</option>`).join('')}
|
|
442
|
+
</select>
|
|
443
|
+
<select class="filter-select" id="filter-priority" onchange="window.__app.applyItemFilters()">
|
|
444
|
+
<option value="">All Priorities</option>
|
|
445
|
+
${[0,1,2,3,4].map(p=>`<option value="${p}"${state.itemFilters.priority==String(p)?' selected':''}>P${p}: ${PRIORITY_LABELS[p]}</option>`).join('')}
|
|
446
|
+
</select>
|
|
447
|
+
<input class="filter-select" id="filter-sprint" type="text" placeholder="Sprint…" value="${escHtml(state.itemFilters.sprint)}" oninput="window.__app.applyItemFilters()" style="width:100px">
|
|
448
|
+
<input class="filter-select" id="filter-release" type="text" placeholder="Release…" value="${escHtml(state.itemFilters.release)}" oninput="window.__app.applyItemFilters()" style="width:100px">
|
|
449
|
+
<input class="filter-select" id="filter-assignee" type="text" placeholder="Assignee…" value="${escHtml(state.itemFilters.assignee)}" oninput="window.__app.applyItemFilters()" style="width:110px">
|
|
450
|
+
<button class="btn btn-ghost btn-sm" onclick="window.__app.clearFilters()">Clear</button>
|
|
451
|
+
</div>
|
|
452
|
+
<div id="items-list"><div class="loading-state"><div class="loading-spinner"></div></div></div>`;
|
|
453
|
+
|
|
454
|
+
await fetchAndRenderItems();
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export async function fetchAndRenderItems(): Promise<void> {
|
|
458
|
+
const pid = state.currentProject?.id;
|
|
459
|
+
if (!pid) return;
|
|
460
|
+
const f = state.itemFilters;
|
|
461
|
+
let params = 'limit=200';
|
|
462
|
+
if (f.status) params += `&status=${encodeURIComponent(f.status)}`;
|
|
463
|
+
if (f.type) params += `&type=${encodeURIComponent(f.type)}`;
|
|
464
|
+
if (f.priority) params += `&priority=${encodeURIComponent(f.priority)}`;
|
|
465
|
+
if (f.sprint) params += `&sprint=${encodeURIComponent(f.sprint)}`;
|
|
466
|
+
if (f.release) params += `&release=${encodeURIComponent(f.release)}`;
|
|
467
|
+
if (f.assignee) params += `&assignee=${encodeURIComponent(f.assignee)}`;
|
|
468
|
+
|
|
469
|
+
const endpoint = f.status ? `list?${params}` : `list-all?${params}`;
|
|
470
|
+
|
|
471
|
+
try {
|
|
472
|
+
const data = await api('GET',`/projects/${pid}/pm/${endpoint}`);
|
|
473
|
+
state.items = data.items || [];
|
|
474
|
+
const sub = document.getElementById('items-subtitle');
|
|
475
|
+
if (sub) sub.textContent = `${state.items.length} item${state.items.length!==1?'s':''}`;
|
|
476
|
+
renderItemsList();
|
|
477
|
+
} catch(err: unknown) {
|
|
478
|
+
const listEl = document.getElementById('items-list');
|
|
479
|
+
if (listEl) listEl.innerHTML = `<div class="empty-state"><div class="empty-state-text">Failed to load items: ${escHtml(err instanceof Error ? err.message : String(err))}</div></div>`;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function renderItemsList(): void {
|
|
484
|
+
const el = document.getElementById('items-list');
|
|
485
|
+
if (!el) return;
|
|
486
|
+
if (state.items.length === 0) {
|
|
487
|
+
el.innerHTML = `<div class="empty-state">
|
|
488
|
+
<div class="empty-state-icon">📋</div>
|
|
489
|
+
<div class="empty-state-text">No items found</div>
|
|
490
|
+
<div class="empty-state-sub">Try adjusting filters or <a href="#" onclick="window.__app.showView('create');return false" style="color:var(--accent)">create a new item</a></div>
|
|
491
|
+
</div>`;
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
el.innerHTML = `<div class="item-list">${state.items.map(item => renderItemRow(item)).join('')}</div>`;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export function renderItemRow(item: Item): string {
|
|
498
|
+
const tags = (item.tags||[]).map(t=>`<span class="tag">${escHtml(t)}</span>`).join('');
|
|
499
|
+
return `<div class="item-row" onclick="window.__app.openItemDetail('${escHtml(item.id)}')">
|
|
500
|
+
${typeIcon(item.type||'')}
|
|
501
|
+
<span class="item-id">${escHtml(item.id)}</span>
|
|
502
|
+
<span class="item-title">${escHtml(item.title)}</span>
|
|
503
|
+
<div class="item-meta">
|
|
504
|
+
${tags ? `<div class="item-tags">${tags}</div>` : ''}
|
|
505
|
+
${priorityDot(item.priority??4)}
|
|
506
|
+
${statusBadge(item.status||'draft')}
|
|
507
|
+
</div>
|
|
508
|
+
</div>`;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export function applyItemFilters(): void {
|
|
512
|
+
const fs = document.getElementById('filter-status') as HTMLSelectElement | null;
|
|
513
|
+
const ft = document.getElementById('filter-type') as HTMLSelectElement | null;
|
|
514
|
+
const fp = document.getElementById('filter-priority') as HTMLSelectElement | null;
|
|
515
|
+
const fsp = document.getElementById('filter-sprint') as HTMLInputElement | null;
|
|
516
|
+
const frl = document.getElementById('filter-release') as HTMLInputElement | null;
|
|
517
|
+
const fas = document.getElementById('filter-assignee') as HTMLInputElement | null;
|
|
518
|
+
state.itemFilters.status = fs?.value || '';
|
|
519
|
+
state.itemFilters.type = ft?.value || '';
|
|
520
|
+
state.itemFilters.priority = fp?.value || '';
|
|
521
|
+
state.itemFilters.sprint = fsp?.value || '';
|
|
522
|
+
state.itemFilters.release = frl?.value || '';
|
|
523
|
+
state.itemFilters.assignee = fas?.value || '';
|
|
524
|
+
fetchAndRenderItems();
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export function clearFilters(): void {
|
|
528
|
+
state.itemFilters = { status:'', type:'', priority:'', sprint:'', release:'', assignee:'' };
|
|
529
|
+
const ids = ['filter-status','filter-type','filter-priority','filter-sprint','filter-release','filter-assignee'];
|
|
530
|
+
ids.forEach(id => {
|
|
531
|
+
const el = document.getElementById(id) as (HTMLInputElement | HTMLSelectElement) | null;
|
|
532
|
+
if (el) el.value = '';
|
|
533
|
+
});
|
|
534
|
+
fetchAndRenderItems();
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export function setStatusFilter(status: string): void {
|
|
538
|
+
state.itemFilters.status = status;
|
|
539
|
+
renderItemsView();
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ═══════════════════════════════════════════════════════════════
|
|
543
|
+
// ITEM DETAIL MODAL
|
|
544
|
+
// ═══════════════════════════════════════════════════════════════
|
|
545
|
+
export async function openItemDetail(itemId: string): Promise<void> {
|
|
546
|
+
const pid = state.currentProject?.id;
|
|
547
|
+
if (!pid) return;
|
|
548
|
+
|
|
549
|
+
createModal('item-detail-modal', 'Loading…', '<div class="loading-state"><div class="loading-spinner"></div></div>', '', true);
|
|
550
|
+
showModal('item-detail-modal');
|
|
551
|
+
|
|
552
|
+
try {
|
|
553
|
+
const [itemData, commentsData, historyData, depsData, learningsData, notesData, testsData, filesData] = await Promise.all([
|
|
554
|
+
api('GET',`/projects/${pid}/pm/get/${itemId}`),
|
|
555
|
+
api('GET',`/projects/${pid}/pm/comments/${itemId}`).catch(()=>({comments:[]})),
|
|
556
|
+
api('GET',`/projects/${pid}/pm/history/${itemId}`).catch(()=>({history:[]})),
|
|
557
|
+
api('GET',`/projects/${pid}/pm/deps/${itemId}`).catch(()=>({deps:[]})),
|
|
558
|
+
api('GET',`/projects/${pid}/pm/learnings/${itemId}`).catch(()=>({learnings:[]})),
|
|
559
|
+
api('GET',`/projects/${pid}/pm/notes/${itemId}`).catch(()=>({notes:[]})),
|
|
560
|
+
api('GET',`/projects/${pid}/pm/tests/${itemId}`).catch(()=>({tests:[]})),
|
|
561
|
+
api('GET',`/projects/${pid}/pm/files/${itemId}`).catch(()=>({files:[]})),
|
|
562
|
+
]);
|
|
563
|
+
|
|
564
|
+
const item = itemData.item || itemData;
|
|
565
|
+
const comments = commentsData.comments || [];
|
|
566
|
+
const history = historyData.history || [];
|
|
567
|
+
const deps = depsData.deps || depsData.dependencies || [];
|
|
568
|
+
const learnings = learningsData.learnings || [];
|
|
569
|
+
const notes = notesData.notes || [];
|
|
570
|
+
const tests = testsData.tests || [];
|
|
571
|
+
const files = filesData.files || [];
|
|
572
|
+
|
|
573
|
+
const modal = document.getElementById('item-detail-modal');
|
|
574
|
+
if (modal) {
|
|
575
|
+
const titleEl = modal.querySelector('.modal-title');
|
|
576
|
+
if (titleEl) titleEl.textContent = item.id;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const tags = (item.tags||[]).map((t: string)=>`<span class="tag">${escHtml(t)}</span>`).join('');
|
|
580
|
+
|
|
581
|
+
const notesHtml = notes.length === 0
|
|
582
|
+
? '<div style="color:var(--text-muted);font-size:13px;margin-bottom:12px">No notes yet</div>'
|
|
583
|
+
: notes.map((n: any)=>`
|
|
584
|
+
<div class="notes-item">
|
|
585
|
+
<div class="notes-item-meta">${relTime(n.timestamp||n.created_at||'')}</div>
|
|
586
|
+
<div class="notes-item-text">${escHtml(n.text||n.content||JSON.stringify(n))}</div>
|
|
587
|
+
</div>`).join('');
|
|
588
|
+
|
|
589
|
+
const testsHtml = tests.length === 0
|
|
590
|
+
? '<div style="color:var(--text-muted);font-size:13px;margin-bottom:12px">No tests defined</div>'
|
|
591
|
+
: tests.map((t: any)=>`
|
|
592
|
+
<div class="test-item">
|
|
593
|
+
<div style="flex:1">
|
|
594
|
+
<div class="test-item-cmd">${escHtml(t.command||t.cmd||JSON.stringify(t))}</div>
|
|
595
|
+
${t.description ? `<div style="font-size:12px;color:var(--text-secondary);margin-top:3px">${escHtml(t.description)}</div>` : ''}
|
|
596
|
+
</div>
|
|
597
|
+
</div>`).join('');
|
|
598
|
+
|
|
599
|
+
const commentsHtml = comments.length === 0
|
|
600
|
+
? '<div style="color:var(--text-muted);font-size:13px">No comments yet</div>'
|
|
601
|
+
: comments.map((c: any)=>`
|
|
602
|
+
<div class="comment-item">
|
|
603
|
+
<div class="comment-avatar">💬</div>
|
|
604
|
+
<div class="comment-body">
|
|
605
|
+
<div class="comment-meta">${fmtDate(c.timestamp||c.created_at)}</div>
|
|
606
|
+
<div class="comment-text">${escHtml(c.text||c.content||c.body||JSON.stringify(c))}</div>
|
|
607
|
+
</div>
|
|
608
|
+
</div>`).join('');
|
|
609
|
+
|
|
610
|
+
const historyHtml = history.length === 0
|
|
611
|
+
? '<div style="color:var(--text-muted);font-size:13px">No history</div>'
|
|
612
|
+
: history.slice(0,10).map((h: any)=>`
|
|
613
|
+
<div class="history-item">
|
|
614
|
+
<div class="history-dot"></div>
|
|
615
|
+
<div><div class="history-text">${escHtml(h.message||h.action||JSON.stringify(h))}</div><div class="history-time">${relTime(h.timestamp||h.created_at)}</div></div>
|
|
616
|
+
</div>`).join('');
|
|
617
|
+
|
|
618
|
+
const bodyEl = modal?.querySelector('.modal-body');
|
|
619
|
+
if (bodyEl) {
|
|
620
|
+
bodyEl.innerHTML = `
|
|
621
|
+
<div class="item-detail-header">
|
|
622
|
+
<div class="item-detail-id">${typeIcon(item.type||'')} ${escHtml(item.type)} · ${escHtml(item.id)}</div>
|
|
623
|
+
<div class="item-detail-title">${escHtml(item.title)}</div>
|
|
624
|
+
<div class="item-detail-meta">
|
|
625
|
+
${statusBadge(item.status||'draft')}
|
|
626
|
+
<div class="meta-chip">${priorityDot(item.priority??4)} <strong>P${item.priority}</strong> ${PRIORITY_LABELS[item.priority]||''}</div>
|
|
627
|
+
${item.created_at ? `<div class="meta-chip">Created <strong>${fmtDate(item.created_at)}</strong></div>` : ''}
|
|
628
|
+
${item.updated_at ? `<div class="meta-chip">Updated <strong>${relTime(item.updated_at)}</strong></div>` : ''}
|
|
629
|
+
${item.parent ? `<div class="meta-chip">Parent <strong class="mono">${escHtml(item.parent)}</strong></div>` : ''}
|
|
630
|
+
${item.claimedBy ? `<div class="meta-chip">Claimed by <strong>${escHtml(item.claimedBy)}</strong></div>` : ''}
|
|
631
|
+
${item.deadline ? `<div class="meta-chip">Due <strong>${fmtDate(item.deadline)}</strong></div>` : ''}
|
|
632
|
+
${item.assignee ? `<div class="meta-chip">Assignee <strong>${escHtml(item.assignee)}</strong></div>` : ''}
|
|
633
|
+
${item.sprint ? `<div class="meta-chip">Sprint <strong>${escHtml(item.sprint)}</strong></div>` : ''}
|
|
634
|
+
${item.release ? `<div class="meta-chip">Release <strong>${escHtml(item.release)}</strong></div>` : ''}
|
|
635
|
+
${item.estimated_minutes ? `<div class="meta-chip">~${item.estimated_minutes}m</div>` : ''}
|
|
636
|
+
${item.blockedBy||item['blocked-by'] ? `<div class="meta-chip" style="border-color:rgba(248,113,113,0.4);color:#f87171">Blocked by <strong class="mono">${escHtml(item.blockedBy||item['blocked-by']||'')}</strong></div>` : ''}
|
|
637
|
+
${tags ? `<div class="item-tags">${tags}</div>` : ''}
|
|
638
|
+
</div>
|
|
639
|
+
<div class="claim-btn-wrap">
|
|
640
|
+
<button class="btn btn-secondary btn-sm" onclick="window.__app.claimItem('${escHtml(itemId)}')">⊕ Claim</button>
|
|
641
|
+
<button class="btn btn-ghost btn-sm" onclick="window.__app.releaseItem('${escHtml(itemId)}')">⊖ Release</button>
|
|
642
|
+
${item.status === 'open' ? `<button class="btn btn-secondary btn-sm" onclick="window.__app.startItem('${escHtml(itemId)}')">▶ Start</button>` : ''}
|
|
643
|
+
${item.status === 'in_progress' ? `<button class="btn btn-ghost btn-sm" onclick="window.__app.pauseItem('${escHtml(itemId)}')">⏸ Pause</button>` : ''}
|
|
644
|
+
<button class="btn btn-ghost btn-sm" onclick="window.__app.useItemAsTemplate(${JSON.stringify(item)})" title="Open create form pre-filled with this item's fields">⊡ Use as Template</button>
|
|
645
|
+
</div>
|
|
646
|
+
</div>
|
|
647
|
+
|
|
648
|
+
${item.description ? `
|
|
649
|
+
<div class="item-detail-section">
|
|
650
|
+
<div class="item-detail-section-title">Description</div>
|
|
651
|
+
<div class="item-detail-desc">${escHtml(item.description)}</div>
|
|
652
|
+
</div>` : ''}
|
|
653
|
+
|
|
654
|
+
${(item.blockedReason||item['blocked-reason']) ? `
|
|
655
|
+
<div class="item-detail-section" style="background:rgba(248,113,113,0.08);border:1px solid rgba(248,113,113,0.25);border-radius:var(--radius);padding:12px 14px;margin-bottom:16px">
|
|
656
|
+
<div class="item-detail-section-title" style="color:#f87171">Blocked Reason</div>
|
|
657
|
+
<div class="item-detail-desc">${escHtml(item.blockedReason||item['blocked-reason']||'')}</div>
|
|
658
|
+
</div>` : ''}
|
|
659
|
+
|
|
660
|
+
${item.acceptance_criteria||item.acceptanceCriteria ? `
|
|
661
|
+
<div class="item-detail-section">
|
|
662
|
+
<div class="item-detail-section-title">Acceptance Criteria</div>
|
|
663
|
+
<div class="item-detail-desc">${escHtml(item.acceptance_criteria||item.acceptanceCriteria||'')}</div>
|
|
664
|
+
</div>` : ''}
|
|
665
|
+
|
|
666
|
+
<div class="tabs">
|
|
667
|
+
<div class="tab active" onclick="window.__app.switchDetailTab(this,'tab-comments')">Comments (${comments.length})</div>
|
|
668
|
+
<div class="tab" onclick="window.__app.switchDetailTab(this,'tab-update')">Update</div>
|
|
669
|
+
<div class="tab" onclick="window.__app.switchDetailTab(this,'tab-notes')">Notes (${notes.length})</div>
|
|
670
|
+
<div class="tab" onclick="window.__app.switchDetailTab(this,'tab-deps')">Deps (${deps.length})</div>
|
|
671
|
+
<div class="tab" onclick="window.__app.switchDetailTab(this,'tab-graph','${escHtml(itemId)}')">◎ Graph</div>
|
|
672
|
+
<div class="tab" onclick="window.__app.switchDetailTab(this,'tab-learnings')">Learnings (${learnings.length})</div>
|
|
673
|
+
<div class="tab" onclick="window.__app.switchDetailTab(this,'tab-tests')">Tests (${tests.length})</div>
|
|
674
|
+
<div class="tab" onclick="window.__app.switchDetailTab(this,'tab-files')">Files (${files.length})</div>
|
|
675
|
+
<div class="tab" onclick="window.__app.switchDetailTab(this,'tab-history')">History</div>
|
|
676
|
+
${item.status!=='closed'&&item.status!=='canceled'?`<div class="tab" onclick="window.__app.switchDetailTab(this,'tab-close')">Close</div>`:''}
|
|
677
|
+
</div>
|
|
678
|
+
|
|
679
|
+
<div id="tab-comments">
|
|
680
|
+
${commentsHtml}
|
|
681
|
+
<hr class="section-divider">
|
|
682
|
+
<div class="form-group">
|
|
683
|
+
<label class="form-label">Add Comment</label>
|
|
684
|
+
<textarea class="form-textarea" id="new-comment" placeholder="Write a comment…" rows="3"></textarea>
|
|
685
|
+
</div>
|
|
686
|
+
<button class="btn btn-primary btn-sm" onclick="window.__app.addComment('${escHtml(itemId)}')">Post Comment</button>
|
|
687
|
+
</div>
|
|
688
|
+
|
|
689
|
+
<div id="tab-update" style="display:none">
|
|
690
|
+
<div class="form-group">
|
|
691
|
+
<label class="form-label">Title</label>
|
|
692
|
+
<input class="form-input" id="edit-title" type="text" value="${escHtml(item.title)}">
|
|
693
|
+
</div>
|
|
694
|
+
<div class="two-col">
|
|
695
|
+
<div class="form-group">
|
|
696
|
+
<label class="form-label">Status</label>
|
|
697
|
+
<select class="form-select" id="edit-status">
|
|
698
|
+
${getStatuses(state.schema).map(s=>`<option value="${s}"${item.status===s?' selected':''}>${s.replace('_',' ')}</option>`).join('')}
|
|
699
|
+
</select>
|
|
700
|
+
</div>
|
|
701
|
+
<div class="form-group">
|
|
702
|
+
<label class="form-label">Priority</label>
|
|
703
|
+
<select class="form-select" id="edit-priority">
|
|
704
|
+
${[0,1,2,3,4].map(p=>`<option value="${p}"${item.priority==p?' selected':''}>P${p}: ${PRIORITY_LABELS[p]}</option>`).join('')}
|
|
705
|
+
</select>
|
|
706
|
+
</div>
|
|
707
|
+
</div>
|
|
708
|
+
<div class="form-group">
|
|
709
|
+
<label class="form-label">Tags (comma-separated)</label>
|
|
710
|
+
<input class="form-input" id="edit-tags" type="text" value="${escHtml((item.tags||[]).join(', '))}">
|
|
711
|
+
</div>
|
|
712
|
+
<div class="two-col">
|
|
713
|
+
<div class="form-group">
|
|
714
|
+
<label class="form-label">Deadline</label>
|
|
715
|
+
<input class="form-input" id="edit-deadline" type="text" placeholder="+1d, +1w, 2026-06-01" value="${escHtml(item.deadline||'')}">
|
|
716
|
+
</div>
|
|
717
|
+
<div class="form-group">
|
|
718
|
+
<label class="form-label">Assignee</label>
|
|
719
|
+
<input class="form-input" id="edit-assignee" type="text" value="${escHtml(item.assignee||'')}">
|
|
720
|
+
</div>
|
|
721
|
+
</div>
|
|
722
|
+
<div class="two-col">
|
|
723
|
+
<div class="form-group">
|
|
724
|
+
<label class="form-label">Sprint</label>
|
|
725
|
+
<input class="form-input" id="edit-sprint" type="text" value="${escHtml(item.sprint||'')}">
|
|
726
|
+
</div>
|
|
727
|
+
<div class="form-group">
|
|
728
|
+
<label class="form-label">Release</label>
|
|
729
|
+
<input class="form-input" id="edit-release" type="text" value="${escHtml(item.release||'')}">
|
|
730
|
+
</div>
|
|
731
|
+
</div>
|
|
732
|
+
<div class="form-group">
|
|
733
|
+
<label class="form-label">Estimated Minutes</label>
|
|
734
|
+
<input class="form-input" id="edit-estimate" type="number" min="1" value="${escHtml(String(item.estimated_minutes||''))}">
|
|
735
|
+
</div>
|
|
736
|
+
<div class="form-group">
|
|
737
|
+
<label class="form-label">Description</label>
|
|
738
|
+
<textarea class="form-textarea" id="edit-desc" rows="4">${escHtml(item.description||'')}</textarea>
|
|
739
|
+
</div>
|
|
740
|
+
<div class="form-group">
|
|
741
|
+
<label class="form-label">Body (extended)</label>
|
|
742
|
+
<textarea class="form-textarea" id="edit-body" rows="3">${escHtml(item.body||'')}</textarea>
|
|
743
|
+
</div>
|
|
744
|
+
<div class="form-group">
|
|
745
|
+
<label class="form-label">Acceptance Criteria</label>
|
|
746
|
+
<textarea class="form-textarea" id="edit-acceptance-criteria" rows="2">${escHtml(item.acceptance_criteria||item.acceptanceCriteria||'')}</textarea>
|
|
747
|
+
</div>
|
|
748
|
+
<div class="form-group">
|
|
749
|
+
<label class="form-label">Blocked Reason</label>
|
|
750
|
+
<textarea class="form-textarea" id="edit-blocked-reason" rows="2" placeholder="Why is this item blocked?">${escHtml(item.blockedReason||item['blocked-reason']||'')}</textarea>
|
|
751
|
+
</div>
|
|
752
|
+
<button class="btn btn-primary" onclick="window.__app.updateItem('${escHtml(itemId)}')">Save Changes</button>
|
|
753
|
+
</div>
|
|
754
|
+
|
|
755
|
+
<div id="tab-notes" style="display:none">
|
|
756
|
+
<div style="margin-bottom:16px">${notesHtml}</div>
|
|
757
|
+
<hr class="section-divider">
|
|
758
|
+
<div class="form-group">
|
|
759
|
+
<label class="form-label">Add Note</label>
|
|
760
|
+
<textarea class="form-textarea" id="new-note" placeholder="Add a note to this item…" rows="4"></textarea>
|
|
761
|
+
</div>
|
|
762
|
+
<button class="btn btn-primary btn-sm" onclick="window.__app.addNote('${escHtml(itemId)}')">Add Note</button>
|
|
763
|
+
<hr class="section-divider">
|
|
764
|
+
<div class="form-group">
|
|
765
|
+
<label class="form-label">Append to Description</label>
|
|
766
|
+
<textarea class="form-textarea" id="new-append" placeholder="Text to append…" rows="3"></textarea>
|
|
767
|
+
</div>
|
|
768
|
+
<button class="btn btn-secondary btn-sm" onclick="window.__app.appendItem('${escHtml(itemId)}')">Append</button>
|
|
769
|
+
</div>
|
|
770
|
+
|
|
771
|
+
<div id="tab-deps" style="display:none">
|
|
772
|
+
<div style="margin-bottom:16px">
|
|
773
|
+
${deps.length === 0
|
|
774
|
+
? `<div style="color:var(--text-muted);font-size:13px;margin-bottom:12px">No dependencies</div>`
|
|
775
|
+
: deps.map((dep: RawDependency)=> {
|
|
776
|
+
const target = depTargetId(dep);
|
|
777
|
+
const rel = depRelation(dep);
|
|
778
|
+
const title = dep.targetTitle || dep.title || '';
|
|
779
|
+
return `<div class="dep-row">
|
|
780
|
+
<span class="dep-rel">${escHtml(depLabel(rel))}</span>
|
|
781
|
+
<span class="dep-id" onclick="window.__app.hideModal('item-detail-modal');window.__app.openItemDetail('${escHtml(target)}')">
|
|
782
|
+
${escHtml(target)}
|
|
783
|
+
</span>
|
|
784
|
+
<span style="flex:1;color:var(--text-secondary);font-size:12px">${escHtml(String(title))}</span>
|
|
785
|
+
<button class="btn btn-danger btn-sm" onclick="window.__app.removeDep('${escHtml(itemId)}','${escHtml(target)}','${escHtml(rel)}')">Remove</button>
|
|
786
|
+
</div>`;
|
|
787
|
+
}).join('')
|
|
788
|
+
}
|
|
789
|
+
</div>
|
|
790
|
+
<hr class="section-divider">
|
|
791
|
+
<div style="font-size:11px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:10px">Add Dependency</div>
|
|
792
|
+
<div class="row" style="margin-bottom:8px">
|
|
793
|
+
<input class="form-input flex-1" id="dep-target-id" type="text" placeholder="Item ID (e.g. ${escHtml(state.currentProject?.prefix||'proj')}-5)">
|
|
794
|
+
<select class="form-select" id="dep-rel" style="width:200px">
|
|
795
|
+
${renderDependencyOptions()}
|
|
796
|
+
</select>
|
|
797
|
+
</div>
|
|
798
|
+
<button class="btn btn-primary btn-sm" onclick="window.__app.addDep('${escHtml(itemId)}')">Add Dependency</button>
|
|
799
|
+
</div>
|
|
800
|
+
|
|
801
|
+
<div id="tab-learnings" style="display:none">
|
|
802
|
+
<div style="margin-bottom:16px">
|
|
803
|
+
${learnings.length === 0
|
|
804
|
+
? `<div style="color:var(--text-muted);font-size:13px;margin-bottom:12px">No learnings recorded</div>`
|
|
805
|
+
: learnings.map((l: any)=>`<div class="learning-row">
|
|
806
|
+
<div style="font-size:11px;color:var(--text-muted);margin-bottom:3px">${relTime(l.timestamp||l.created_at||'')}</div>
|
|
807
|
+
${escHtml(l.text||l.content||JSON.stringify(l))}
|
|
808
|
+
</div>`).join('')
|
|
809
|
+
}
|
|
810
|
+
</div>
|
|
811
|
+
<hr class="section-divider">
|
|
812
|
+
<div class="form-group">
|
|
813
|
+
<label class="form-label">Add Learning</label>
|
|
814
|
+
<textarea class="form-textarea" id="new-learning" placeholder="What did you learn working on this item?" rows="3"></textarea>
|
|
815
|
+
</div>
|
|
816
|
+
<button class="btn btn-primary btn-sm" onclick="window.__app.addLearning('${escHtml(itemId)}')">Record Learning</button>
|
|
817
|
+
</div>
|
|
818
|
+
|
|
819
|
+
<div id="tab-tests" style="display:none">
|
|
820
|
+
<div style="margin-bottom:16px">${testsHtml}</div>
|
|
821
|
+
<hr class="section-divider">
|
|
822
|
+
<div style="font-size:11px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:10px">Add Test</div>
|
|
823
|
+
<div class="form-group">
|
|
824
|
+
<label class="form-label">Command *</label>
|
|
825
|
+
<input class="form-input" id="new-test-cmd" type="text" placeholder="npm test, pytest tests/, etc.">
|
|
826
|
+
</div>
|
|
827
|
+
<div class="form-group">
|
|
828
|
+
<label class="form-label">Description (optional)</label>
|
|
829
|
+
<input class="form-input" id="new-test-desc" type="text" placeholder="What does this test verify?">
|
|
830
|
+
</div>
|
|
831
|
+
<button class="btn btn-primary btn-sm" onclick="window.__app.addTest('${escHtml(itemId)}')">Add Test</button>
|
|
832
|
+
</div>
|
|
833
|
+
|
|
834
|
+
<div id="tab-history" style="display:none">${historyHtml}</div>
|
|
835
|
+
|
|
836
|
+
<!-- Local graph tab -->
|
|
837
|
+
<div id="tab-graph" style="display:none">
|
|
838
|
+
<div class="local-graph-controls" style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
|
839
|
+
<span style="font-size:12px;color:var(--text-muted)">Depth</span>
|
|
840
|
+
<input type="range" id="local-graph-depth" min="1" max="5" step="1" value="2" class="graph-depth-slider" style="width:100px">
|
|
841
|
+
<span id="local-graph-depth-val" style="font-size:11px;color:var(--accent);font-family:'JetBrains Mono',monospace;min-width:12px">2</span>
|
|
842
|
+
<button class="btn btn-ghost btn-sm" onclick="window.__app.openGraphAt('${escHtml(itemId)}')" title="Open in full graph view" style="margin-left:auto">Full graph →</button>
|
|
843
|
+
</div>
|
|
844
|
+
<div id="local-graph-canvas" style="height:320px;border-radius:8px;overflow:hidden;background:#080d1a;border:1px solid rgba(148,163,184,0.12)"></div>
|
|
845
|
+
</div>
|
|
846
|
+
|
|
847
|
+
<div id="tab-files" style="display:none">
|
|
848
|
+
<div style="margin-bottom:16px">
|
|
849
|
+
${files.length === 0
|
|
850
|
+
? '<div style="color:var(--text-muted);font-size:13px;margin-bottom:12px">No files linked</div>'
|
|
851
|
+
: files.map((f: any) => `
|
|
852
|
+
<div class="file-row">
|
|
853
|
+
<span style="color:var(--text-muted)">📄</span>
|
|
854
|
+
<span class="file-path">${escHtml(f.path || f.name || JSON.stringify(f))}</span>
|
|
855
|
+
${f.scope ? `<span style="font-size:10px;color:var(--text-muted);background:var(--bg-input);padding:2px 6px;border-radius:4px">${escHtml(f.scope)}</span>` : ''}
|
|
856
|
+
</div>`).join('')
|
|
857
|
+
}
|
|
858
|
+
</div>
|
|
859
|
+
<hr class="section-divider">
|
|
860
|
+
<div style="font-size:11px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:10px">Link File</div>
|
|
861
|
+
<div class="row" style="margin-bottom:8px">
|
|
862
|
+
<input class="form-input flex-1" id="file-path-input" type="text" placeholder="src/components/App.tsx">
|
|
863
|
+
<button class="btn btn-primary btn-sm" onclick="window.__app.addFileLink('${escHtml(itemId)}')">Add</button>
|
|
864
|
+
</div>
|
|
865
|
+
</div>
|
|
866
|
+
|
|
867
|
+
${item.status!=='closed'&&item.status!=='canceled'?`
|
|
868
|
+
<div id="tab-close" style="display:none">
|
|
869
|
+
<div class="form-group">
|
|
870
|
+
<label class="form-label">Close Reason *</label>
|
|
871
|
+
<textarea class="form-textarea" id="close-reason" placeholder="Why is this being closed?" rows="3"></textarea>
|
|
872
|
+
</div>
|
|
873
|
+
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
|
874
|
+
<button class="btn btn-primary" onclick="window.__app.closeItem('${escHtml(itemId)}','closed')">Mark Closed</button>
|
|
875
|
+
<button class="btn btn-secondary" onclick="window.__app.closeItem('${escHtml(itemId)}','canceled')">Mark Canceled</button>
|
|
876
|
+
<button class="btn btn-danger btn-sm" onclick="window.__app.confirmDeleteItem('${escHtml(itemId)}')">Delete Item</button>
|
|
877
|
+
</div>
|
|
878
|
+
</div>`:''}
|
|
879
|
+
`;
|
|
880
|
+
}
|
|
881
|
+
} catch(err: unknown) {
|
|
882
|
+
const bodyEl = document.getElementById('item-detail-modal')?.querySelector('.modal-body');
|
|
883
|
+
if (bodyEl) bodyEl.innerHTML = `<div class="empty-state"><div class="empty-state-text">Failed to load: ${escHtml(err instanceof Error ? err.message : String(err))}</div></div>`;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function relTime(ts: string | undefined | null): string {
|
|
888
|
+
if (!ts) return '';
|
|
889
|
+
const d = new Date(ts);
|
|
890
|
+
const diff = Date.now() - d.getTime();
|
|
891
|
+
const s = Math.floor(diff/1000);
|
|
892
|
+
if (s < 60) return 'just now';
|
|
893
|
+
if (s < 3600) return `${Math.floor(s/60)}m ago`;
|
|
894
|
+
if (s < 86400) return `${Math.floor(s/3600)}h ago`;
|
|
895
|
+
if (s < 604800) return `${Math.floor(s/86400)}d ago`;
|
|
896
|
+
return d.toLocaleDateString();
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function fmtDate(ts: string | undefined | null): string {
|
|
900
|
+
if (!ts) return '';
|
|
901
|
+
return new Date(ts).toLocaleDateString('en-US', { year:'numeric', month:'short', day:'numeric' });
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
export function switchDetailTab(tabEl: HTMLElement, targetId: string, nodeId?: string): void {
|
|
905
|
+
const allTabs = tabEl.parentElement?.querySelectorAll('.tab');
|
|
906
|
+
allTabs?.forEach(t => t.classList.remove('active'));
|
|
907
|
+
tabEl.classList.add('active');
|
|
908
|
+
const ids = ['tab-comments','tab-update','tab-notes','tab-deps','tab-graph','tab-learnings','tab-tests','tab-files','tab-history','tab-close'];
|
|
909
|
+
ids.forEach(id => {
|
|
910
|
+
const el = document.getElementById(id);
|
|
911
|
+
if (el) el.style.display = id === targetId ? '' : 'none';
|
|
912
|
+
// Clean up local graph canvas when switching away
|
|
913
|
+
if (id === 'tab-graph' && id !== targetId) destroyLocalGraph('local-graph-canvas');
|
|
914
|
+
});
|
|
915
|
+
// Initialize local graph when tab is opened
|
|
916
|
+
if (targetId === 'tab-graph' && nodeId) {
|
|
917
|
+
const depthSlider = document.getElementById('local-graph-depth') as HTMLInputElement | null;
|
|
918
|
+
const depthVal = document.getElementById('local-graph-depth-val');
|
|
919
|
+
const depth = depthSlider ? parseInt(depthSlider.value, 10) : 2;
|
|
920
|
+
void renderLocalGraph('local-graph-canvas', nodeId, depth);
|
|
921
|
+
depthSlider?.addEventListener('input', () => {
|
|
922
|
+
const d = parseInt(depthSlider.value, 10);
|
|
923
|
+
if (depthVal) depthVal.textContent = String(d);
|
|
924
|
+
void renderLocalGraph('local-graph-canvas', nodeId, d);
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
export async function addComment(itemId: string): Promise<void> {
|
|
930
|
+
const el = document.getElementById('new-comment') as HTMLTextAreaElement | null;
|
|
931
|
+
if (!el) return;
|
|
932
|
+
const text = el.value.trim();
|
|
933
|
+
if (!text) { toast('Comment cannot be empty','error'); return; }
|
|
934
|
+
try {
|
|
935
|
+
await api('POST',`/projects/${state.currentProject!.id}/pm/comments/${itemId}`,{text});
|
|
936
|
+
toast('Comment added','success');
|
|
937
|
+
el.value = '';
|
|
938
|
+
openItemDetail(itemId);
|
|
939
|
+
} catch(err: unknown) { toast(err instanceof Error ? err.message : String(err),'error'); }
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
export async function addNote(itemId: string): Promise<void> {
|
|
943
|
+
const el = document.getElementById('new-note') as HTMLTextAreaElement | null;
|
|
944
|
+
if (!el) return;
|
|
945
|
+
const text = el.value.trim();
|
|
946
|
+
if (!text) { toast('Note cannot be empty','error'); return; }
|
|
947
|
+
try {
|
|
948
|
+
await api('POST',`/projects/${state.currentProject!.id}/pm/notes/${itemId}`,{text});
|
|
949
|
+
toast('Note added','success');
|
|
950
|
+
el.value = '';
|
|
951
|
+
} catch(err: unknown) { toast(err instanceof Error ? err.message : String(err),'error'); }
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
export async function appendItem(itemId: string): Promise<void> {
|
|
955
|
+
const el = document.getElementById('new-append') as HTMLTextAreaElement | null;
|
|
956
|
+
if (!el) return;
|
|
957
|
+
const text = el.value.trim();
|
|
958
|
+
if (!text) { toast('Text cannot be empty','error'); return; }
|
|
959
|
+
try {
|
|
960
|
+
await api('POST',`/projects/${state.currentProject!.id}/pm/append/${itemId}`,{text});
|
|
961
|
+
toast('Appended','success');
|
|
962
|
+
el.value = '';
|
|
963
|
+
} catch(err: unknown) { toast(err instanceof Error ? err.message : String(err),'error'); }
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
export async function updateItem(itemId: string): Promise<void> {
|
|
967
|
+
const titleEl = document.getElementById('edit-title') as HTMLInputElement | null;
|
|
968
|
+
const statusEl = document.getElementById('edit-status') as HTMLSelectElement | null;
|
|
969
|
+
const priorityEl = document.getElementById('edit-priority') as HTMLSelectElement | null;
|
|
970
|
+
const tagsEl = document.getElementById('edit-tags') as HTMLInputElement | null;
|
|
971
|
+
const descEl = document.getElementById('edit-desc') as HTMLTextAreaElement | null;
|
|
972
|
+
const deadlineEl = document.getElementById('edit-deadline') as HTMLInputElement | null;
|
|
973
|
+
const assigneeEl = document.getElementById('edit-assignee') as HTMLInputElement | null;
|
|
974
|
+
const sprintEl = document.getElementById('edit-sprint') as HTMLInputElement | null;
|
|
975
|
+
const releaseEl = document.getElementById('edit-release') as HTMLInputElement | null;
|
|
976
|
+
const estimateEl = document.getElementById('edit-estimate') as HTMLInputElement | null;
|
|
977
|
+
const bodyEl = document.getElementById('edit-body') as HTMLTextAreaElement | null;
|
|
978
|
+
const acEl = document.getElementById('edit-acceptance-criteria') as HTMLTextAreaElement | null;
|
|
979
|
+
const blockedReasonEl = document.getElementById('edit-blocked-reason') as HTMLTextAreaElement | null;
|
|
980
|
+
|
|
981
|
+
const title = titleEl?.value.trim() || '';
|
|
982
|
+
const status = statusEl?.value || '';
|
|
983
|
+
const priority = priorityEl?.value || '';
|
|
984
|
+
const tags = tagsEl?.value.trim() || '';
|
|
985
|
+
const description = descEl?.value.trim() || '';
|
|
986
|
+
const deadline = deadlineEl?.value.trim() || '';
|
|
987
|
+
const assignee = assigneeEl?.value.trim() || '';
|
|
988
|
+
const sprint = sprintEl?.value.trim() || '';
|
|
989
|
+
const release = releaseEl?.value.trim() || '';
|
|
990
|
+
const estimate = estimateEl?.value.trim() || '';
|
|
991
|
+
const body = bodyEl?.value.trim() || '';
|
|
992
|
+
const acceptanceCriteria = acEl?.value.trim() || '';
|
|
993
|
+
const blockedReason = blockedReasonEl?.value.trim() || '';
|
|
994
|
+
|
|
995
|
+
if (!title) { toast('Title required','error'); return; }
|
|
996
|
+
try {
|
|
997
|
+
const payload: Record<string, string> = {title,status,priority,tags,description};
|
|
998
|
+
if (deadline) payload.deadline = deadline;
|
|
999
|
+
if (assignee) payload.assignee = assignee;
|
|
1000
|
+
if (sprint) payload.sprint = sprint;
|
|
1001
|
+
if (release) payload.release = release;
|
|
1002
|
+
if (estimate) payload.estimate = estimate;
|
|
1003
|
+
if (body) payload.body = body;
|
|
1004
|
+
if (acceptanceCriteria) payload.acceptanceCriteria = acceptanceCriteria;
|
|
1005
|
+
if (blockedReason) payload.blockedReason = blockedReason;
|
|
1006
|
+
await api('PATCH',`/projects/${state.currentProject!.id}/pm/update/${itemId}`,payload);
|
|
1007
|
+
toast('Item updated','success');
|
|
1008
|
+
openItemDetail(itemId);
|
|
1009
|
+
if (state.currentView==='items') fetchAndRenderItems();
|
|
1010
|
+
loadItemsBadge();
|
|
1011
|
+
} catch(err: unknown) { toast(err instanceof Error ? err.message : String(err),'error'); }
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
export async function closeItem(itemId: string, targetStatus: string): Promise<void> {
|
|
1015
|
+
const reasonEl = document.getElementById('close-reason') as HTMLTextAreaElement | null;
|
|
1016
|
+
const reason = reasonEl?.value?.trim();
|
|
1017
|
+
if (!reason) { toast('Close reason is required','error'); return; }
|
|
1018
|
+
try {
|
|
1019
|
+
if (targetStatus === 'canceled') {
|
|
1020
|
+
await api('PATCH',`/projects/${state.currentProject!.id}/pm/update/${itemId}`,{status:'canceled'});
|
|
1021
|
+
await api('POST',`/projects/${state.currentProject!.id}/pm/close/${itemId}`,{reason});
|
|
1022
|
+
} else {
|
|
1023
|
+
await api('POST',`/projects/${state.currentProject!.id}/pm/close/${itemId}`,{reason});
|
|
1024
|
+
}
|
|
1025
|
+
toast(`Item ${targetStatus}`,'success');
|
|
1026
|
+
hideModal('item-detail-modal');
|
|
1027
|
+
if (state.currentView==='items') fetchAndRenderItems();
|
|
1028
|
+
loadItemsBadge();
|
|
1029
|
+
} catch(err: unknown) { toast(err instanceof Error ? err.message : String(err),'error'); }
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
export function confirmDeleteItem(itemId: string): void {
|
|
1033
|
+
confirmDialog('Delete Item?', 'This action cannot be undone. The item and all its data will be permanently removed.', async () => {
|
|
1034
|
+
try {
|
|
1035
|
+
await api('DELETE',`/projects/${state.currentProject!.id}/pm/delete/${itemId}`);
|
|
1036
|
+
toast('Item deleted','success');
|
|
1037
|
+
hideModal('item-detail-modal');
|
|
1038
|
+
if (state.currentView==='items') fetchAndRenderItems();
|
|
1039
|
+
loadItemsBadge();
|
|
1040
|
+
} catch(err: unknown) { toast(err instanceof Error ? err.message : String(err),'error'); }
|
|
1041
|
+
}, true);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1045
|
+
// CLAIM / RELEASE / START / PAUSE
|
|
1046
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1047
|
+
export async function claimItem(itemId: string): Promise<void> {
|
|
1048
|
+
const row = document.querySelector(`.item-row[onclick*="${itemId}"]`) as HTMLElement | null;
|
|
1049
|
+
if (row) row.style.opacity = '0.6';
|
|
1050
|
+
try {
|
|
1051
|
+
await api('POST',`/projects/${state.currentProject!.id}/pm/claim/${itemId}`,{});
|
|
1052
|
+
toast('Item claimed','success');
|
|
1053
|
+
openItemDetail(itemId);
|
|
1054
|
+
if (state.currentView==='items') fetchAndRenderItems();
|
|
1055
|
+
} catch(err: unknown) { toast(err instanceof Error ? err.message : String(err),'error'); if (row) row.style.opacity = ''; }
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
export async function releaseItem(itemId: string): Promise<void> {
|
|
1059
|
+
try {
|
|
1060
|
+
await api('POST',`/projects/${state.currentProject!.id}/pm/release/${itemId}`,{});
|
|
1061
|
+
toast('Item released','success');
|
|
1062
|
+
openItemDetail(itemId);
|
|
1063
|
+
if (state.currentView==='items') fetchAndRenderItems();
|
|
1064
|
+
} catch(err: unknown) { toast(err instanceof Error ? err.message : String(err),'error'); }
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
export async function startItem(itemId: string): Promise<void> {
|
|
1068
|
+
try {
|
|
1069
|
+
await api('POST',`/projects/${state.currentProject!.id}/pm/start-task/${itemId}`,{});
|
|
1070
|
+
toast('Item started','success');
|
|
1071
|
+
openItemDetail(itemId);
|
|
1072
|
+
if (state.currentView==='items') fetchAndRenderItems();
|
|
1073
|
+
loadItemsBadge();
|
|
1074
|
+
} catch(err: unknown) { toast(err instanceof Error ? err.message : String(err),'error'); }
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
export async function pauseItem(itemId: string): Promise<void> {
|
|
1078
|
+
try {
|
|
1079
|
+
await api('POST',`/projects/${state.currentProject!.id}/pm/pause-task/${itemId}`,{});
|
|
1080
|
+
toast('Item paused','success');
|
|
1081
|
+
openItemDetail(itemId);
|
|
1082
|
+
if (state.currentView==='items') fetchAndRenderItems();
|
|
1083
|
+
loadItemsBadge();
|
|
1084
|
+
} catch(err: unknown) { toast(err instanceof Error ? err.message : String(err),'error'); }
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1088
|
+
// DEPS / LEARNINGS / TESTS / FILES
|
|
1089
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1090
|
+
export async function addDep(itemId: string): Promise<void> {
|
|
1091
|
+
const targetIdEl = document.getElementById('dep-target-id') as HTMLInputElement | null;
|
|
1092
|
+
const relEl = document.getElementById('dep-rel') as HTMLSelectElement | null;
|
|
1093
|
+
const targetId = targetIdEl?.value?.trim() || '';
|
|
1094
|
+
const rel = normalizeDepRelation(relEl?.value || 'blocked_by');
|
|
1095
|
+
if (!targetId) { toast('Target item ID is required','error'); return; }
|
|
1096
|
+
if (targetId === itemId) {
|
|
1097
|
+
toast('A dependency cannot target the same item','error');
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
try {
|
|
1101
|
+
await api('POST',`/projects/${state.currentProject!.id}/pm/deps/${itemId}`,{targetId,rel});
|
|
1102
|
+
toast('Dependency added','success');
|
|
1103
|
+
openItemDetail(itemId);
|
|
1104
|
+
} catch(err: unknown) { toast(err instanceof Error ? err.message : String(err),'error'); }
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
export async function removeDep(itemId: string, targetId: string, relation: string): Promise<void> {
|
|
1108
|
+
const rel = normalizeDepRelation(relation);
|
|
1109
|
+
if (!targetId) { toast('Target item ID is required','error'); return; }
|
|
1110
|
+
confirmDialog(
|
|
1111
|
+
`Remove dependency ${rel}?`,
|
|
1112
|
+
`This will remove the ${rel} dependency between ${itemId} and ${targetId}.`,
|
|
1113
|
+
async () => {
|
|
1114
|
+
try {
|
|
1115
|
+
await api('DELETE', `/projects/${state.currentProject!.id}/pm/deps/${itemId}`, { targetId, rel });
|
|
1116
|
+
toast('Dependency removed','success');
|
|
1117
|
+
openItemDetail(itemId);
|
|
1118
|
+
} catch(err: unknown) { toast(err instanceof Error ? err.message : String(err),'error'); }
|
|
1119
|
+
},
|
|
1120
|
+
true
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
export async function addLearning(itemId: string): Promise<void> {
|
|
1125
|
+
const el = document.getElementById('new-learning') as HTMLTextAreaElement | null;
|
|
1126
|
+
const text = el?.value?.trim() || '';
|
|
1127
|
+
if (!text) { toast('Learning text is required','error'); return; }
|
|
1128
|
+
try {
|
|
1129
|
+
await api('POST',`/projects/${state.currentProject!.id}/pm/learnings/${itemId}`,{text});
|
|
1130
|
+
toast('Learning recorded','success');
|
|
1131
|
+
openItemDetail(itemId);
|
|
1132
|
+
} catch(err: unknown) { toast(err instanceof Error ? err.message : String(err),'error'); }
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
export async function addTest(itemId: string): Promise<void> {
|
|
1136
|
+
const cmdEl = document.getElementById('new-test-cmd') as HTMLInputElement | null;
|
|
1137
|
+
const descEl = document.getElementById('new-test-desc') as HTMLInputElement | null;
|
|
1138
|
+
const command = cmdEl?.value?.trim() || '';
|
|
1139
|
+
const description = descEl?.value?.trim() || '';
|
|
1140
|
+
if (!command) { toast('Test command is required','error'); return; }
|
|
1141
|
+
try {
|
|
1142
|
+
await api('POST',`/projects/${state.currentProject!.id}/pm/tests/${itemId}`,{command,description});
|
|
1143
|
+
toast('Test added','success');
|
|
1144
|
+
openItemDetail(itemId);
|
|
1145
|
+
} catch(err: unknown) { toast(err instanceof Error ? err.message : String(err),'error'); }
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
export async function addFileLink(itemId: string): Promise<void> {
|
|
1149
|
+
const el = document.getElementById('file-path-input') as HTMLInputElement | null;
|
|
1150
|
+
const filePath = el?.value?.trim() || '';
|
|
1151
|
+
if (!filePath) { toast('File path is required','error'); return; }
|
|
1152
|
+
try {
|
|
1153
|
+
await api('POST',`/projects/${state.currentProject!.id}/pm/files/${itemId}`,{path:filePath});
|
|
1154
|
+
toast('File linked','success');
|
|
1155
|
+
openItemDetail(itemId);
|
|
1156
|
+
} catch(err: unknown) { toast(err instanceof Error ? err.message : String(err),'error'); }
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1160
|
+
// USE ITEM AS TEMPLATE
|
|
1161
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1162
|
+
export function useItemAsTemplate(item: Record<string, unknown>): void {
|
|
1163
|
+
// Close the item detail modal and navigate to create view
|
|
1164
|
+
hideModal('item-detail-modal');
|
|
1165
|
+
showView('create');
|
|
1166
|
+
// Give the create view time to render, then fill fields
|
|
1167
|
+
setTimeout(() => {
|
|
1168
|
+
const setVal = (id: string, val: string | undefined) => {
|
|
1169
|
+
if (!val) return;
|
|
1170
|
+
const el = document.getElementById(id) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | null;
|
|
1171
|
+
if (el) el.value = val;
|
|
1172
|
+
};
|
|
1173
|
+
// Pre-fill create form from item fields (title gets "Copy of …" prefix)
|
|
1174
|
+
const origTitle = String(item['title'] || '');
|
|
1175
|
+
setVal('ci-title', origTitle ? `Copy of ${origTitle}` : '');
|
|
1176
|
+
setVal('ci-type', String(item['type'] || ''));
|
|
1177
|
+
setVal('ci-priority', String(item['priority'] || ''));
|
|
1178
|
+
const tags = Array.isArray(item['tags']) ? (item['tags'] as string[]).join(', ') : String(item['tags'] || '');
|
|
1179
|
+
setVal('ci-tags', tags);
|
|
1180
|
+
setVal('ci-desc', String(item['description'] || ''));
|
|
1181
|
+
setVal('ci-sprint', String(item['sprint'] || ''));
|
|
1182
|
+
setVal('ci-release', String(item['release'] || ''));
|
|
1183
|
+
setVal('ci-assignee', String(item['assignee'] || ''));
|
|
1184
|
+
if (item['acceptance_criteria'] || item['acceptanceCriteria']) {
|
|
1185
|
+
setVal('ci-acceptance-criteria', String(item['acceptance_criteria'] || item['acceptanceCriteria'] || ''));
|
|
1186
|
+
}
|
|
1187
|
+
if (item['body']) {
|
|
1188
|
+
setVal('ci-body', String(item['body']));
|
|
1189
|
+
}
|
|
1190
|
+
document.getElementById('ci-title')?.focus();
|
|
1191
|
+
// Select all title text so user can immediately replace or refine
|
|
1192
|
+
const titleEl = document.getElementById('ci-title') as HTMLInputElement | null;
|
|
1193
|
+
titleEl?.select();
|
|
1194
|
+
toast('Create form pre-filled from item', 'success');
|
|
1195
|
+
}, 150);
|
|
1196
|
+
}
|