@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.
Files changed (150) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +107 -0
  3. package/dist/auth.js +20 -0
  4. package/dist/auth.js.map +1 -0
  5. package/dist/crypto.js +42 -0
  6. package/dist/crypto.js.map +1 -0
  7. package/dist/db.js +111 -0
  8. package/dist/db.js.map +1 -0
  9. package/dist/index.js +88 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/middleware/auth.js +16 -0
  12. package/dist/middleware/auth.js.map +1 -0
  13. package/dist/routes/admin.js +207 -0
  14. package/dist/routes/admin.js.map +1 -0
  15. package/dist/routes/auth.js +163 -0
  16. package/dist/routes/auth.js.map +1 -0
  17. package/dist/routes/github.js +354 -0
  18. package/dist/routes/github.js.map +1 -0
  19. package/dist/routes/groups.js +180 -0
  20. package/dist/routes/groups.js.map +1 -0
  21. package/dist/routes/pm.js +2446 -0
  22. package/dist/routes/pm.js.map +1 -0
  23. package/dist/routes/projects.js +151 -0
  24. package/dist/routes/projects.js.map +1 -0
  25. package/dist/routes/sharing.js +155 -0
  26. package/dist/routes/sharing.js.map +1 -0
  27. package/dist/server.js +64 -0
  28. package/dist/server.js.map +1 -0
  29. package/dist/services/pm-runner.js +190 -0
  30. package/dist/services/pm-runner.js.map +1 -0
  31. package/dist/services/sse.js +111 -0
  32. package/dist/services/sse.js.map +1 -0
  33. package/manifest.json +15 -0
  34. package/package.json +111 -0
  35. package/public/icons/icon-192.png +0 -0
  36. package/public/icons/icon-512.png +0 -0
  37. package/public/index.html +265 -0
  38. package/public/manifest.json +66 -0
  39. package/public/src/api.js +28 -0
  40. package/public/src/api.js.map +1 -0
  41. package/public/src/api.ts +29 -0
  42. package/public/src/app.js +926 -0
  43. package/public/src/app.js.map +1 -0
  44. package/public/src/app.ts +929 -0
  45. package/public/src/components/modals.js +62 -0
  46. package/public/src/components/modals.js.map +1 -0
  47. package/public/src/components/modals.ts +73 -0
  48. package/public/src/components/toast.js +10 -0
  49. package/public/src/components/toast.js.map +1 -0
  50. package/public/src/components/toast.ts +13 -0
  51. package/public/src/constants.js +30 -0
  52. package/public/src/constants.js.map +1 -0
  53. package/public/src/constants.ts +41 -0
  54. package/public/src/state.js +15 -0
  55. package/public/src/state.js.map +1 -0
  56. package/public/src/state.ts +19 -0
  57. package/public/src/types.js +5 -0
  58. package/public/src/types.js.map +1 -0
  59. package/public/src/types.ts +253 -0
  60. package/public/src/utils.js +57 -0
  61. package/public/src/utils.js.map +1 -0
  62. package/public/src/utils.ts +56 -0
  63. package/public/src/views/activity.js +47 -0
  64. package/public/src/views/activity.js.map +1 -0
  65. package/public/src/views/activity.ts +41 -0
  66. package/public/src/views/admin.js +435 -0
  67. package/public/src/views/admin.js.map +1 -0
  68. package/public/src/views/admin.ts +504 -0
  69. package/public/src/views/auth.js +81 -0
  70. package/public/src/views/auth.js.map +1 -0
  71. package/public/src/views/auth.ts +74 -0
  72. package/public/src/views/calendar.js +133 -0
  73. package/public/src/views/calendar.js.map +1 -0
  74. package/public/src/views/calendar.ts +129 -0
  75. package/public/src/views/comments-audit.js +109 -0
  76. package/public/src/views/comments-audit.js.map +1 -0
  77. package/public/src/views/comments-audit.ts +108 -0
  78. package/public/src/views/config.js +322 -0
  79. package/public/src/views/config.js.map +1 -0
  80. package/public/src/views/config.ts +344 -0
  81. package/public/src/views/context.js +98 -0
  82. package/public/src/views/context.js.map +1 -0
  83. package/public/src/views/context.ts +100 -0
  84. package/public/src/views/create.js +293 -0
  85. package/public/src/views/create.js.map +1 -0
  86. package/public/src/views/create.ts +246 -0
  87. package/public/src/views/dedupe.js +51 -0
  88. package/public/src/views/dedupe.js.map +1 -0
  89. package/public/src/views/dedupe.ts +43 -0
  90. package/public/src/views/export.js +300 -0
  91. package/public/src/views/export.js.map +1 -0
  92. package/public/src/views/export.ts +274 -0
  93. package/public/src/views/github.js +360 -0
  94. package/public/src/views/github.js.map +1 -0
  95. package/public/src/views/github.ts +308 -0
  96. package/public/src/views/graph-canvas.js +1986 -0
  97. package/public/src/views/graph-canvas.js.map +1 -0
  98. package/public/src/views/graph-canvas.ts +2218 -0
  99. package/public/src/views/graph.js +1824 -0
  100. package/public/src/views/graph.js.map +1 -0
  101. package/public/src/views/graph.ts +1891 -0
  102. package/public/src/views/groups.js +186 -0
  103. package/public/src/views/groups.js.map +1 -0
  104. package/public/src/views/groups.ts +172 -0
  105. package/public/src/views/guide.js +151 -0
  106. package/public/src/views/guide.js.map +1 -0
  107. package/public/src/views/guide.ts +162 -0
  108. package/public/src/views/health.js +105 -0
  109. package/public/src/views/health.js.map +1 -0
  110. package/public/src/views/health.ts +102 -0
  111. package/public/src/views/items.js +1306 -0
  112. package/public/src/views/items.js.map +1 -0
  113. package/public/src/views/items.ts +1196 -0
  114. package/public/src/views/normalize.js +67 -0
  115. package/public/src/views/normalize.js.map +1 -0
  116. package/public/src/views/normalize.ts +58 -0
  117. package/public/src/views/plan.js +454 -0
  118. package/public/src/views/plan.js.map +1 -0
  119. package/public/src/views/plan.ts +496 -0
  120. package/public/src/views/projects.js +204 -0
  121. package/public/src/views/projects.js.map +1 -0
  122. package/public/src/views/projects.ts +196 -0
  123. package/public/src/views/router.js +227 -0
  124. package/public/src/views/router.js.map +1 -0
  125. package/public/src/views/router.ts +188 -0
  126. package/public/src/views/search.js +103 -0
  127. package/public/src/views/search.js.map +1 -0
  128. package/public/src/views/search.ts +94 -0
  129. package/public/src/views/settings.js +272 -0
  130. package/public/src/views/settings.js.map +1 -0
  131. package/public/src/views/settings.ts +190 -0
  132. package/public/src/views/shared.js +49 -0
  133. package/public/src/views/shared.js.map +1 -0
  134. package/public/src/views/shared.ts +49 -0
  135. package/public/src/views/sharing.js +152 -0
  136. package/public/src/views/sharing.js.map +1 -0
  137. package/public/src/views/sharing.ts +139 -0
  138. package/public/src/views/stats.js +92 -0
  139. package/public/src/views/stats.js.map +1 -0
  140. package/public/src/views/stats.ts +88 -0
  141. package/public/src/views/templates.js +117 -0
  142. package/public/src/views/templates.js.map +1 -0
  143. package/public/src/views/templates.ts +113 -0
  144. package/public/src/views/validate.js +54 -0
  145. package/public/src/views/validate.js.map +1 -0
  146. package/public/src/views/validate.ts +48 -0
  147. package/public/styles.css +2231 -0
  148. package/public/sw.js +318 -0
  149. package/public/tsconfig.json +20 -0
  150. package/sql/schema.sql +105 -0
@@ -0,0 +1,496 @@
1
+ // ═══════════════════════════════════════════════════════════════
2
+ // PLAN VIEW
3
+ // ═══════════════════════════════════════════════════════════════
4
+ import { state } from '../state.js';
5
+ import { api } from '../api.js';
6
+ import { escHtml } from '../utils.js';
7
+ import { toast } from '../components/toast.js';
8
+ import { showModal, hideModal, createModal, confirmDialog } from '../components/modals.js';
9
+
10
+ // ─── Types ───────────────────────────────────────────────────
11
+
12
+ type PlanStep = {
13
+ id?: string;
14
+ ref?: string;
15
+ title?: string;
16
+ description?: string;
17
+ status?: string;
18
+ blockedReason?: string;
19
+ blocked_reason?: string;
20
+ dependsOn?: string[];
21
+ depends_on?: string[];
22
+ };
23
+
24
+ type PlanData = {
25
+ id?: string;
26
+ title?: string;
27
+ description?: string;
28
+ scope?: string;
29
+ status?: string;
30
+ tags?: string[];
31
+ priority?: number;
32
+ steps?: PlanStep[];
33
+ approvedAt?: string;
34
+ approved_at?: string;
35
+ createdAt?: string;
36
+ created_at?: string;
37
+ };
38
+
39
+ // ─── State ───────────────────────────────────────────────────
40
+
41
+ let currentPlanId: string | null = null;
42
+
43
+ // ─── Helpers ─────────────────────────────────────────────────
44
+
45
+ function stepRef(step: PlanStep): string {
46
+ return step.ref || step.id || '';
47
+ }
48
+
49
+ function stepStatusBadge(status?: string): string {
50
+ const s = (status || 'pending').toLowerCase();
51
+ const colors: Record<string, string> = {
52
+ done: 'var(--status-closed)',
53
+ completed: 'var(--status-closed)',
54
+ blocked: 'var(--status-blocked)',
55
+ in_progress: 'var(--status-in-progress)',
56
+ pending: 'var(--text-muted)',
57
+ open: 'var(--accent)',
58
+ };
59
+ const color = colors[s] || 'var(--text-muted)';
60
+ return `<span style="font-size:11px;padding:2px 7px;border-radius:4px;background:color-mix(in srgb,${color} 18%,transparent);color:${color};font-weight:600;letter-spacing:.3px">${escHtml(s)}</span>`;
61
+ }
62
+
63
+ function renderStepRow(step: PlanStep, planId: string): string {
64
+ const ref = stepRef(step);
65
+ const isDone = ['done', 'completed'].includes((step.status || '').toLowerCase());
66
+ const isBlocked = (step.status || '').toLowerCase() === 'blocked';
67
+ return `
68
+ <div class="plan-step-row" data-step-ref="${escHtml(ref)}">
69
+ <div style="flex:1;min-width:0">
70
+ <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
71
+ <span style="font-size:12px;color:var(--text-muted);font-family:'JetBrains Mono',monospace">${escHtml(ref)}</span>
72
+ <span style="font-size:13px;font-weight:500;${isDone ? 'text-decoration:line-through;color:var(--text-muted)' : ''}">${escHtml(step.title || '(untitled)')}</span>
73
+ ${stepStatusBadge(step.status)}
74
+ </div>
75
+ ${step.description ? `<div style="font-size:12px;color:var(--text-muted);margin-top:3px">${escHtml(step.description)}</div>` : ''}
76
+ ${isBlocked && (step.blockedReason || step.blocked_reason) ? `<div style="font-size:12px;color:var(--status-blocked);margin-top:3px">Blocked: ${escHtml(step.blockedReason || step.blocked_reason || '')}</div>` : ''}
77
+ </div>
78
+ <div style="display:flex;gap:6px;flex-shrink:0">
79
+ ${!isDone ? `<button class="btn btn-ghost btn-sm" onclick="window.__app.planCompleteStep('${escHtml(planId)}','${escHtml(ref)}')" title="Mark complete">✓</button>` : ''}
80
+ ${!isBlocked && !isDone ? `<button class="btn btn-ghost btn-sm" onclick="window.__app.planBlockStepPrompt('${escHtml(planId)}','${escHtml(ref)}')" title="Block">⊘</button>` : ''}
81
+ <button class="btn btn-ghost btn-sm" onclick="window.__app.planRemoveStep('${escHtml(planId)}','${escHtml(ref)}')" title="Remove" style="color:var(--danger,#f87171)">✕</button>
82
+ </div>
83
+ </div>`;
84
+ }
85
+
86
+ // ─── Render ──────────────────────────────────────────────────
87
+
88
+ export function renderPlanView(): string {
89
+ return `
90
+ <div class="page-header">
91
+ <div>
92
+ <div class="page-title">Plans</div>
93
+ <div class="page-subtitle" id="plan-subtitle">Loading…</div>
94
+ </div>
95
+ <div class="page-actions">
96
+ <button class="btn btn-primary btn-sm" onclick="window.__app.openCreatePlanModal()">+ New Plan</button>
97
+ <button class="btn btn-secondary btn-sm" onclick="window.__app.initPlanView()">↺ Refresh</button>
98
+ </div>
99
+ </div>
100
+ <div class="plan-layout">
101
+ <div class="plan-list-panel">
102
+ <div id="plan-list-panel"><div class="loading-state"><div class="loading-spinner"></div></div></div>
103
+ </div>
104
+ <div class="plan-detail-panel">
105
+ <div id="plan-detail-panel">
106
+ <div class="empty-state"><div class="empty-state-text">Select a plan to view its steps</div></div>
107
+ </div>
108
+ </div>
109
+ </div>`;
110
+ }
111
+
112
+ export async function initPlanView(): Promise<void> {
113
+ const el = document.getElementById('content-plan');
114
+ if (!el) return;
115
+ if (!state.currentProject) {
116
+ el.innerHTML = '<div class="empty-state"><div class="empty-state-text">No project selected</div></div>';
117
+ return;
118
+ }
119
+ el.innerHTML = renderPlanView();
120
+ await loadPlanList();
121
+ }
122
+
123
+ async function loadPlanList(): Promise<void> {
124
+ const listEl = document.getElementById('plan-list-panel');
125
+ const subEl = document.getElementById('plan-subtitle');
126
+ if (!listEl || !state.currentProject) return;
127
+
128
+ listEl.innerHTML = '<div class="loading-state"><div class="loading-spinner"></div></div>';
129
+
130
+ try {
131
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
132
+ const data = await api('GET', `/projects/${state.currentProject.id}/pm/list-all?type=Plan`) as any;
133
+ const items = (data.items || []) as PlanData[];
134
+ if (subEl) subEl.textContent = state.currentProject.name;
135
+
136
+ if (items.length === 0) {
137
+ listEl.innerHTML = '<div class="empty-state" style="padding:16px"><div class="empty-state-text">No plans yet</div></div>';
138
+ return;
139
+ }
140
+
141
+ listEl.innerHTML = `<div class="card" style="padding:0">
142
+ ${items.map(item => `
143
+ <div class="sidebar-item" style="padding:10px 12px;border-bottom:1px solid var(--border);cursor:pointer"
144
+ onclick="window.__app.openPlanDetail('${escHtml(item.id || '')}')"
145
+ data-plan-id="${escHtml(item.id || '')}">
146
+ <div style="font-size:13px;font-weight:500">${escHtml(item.title || '(untitled)')}</div>
147
+ <div style="font-size:11px;color:var(--text-muted);margin-top:2px;display:flex;gap:6px;align-items:center">
148
+ <span style="font-family:'JetBrains Mono',monospace">${escHtml(item.id || '')}</span>
149
+ ${stepStatusBadge(item.status)}
150
+ </div>
151
+ </div>`).join('')}
152
+ </div>`;
153
+ } catch(err: unknown) {
154
+ listEl.innerHTML = `<div class="empty-state" style="padding:16px"><div class="empty-state-text">Error: ${escHtml(err instanceof Error ? err.message : String(err))}</div></div>`;
155
+ }
156
+ }
157
+
158
+ export async function openPlanDetail(planId: string): Promise<void> {
159
+ currentPlanId = planId;
160
+
161
+ // Highlight selected in list
162
+ document.querySelectorAll('#plan-list-panel [data-plan-id]').forEach(el => {
163
+ (el as HTMLElement).style.background = (el as HTMLElement).dataset.planId === planId ? 'var(--bg-elevated)' : '';
164
+ });
165
+
166
+ const detailEl = document.getElementById('plan-detail-panel');
167
+ if (!detailEl || !state.currentProject) return;
168
+ detailEl.innerHTML = '<div class="loading-state"><div class="loading-spinner"></div></div>';
169
+
170
+ try {
171
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
172
+ const data = await api('GET', `/projects/${state.currentProject.id}/pm/plan/${encodeURIComponent(planId)}`) as any;
173
+ const plan = (data.plan || data) as PlanData;
174
+ const steps = plan.steps || [];
175
+
176
+ const isApproved = !!(plan.approvedAt || plan.approved_at);
177
+
178
+ detailEl.innerHTML = `
179
+ <div class="card">
180
+ <div class="card-header" style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px">
181
+ <div>
182
+ <div class="card-title">${escHtml(plan.title || planId)}</div>
183
+ <div style="font-size:12px;color:var(--text-muted);margin-top:2px;font-family:'JetBrains Mono',monospace">${escHtml(plan.id || planId)}</div>
184
+ </div>
185
+ <div style="display:flex;gap:8px;flex-wrap:wrap">
186
+ ${!isApproved ? `<button class="btn btn-secondary btn-sm" onclick="window.__app.planApprove('${escHtml(planId)}')">✓ Approve</button>` : '<span style="font-size:12px;color:var(--status-closed)">✓ Approved</span>'}
187
+ <button class="btn btn-primary btn-sm" onclick="window.__app.planMaterializePrompt('${escHtml(planId)}')">⇗ Materialize</button>
188
+ <button class="btn btn-ghost btn-sm" onclick="window.__app.planEditPrompt('${escHtml(planId)}','${escHtml(plan.title||'')}')" title="Edit plan">✎</button>
189
+ <button class="btn btn-ghost btn-sm" style="color:var(--danger,#f87171)" onclick="window.__app.planDeletePrompt('${escHtml(planId)}')" title="Delete plan">✕</button>
190
+ </div>
191
+ </div>
192
+ <div class="card-body">
193
+ ${plan.description ? `<div style="font-size:13px;color:var(--text-secondary);margin-bottom:12px">${escHtml(plan.description)}</div>` : ''}
194
+ ${plan.scope ? `<div style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Scope: ${escHtml(plan.scope)}</div>` : ''}
195
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
196
+ <div style="font-size:13px;font-weight:600">Steps (${steps.length})</div>
197
+ <button class="btn btn-ghost btn-sm" onclick="window.__app.openAddStepModal('${escHtml(planId)}')">+ Add Step</button>
198
+ </div>
199
+ <div id="plan-steps-list">
200
+ ${steps.length === 0
201
+ ? '<div style="font-size:13px;color:var(--text-muted);padding:8px 0">No steps yet. Add the first step to get started.</div>'
202
+ : steps.map(s => renderStepRow(s, planId)).join('')}
203
+ </div>
204
+ </div>
205
+ </div>`;
206
+ } catch(err: unknown) {
207
+ detailEl.innerHTML = `<div class="empty-state"><div class="empty-state-text">Error: ${escHtml(err instanceof Error ? err.message : String(err))}</div></div>`;
208
+ }
209
+ }
210
+
211
+ // ─── Modal builders ──────────────────────────────────────────
212
+
213
+ export function openCreatePlanModal(): void {
214
+ if (!state.currentProject) { toast('Select a project first', 'info'); return; }
215
+ createModal('create-plan-modal', 'New Plan', `
216
+ <div class="form-group">
217
+ <label class="form-label">Title *</label>
218
+ <input class="form-input" id="plan-title" type="text" placeholder="Plan title" autofocus required>
219
+ </div>
220
+ <div class="form-group">
221
+ <label class="form-label">Description</label>
222
+ <textarea class="form-textarea" id="plan-desc" rows="3" placeholder="What is this plan for?"></textarea>
223
+ </div>
224
+ <div class="form-group">
225
+ <label class="form-label">Scope</label>
226
+ <input class="form-input" id="plan-scope" type="text" placeholder="e.g. backend, v2, sprint-3">
227
+ </div>
228
+ <div class="two-col">
229
+ <div class="form-group">
230
+ <label class="form-label">Tags</label>
231
+ <input class="form-input" id="plan-tags" type="text" placeholder="comma, separated">
232
+ </div>
233
+ <div class="form-group">
234
+ <label class="form-label">Priority</label>
235
+ <select class="form-select" id="plan-priority">
236
+ <option value="">Default</option>
237
+ <option value="1">P1: Critical</option>
238
+ <option value="2">P2: High</option>
239
+ <option value="3" selected>P3: Medium</option>
240
+ <option value="4">P4: Low</option>
241
+ <option value="5">P5: Minimal</option>
242
+ </select>
243
+ </div>
244
+ </div>`,
245
+ `<button class="btn btn-primary" onclick="window.__app.submitCreatePlan()">Create Plan</button>
246
+ <button class="btn btn-ghost" onclick="window.__app.hideModal('create-plan-modal')">Cancel</button>`);
247
+ showModal('create-plan-modal');
248
+ }
249
+
250
+ export async function submitCreatePlan(): Promise<void> {
251
+ if (!state.currentProject) return;
252
+ const title = (document.getElementById('plan-title') as HTMLInputElement | null)?.value?.trim();
253
+ if (!title) { toast('Title is required', 'error'); return; }
254
+
255
+ const description = (document.getElementById('plan-desc') as HTMLTextAreaElement | null)?.value?.trim() || '';
256
+ const scope = (document.getElementById('plan-scope') as HTMLInputElement | null)?.value?.trim() || '';
257
+ const tags = (document.getElementById('plan-tags') as HTMLInputElement | null)?.value?.trim() || '';
258
+ const priority = (document.getElementById('plan-priority') as HTMLSelectElement | null)?.value || '';
259
+
260
+ const body: Record<string, string> = { title };
261
+ if (description) body.description = description;
262
+ if (scope) body.scope = scope;
263
+ if (tags) body.tags = tags;
264
+ if (priority) body.priority = priority;
265
+
266
+ try {
267
+ await api('POST', `/projects/${state.currentProject.id}/pm/plan`, body);
268
+ hideModal('create-plan-modal');
269
+ toast('Plan created', 'success');
270
+ await loadPlanList();
271
+ } catch(err: unknown) {
272
+ toast(err instanceof Error ? err.message : 'Failed to create plan', 'error');
273
+ }
274
+ }
275
+
276
+ export function openAddStepModal(planId: string): void {
277
+ createModal('add-step-modal', 'Add Step', `
278
+ <div class="form-group">
279
+ <label class="form-label">Title *</label>
280
+ <input class="form-input" id="step-title" type="text" placeholder="Step title" autofocus required>
281
+ </div>
282
+ <div class="form-group">
283
+ <label class="form-label">Description</label>
284
+ <textarea class="form-textarea" id="step-desc" rows="2" placeholder="Optional step description"></textarea>
285
+ </div>
286
+ <div class="form-group">
287
+ <label class="form-label">Depends On (step refs, comma-separated)</label>
288
+ <input class="form-input" id="step-depends" type="text" placeholder="e.g. step-001,step-002">
289
+ </div>`,
290
+ `<button class="btn btn-primary" onclick="window.__app.submitAddStep('${escHtml(planId)}')">Add Step</button>
291
+ <button class="btn btn-ghost" onclick="window.__app.hideModal('add-step-modal')">Cancel</button>`);
292
+ showModal('add-step-modal');
293
+ }
294
+
295
+ export async function submitAddStep(planId: string): Promise<void> {
296
+ if (!state.currentProject) return;
297
+ const title = (document.getElementById('step-title') as HTMLInputElement | null)?.value?.trim();
298
+ if (!title) { toast('Title is required', 'error'); return; }
299
+
300
+ const description = (document.getElementById('step-desc') as HTMLTextAreaElement | null)?.value?.trim() || '';
301
+ const dependsOn = (document.getElementById('step-depends') as HTMLInputElement | null)?.value?.trim() || '';
302
+
303
+ const body: Record<string, string> = { title };
304
+ if (description) body.description = description;
305
+ if (dependsOn) body.dependsOn = dependsOn;
306
+
307
+ try {
308
+ await api('POST', `/projects/${state.currentProject.id}/pm/plan/${encodeURIComponent(planId)}/steps`, body);
309
+ hideModal('add-step-modal');
310
+ toast('Step added', 'success');
311
+ await openPlanDetail(planId);
312
+ } catch(err: unknown) {
313
+ toast(err instanceof Error ? err.message : 'Failed to add step', 'error');
314
+ }
315
+ }
316
+
317
+ export async function planCompleteStep(planId: string, stepRef: string): Promise<void> {
318
+ if (!state.currentProject) return;
319
+ try {
320
+ await api('POST', `/projects/${state.currentProject.id}/pm/plan/${encodeURIComponent(planId)}/steps/${encodeURIComponent(stepRef)}/complete`, {});
321
+ toast('Step completed', 'success');
322
+ await openPlanDetail(planId);
323
+ } catch(err: unknown) {
324
+ toast(err instanceof Error ? err.message : 'Failed to complete step', 'error');
325
+ }
326
+ }
327
+
328
+ export function planBlockStepPrompt(planId: string, stepRef: string): void {
329
+ createModal('block-step-modal', 'Block Step', `
330
+ <div class="form-group">
331
+ <label class="form-label">Reason *</label>
332
+ <input class="form-input" id="block-reason" type="text" placeholder="Why is this step blocked?" autofocus required>
333
+ </div>`,
334
+ `<button class="btn btn-primary" onclick="window.__app.submitBlockStep('${escHtml(planId)}','${escHtml(stepRef)}')">Block Step</button>
335
+ <button class="btn btn-ghost" onclick="window.__app.hideModal('block-step-modal')">Cancel</button>`);
336
+ showModal('block-step-modal');
337
+ }
338
+
339
+ export async function submitBlockStep(planId: string, stepRef: string): Promise<void> {
340
+ if (!state.currentProject) return;
341
+ const reason = (document.getElementById('block-reason') as HTMLInputElement | null)?.value?.trim();
342
+ if (!reason) { toast('Reason is required', 'error'); return; }
343
+
344
+ try {
345
+ await api('POST', `/projects/${state.currentProject.id}/pm/plan/${encodeURIComponent(planId)}/steps/${encodeURIComponent(stepRef)}/block`, { reason });
346
+ hideModal('block-step-modal');
347
+ toast('Step blocked', 'success');
348
+ await openPlanDetail(planId);
349
+ } catch(err: unknown) {
350
+ toast(err instanceof Error ? err.message : 'Failed to block step', 'error');
351
+ }
352
+ }
353
+
354
+ export function planRemoveStep(planId: string, stepRef: string): void {
355
+ confirmDialog(
356
+ 'Remove step',
357
+ `Remove step ${stepRef} from plan?`,
358
+ async () => {
359
+ if (!state.currentProject) return;
360
+ try {
361
+ await api('DELETE', `/projects/${state.currentProject.id}/pm/plan/${encodeURIComponent(planId)}/steps/${encodeURIComponent(stepRef)}`, {});
362
+ toast('Step removed', 'success');
363
+ await openPlanDetail(planId);
364
+ } catch(err: unknown) {
365
+ toast(err instanceof Error ? err.message : 'Failed to remove step', 'error');
366
+ }
367
+ },
368
+ true
369
+ );
370
+ }
371
+
372
+ export function planApprove(planId: string): void {
373
+ confirmDialog(
374
+ 'Approve plan',
375
+ `Approve plan ${planId}? Once approved, it can be materialized.`,
376
+ async () => {
377
+ if (!state.currentProject) return;
378
+ try {
379
+ await api('POST', `/projects/${state.currentProject.id}/pm/plan/${encodeURIComponent(planId)}/approve`, {});
380
+ toast('Plan approved', 'success');
381
+ await openPlanDetail(planId);
382
+ } catch(err: unknown) {
383
+ toast(err instanceof Error ? err.message : 'Failed to approve plan', 'error');
384
+ }
385
+ }
386
+ );
387
+ }
388
+
389
+ export function planMaterializePrompt(planId: string): void {
390
+ createModal('materialize-plan-modal', 'Materialize Plan', `
391
+ <p style="font-size:13px;color:var(--text-secondary);margin-bottom:12px">Materializing creates real project items from plan steps.</p>
392
+ <div class="form-group">
393
+ <label class="form-label">Item Type</label>
394
+ <select class="form-select" id="mat-type">
395
+ <option value="">Default (Task)</option>
396
+ <option value="Task">Task</option>
397
+ <option value="Feature">Feature</option>
398
+ <option value="Issue">Issue</option>
399
+ <option value="Chore">Chore</option>
400
+ </select>
401
+ </div>
402
+ <div class="form-group">
403
+ <label class="form-label">Parent Item ID (optional)</label>
404
+ <input class="form-input" id="mat-parent" type="text" placeholder="e.g. PROJ-5">
405
+ </div>
406
+ <div class="form-group">
407
+ <label class="form-label">Steps to materialize (comma-separated, leave blank for all)</label>
408
+ <input class="form-input" id="mat-steps" type="text" placeholder="e.g. step-001,step-002">
409
+ </div>`,
410
+ `<button class="btn btn-primary" onclick="window.__app.submitMaterializePlan('${escHtml(planId)}')">Materialize</button>
411
+ <button class="btn btn-ghost" onclick="window.__app.hideModal('materialize-plan-modal')">Cancel</button>`);
412
+ showModal('materialize-plan-modal');
413
+ }
414
+
415
+ export async function submitMaterializePlan(planId: string): Promise<void> {
416
+ if (!state.currentProject) return;
417
+ const materializeType = (document.getElementById('mat-type') as HTMLSelectElement | null)?.value || '';
418
+ const materializeParent = (document.getElementById('mat-parent') as HTMLInputElement | null)?.value?.trim() || '';
419
+ const steps = (document.getElementById('mat-steps') as HTMLInputElement | null)?.value?.trim() || '';
420
+
421
+ const body: Record<string, string> = {};
422
+ if (materializeType) body.materializeType = materializeType;
423
+ if (materializeParent) body.materializeParent = materializeParent;
424
+ if (steps) body.steps = steps;
425
+
426
+ try {
427
+ await api('POST', `/projects/${state.currentProject.id}/pm/plan/${encodeURIComponent(planId)}/materialize`, body);
428
+ hideModal('materialize-plan-modal');
429
+ toast('Plan materialized — items created. Switching to Items view.', 'success');
430
+ // Navigate to items view so user can see the created items
431
+ setTimeout(() => (window as any).__app?.showView('items'), 1200);
432
+ } catch(err: unknown) {
433
+ toast(err instanceof Error ? err.message : 'Failed to materialize plan', 'error');
434
+ }
435
+ }
436
+
437
+ export function planEditPrompt(planId: string, currentTitle: string): void {
438
+ createModal('edit-plan-modal', 'Edit Plan', `
439
+ <div class="form-group">
440
+ <label class="form-label">Title</label>
441
+ <input class="form-input" id="edit-plan-title" type="text" value="${escHtml(currentTitle)}" autofocus>
442
+ </div>
443
+ <div class="form-group">
444
+ <label class="form-label">Description</label>
445
+ <textarea class="form-textarea" id="edit-plan-desc" rows="3" placeholder="Plan description"></textarea>
446
+ </div>`,
447
+ `<button class="btn btn-primary" onclick="window.__app.submitEditPlan('${escHtml(planId)}')">Save</button>
448
+ <button class="btn btn-ghost" onclick="window.__app.hideModal('edit-plan-modal')">Cancel</button>`);
449
+ showModal('edit-plan-modal');
450
+ // Populate description async after modal shows
451
+ api('GET', `/projects/${state.currentProject!.id}/pm/plan/${encodeURIComponent(planId)}`).then((data: any) => {
452
+ const desc = data?.plan?.description || data?.description || '';
453
+ const el = document.getElementById('edit-plan-desc') as HTMLTextAreaElement | null;
454
+ if (el) el.value = desc;
455
+ }).catch(() => {});
456
+ }
457
+
458
+ export async function submitEditPlan(planId: string): Promise<void> {
459
+ if (!state.currentProject) return;
460
+ const title = (document.getElementById('edit-plan-title') as HTMLInputElement | null)?.value?.trim();
461
+ const description = (document.getElementById('edit-plan-desc') as HTMLTextAreaElement | null)?.value?.trim() || '';
462
+ if (!title) { toast('Title is required', 'error'); return; }
463
+ try {
464
+ await api('PATCH', `/projects/${state.currentProject.id}/pm/plan/${encodeURIComponent(planId)}`, { title, description });
465
+ hideModal('edit-plan-modal');
466
+ toast('Plan updated', 'success');
467
+ await loadPlanList();
468
+ await openPlanDetail(planId);
469
+ } catch(err: unknown) {
470
+ toast(err instanceof Error ? err.message : 'Failed to update plan', 'error');
471
+ }
472
+ }
473
+
474
+ export function planDeletePrompt(planId: string): void {
475
+ confirmDialog(
476
+ 'Delete plan',
477
+ `Delete plan ${planId} and all its steps? This cannot be undone.`,
478
+ async () => {
479
+ if (!state.currentProject) return;
480
+ try {
481
+ await api('DELETE', `/projects/${state.currentProject.id}/pm/plan/${encodeURIComponent(planId)}`, {});
482
+ toast('Plan deleted', 'success');
483
+ currentPlanId = null;
484
+ const detailEl = document.getElementById('plan-detail-panel');
485
+ if (detailEl) detailEl.innerHTML = '<div class="empty-state"><div class="empty-state-text">Select a plan to view its steps</div></div>';
486
+ await loadPlanList();
487
+ } catch(err: unknown) {
488
+ toast(err instanceof Error ? err.message : 'Failed to delete plan', 'error');
489
+ }
490
+ },
491
+ true
492
+ );
493
+ }
494
+
495
+ // Expose currentPlanId for potential external use
496
+ export function getCurrentPlanId(): string | null { return currentPlanId; }