popilot 0.7.0 → 0.8.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/package.json +1 -1
- package/scaffold/mcp-notification-server/package.json +18 -0
- package/scaffold/mcp-notification-server/src/index.ts +275 -0
- package/scaffold/mcp-notification-server/src/turso-client.ts +142 -0
- package/scaffold/mcp-notification-server/tsconfig.json +14 -0
- package/scaffold/pm-api/sql/001-memo-v2.sql +49 -0
- package/scaffold/pm-api/sql/002-notifications.sql +18 -0
- package/scaffold/pm-api/sql/003-content.sql +66 -0
- package/scaffold/pm-api/sql/004-agent-events.sql +21 -0
- package/scaffold/pm-api/sql/005-epic-sprint-decoupling.sql +6 -0
- package/scaffold/pm-api/src/utils/retro-link.ts +32 -0
- package/scaffold/spec-site/package-lock.json +852 -0
- package/scaffold/spec-site/package.json +12 -1
- package/scaffold/spec-site/src/components/AuthGate.vue +117 -0
- package/scaffold/spec-site/src/components/BurndownChart.vue +78 -0
- package/scaffold/spec-site/src/components/DocComments.vue +137 -0
- package/scaffold/spec-site/src/components/DocEditor.vue +118 -0
- package/scaffold/spec-site/src/components/DocExportBar.vue +110 -0
- package/scaffold/spec-site/src/components/DocsSidebar.vue +309 -0
- package/scaffold/spec-site/src/components/EmptyState.vue +30 -0
- package/scaffold/spec-site/src/components/ErrorBanner.vue +38 -0
- package/scaffold/spec-site/src/components/Icon.vue +58 -0
- package/scaffold/spec-site/src/components/MemoChecklist.vue +88 -0
- package/scaffold/spec-site/src/components/MemoGraph.vue +75 -0
- package/scaffold/spec-site/src/components/MemoItem.vue +353 -0
- package/scaffold/spec-site/src/components/MemoRelations.vue +101 -0
- package/scaffold/spec-site/src/components/MemoTimeline.vue +53 -0
- package/scaffold/spec-site/src/components/MentionInput.vue +174 -0
- package/scaffold/spec-site/src/components/PriorityBadge.vue +23 -0
- package/scaffold/spec-site/src/components/SlashCommand.ts +123 -0
- package/scaffold/spec-site/src/components/StateDisplay.vue +54 -0
- package/scaffold/spec-site/src/components/TreeNode.vue +82 -0
- package/scaffold/spec-site/src/components/UserAvatar.vue +24 -0
- package/scaffold/spec-site/src/composables/navTypes.ts +3 -0
- package/scaffold/spec-site/src/composables/useBottomSheet.ts +103 -0
- package/scaffold/spec-site/src/composables/useMemo.ts +39 -0
- package/scaffold/spec-site/src/composables/useTurso.ts +17 -0
- package/scaffold/spec-site/src/composables/useViewport.ts +26 -0
- package/scaffold/spec-site/src/mockup/ComponentPalette.vue +61 -0
- package/scaffold/spec-site/src/mockup/MockupCanvas.vue +459 -0
- package/scaffold/spec-site/src/mockup/PropertyPanel.vue +217 -0
- package/scaffold/spec-site/src/mockup/componentCatalog.ts +68 -0
- package/scaffold/spec-site/src/mockup/useScenarios.ts +67 -0
- package/scaffold/spec-site/src/pages/DocsEditor.vue +119 -0
- package/scaffold/spec-site/src/pages/DocsPage.vue +444 -0
- package/scaffold/spec-site/src/pages/MemosPage.vue +857 -0
- package/scaffold/spec-site/src/pages/MockupEditorPage.vue +611 -0
- package/scaffold/spec-site/src/pages/MockupListPage.vue +121 -0
- package/scaffold/spec-site/src/pages/MockupViewerPage.vue +199 -0
- package/scaffold/spec-site/src/pages/NotificationSettingsPage.vue +59 -0
- package/scaffold/spec-site/src/pages/SprintAdmin.vue +521 -0
- package/scaffold/spec-site/src/pages/SprintTimeline.vue +159 -0
- package/scaffold/spec-site/src/pages/board/KanbanBoard.vue +93 -0
- package/scaffold/spec-site/src/styles/buttons.css +124 -0
- package/scaffold/spec-site/src/utils/parseMentions.ts +56 -0
- package/scaffold/spec-site/src/utils/timezone.ts +18 -0
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed, onMounted } from 'vue'
|
|
3
|
+
import { useRouter } from 'vue-router'
|
|
4
|
+
import {
|
|
5
|
+
sprints, epics, loaded, loadNavData,
|
|
6
|
+
addSprint, updateSprint, deleteSprint, setActiveSprint,
|
|
7
|
+
addEpic, updateEpic, deleteEpic, carryOverEpic,
|
|
8
|
+
type SprintConfig, type PageConfig,
|
|
9
|
+
} from '@/composables/useNavStore'
|
|
10
|
+
|
|
11
|
+
const router = useRouter()
|
|
12
|
+
const statusMsg = ref('')
|
|
13
|
+
const loading = ref(true)
|
|
14
|
+
|
|
15
|
+
// -- Sprint form --
|
|
16
|
+
const showSprintForm = ref(false)
|
|
17
|
+
const sprintForm = ref({ id: '', label: '', theme: '', startDate: '', endDate: '' })
|
|
18
|
+
|
|
19
|
+
// -- Epic form (per sprint) --
|
|
20
|
+
const epicFormSprint = ref<string | null>(null)
|
|
21
|
+
const epicForm = ref({ epicId: '', label: '', badge: '', category: 'policy', description: '' })
|
|
22
|
+
|
|
23
|
+
// -- Inline edit --
|
|
24
|
+
const editingSprint = ref<string | null>(null)
|
|
25
|
+
const editSprintData = ref({ label: '', theme: '', startDate: '', endDate: '' })
|
|
26
|
+
|
|
27
|
+
const editingEpic = ref<string | null>(null) // "sprint:epicId"
|
|
28
|
+
const editEpicData = ref({ label: '', badge: '', category: 'policy', description: '' })
|
|
29
|
+
|
|
30
|
+
// -- Carryover form --
|
|
31
|
+
const carryoverEpic = ref<string | null>(null) // uid
|
|
32
|
+
const carryoverForm = ref({ targetSprint: '', newEpicId: '', newLabel: '', newBadge: '' })
|
|
33
|
+
|
|
34
|
+
function startCarryover(e: PageConfig) {
|
|
35
|
+
const uid = e.uid ?? `${e.sprint}:${e.id}`
|
|
36
|
+
carryoverEpic.value = uid
|
|
37
|
+
carryoverForm.value = { targetSprint: '', newEpicId: '', newLabel: e.label, newBadge: e.badge ?? '' }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function handleCarryover() {
|
|
41
|
+
if (!carryoverEpic.value || !carryoverForm.value.targetSprint || !carryoverForm.value.newEpicId) return
|
|
42
|
+
const f = carryoverForm.value
|
|
43
|
+
const r = await carryOverEpic(
|
|
44
|
+
carryoverEpic.value,
|
|
45
|
+
f.targetSprint,
|
|
46
|
+
f.newEpicId,
|
|
47
|
+
f.newLabel || undefined,
|
|
48
|
+
f.newBadge || undefined,
|
|
49
|
+
)
|
|
50
|
+
if (r.error) {
|
|
51
|
+
statusMsg.value = `Error: ${r.error}`
|
|
52
|
+
} else {
|
|
53
|
+
statusMsg.value = `Carryover complete -> ${f.targetSprint}`
|
|
54
|
+
carryoverEpic.value = null
|
|
55
|
+
}
|
|
56
|
+
clearStatus()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function clearStatus() {
|
|
60
|
+
setTimeout(() => { statusMsg.value = '' }, 3000)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function epicsForSprint(sprintId: string): PageConfig[] {
|
|
64
|
+
return epics.value
|
|
65
|
+
.filter(e => e.sprint === sprintId)
|
|
66
|
+
.sort((a, b) => a.sortOrder - b.sortOrder)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// -- Sprint CRUD --
|
|
70
|
+
|
|
71
|
+
async function handleAddSprint() {
|
|
72
|
+
const { id, label, theme, startDate, endDate } = sprintForm.value
|
|
73
|
+
if (!id.trim() || !label.trim() || !theme.trim()) return
|
|
74
|
+
const r = await addSprint({
|
|
75
|
+
id: id.trim(),
|
|
76
|
+
label: label.trim(),
|
|
77
|
+
theme: theme.trim(),
|
|
78
|
+
startDate: startDate || null,
|
|
79
|
+
endDate: endDate || null,
|
|
80
|
+
})
|
|
81
|
+
if (r.error) {
|
|
82
|
+
statusMsg.value = `Error: ${r.error}`
|
|
83
|
+
} else {
|
|
84
|
+
statusMsg.value = `${label} added`
|
|
85
|
+
sprintForm.value = { id: '', label: '', theme: '', startDate: '', endDate: '' }
|
|
86
|
+
showSprintForm.value = false
|
|
87
|
+
}
|
|
88
|
+
clearStatus()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function startEditSprint(s: SprintConfig) {
|
|
92
|
+
editingSprint.value = s.id
|
|
93
|
+
editSprintData.value = {
|
|
94
|
+
label: s.label,
|
|
95
|
+
theme: s.theme,
|
|
96
|
+
startDate: s.startDate ?? '',
|
|
97
|
+
endDate: s.endDate ?? '',
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function saveEditSprint(id: string) {
|
|
102
|
+
const d = editSprintData.value
|
|
103
|
+
const r = await updateSprint(id, {
|
|
104
|
+
label: d.label,
|
|
105
|
+
theme: d.theme,
|
|
106
|
+
startDate: d.startDate || null,
|
|
107
|
+
endDate: d.endDate || null,
|
|
108
|
+
})
|
|
109
|
+
if (r.error) {
|
|
110
|
+
statusMsg.value = `Error: ${r.error}`
|
|
111
|
+
} else {
|
|
112
|
+
statusMsg.value = 'Sprint updated'
|
|
113
|
+
editingSprint.value = null
|
|
114
|
+
}
|
|
115
|
+
clearStatus()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function handleSetActive(id: string) {
|
|
119
|
+
const r = await setActiveSprint(id)
|
|
120
|
+
if (!r.error) statusMsg.value = `${id} activated`
|
|
121
|
+
clearStatus()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function handleDeleteSprint(id: string, label: string) {
|
|
125
|
+
if (!confirm(`Delete sprint "${label}" and all its epics? This cannot be undone.`)) return
|
|
126
|
+
const r = await deleteSprint(id)
|
|
127
|
+
if (r.error) {
|
|
128
|
+
statusMsg.value = `Error: ${r.error}`
|
|
129
|
+
} else {
|
|
130
|
+
statusMsg.value = `${label} deleted`
|
|
131
|
+
}
|
|
132
|
+
clearStatus()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// -- Epic CRUD --
|
|
136
|
+
|
|
137
|
+
function showEpicForm(sprintId: string) {
|
|
138
|
+
epicFormSprint.value = sprintId
|
|
139
|
+
epicForm.value = { epicId: '', label: '', badge: '', category: 'policy', description: '' }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function handleAddEpic() {
|
|
143
|
+
if (!epicFormSprint.value) return
|
|
144
|
+
const { epicId, label, badge, category, description } = epicForm.value
|
|
145
|
+
if (!epicId.trim() || !label.trim()) return
|
|
146
|
+
const r = await addEpic(epicFormSprint.value, {
|
|
147
|
+
epicId: epicId.trim(),
|
|
148
|
+
label: label.trim(),
|
|
149
|
+
badge: badge.trim() || null,
|
|
150
|
+
category,
|
|
151
|
+
description: description.trim() || null,
|
|
152
|
+
})
|
|
153
|
+
if (r.error) {
|
|
154
|
+
statusMsg.value = `Error: ${r.error}`
|
|
155
|
+
} else {
|
|
156
|
+
statusMsg.value = `${epicId} added`
|
|
157
|
+
epicFormSprint.value = null
|
|
158
|
+
}
|
|
159
|
+
clearStatus()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function startEditEpic(e: PageConfig) {
|
|
163
|
+
editingEpic.value = `${e.sprint}:${e.id}`
|
|
164
|
+
editEpicData.value = {
|
|
165
|
+
label: e.label,
|
|
166
|
+
badge: e.badge ?? '',
|
|
167
|
+
category: e.category,
|
|
168
|
+
description: e.description ?? '',
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function saveEditEpic(sprint: string, epicId: string) {
|
|
173
|
+
const d = editEpicData.value
|
|
174
|
+
const r = await updateEpic(sprint, epicId, {
|
|
175
|
+
label: d.label,
|
|
176
|
+
badge: d.badge || null,
|
|
177
|
+
category: d.category,
|
|
178
|
+
description: d.description || null,
|
|
179
|
+
})
|
|
180
|
+
if (r.error) {
|
|
181
|
+
statusMsg.value = `Error: ${r.error}`
|
|
182
|
+
} else {
|
|
183
|
+
statusMsg.value = `${epicId} updated`
|
|
184
|
+
editingEpic.value = null
|
|
185
|
+
}
|
|
186
|
+
clearStatus()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function handleDeleteEpic(sprint: string, epicId: string) {
|
|
190
|
+
if (!confirm(`Delete ${epicId}? This cannot be undone.`)) return
|
|
191
|
+
const r = await deleteEpic(sprint, epicId)
|
|
192
|
+
if (r.error) {
|
|
193
|
+
statusMsg.value = `Error: ${r.error}`
|
|
194
|
+
} else {
|
|
195
|
+
statusMsg.value = `${epicId} deleted`
|
|
196
|
+
}
|
|
197
|
+
clearStatus()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
onMounted(async () => {
|
|
201
|
+
if (!loaded.value) await loadNavData()
|
|
202
|
+
loading.value = false
|
|
203
|
+
})
|
|
204
|
+
</script>
|
|
205
|
+
|
|
206
|
+
<template>
|
|
207
|
+
<div class="admin">
|
|
208
|
+
<div class="admin-header">
|
|
209
|
+
<h1>Sprint & Epic Management</h1>
|
|
210
|
+
<p class="admin-subtitle">Admin page — accessible only with the URL</p>
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
<!-- Status -->
|
|
214
|
+
<Transition name="fade">
|
|
215
|
+
<div v-if="statusMsg" class="admin-status">{{ statusMsg }}</div>
|
|
216
|
+
</Transition>
|
|
217
|
+
|
|
218
|
+
<!-- Top actions -->
|
|
219
|
+
<div class="top-actions">
|
|
220
|
+
<button class="btn btn--primary" @click="showSprintForm = !showSprintForm">
|
|
221
|
+
{{ showSprintForm ? 'Cancel' : '+ New Sprint' }}
|
|
222
|
+
</button>
|
|
223
|
+
<button class="btn" @click="router.push('/admin/board')">Story Management</button>
|
|
224
|
+
<button class="btn" @click="router.push('/admin')">Token Management</button>
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
<!-- Sprint add form -->
|
|
228
|
+
<div v-if="showSprintForm" class="admin-card">
|
|
229
|
+
<h2>Add New Sprint</h2>
|
|
230
|
+
<div class="form-grid">
|
|
231
|
+
<input v-model="sprintForm.id" class="input" placeholder="ID (e.g. s55)" />
|
|
232
|
+
<input v-model="sprintForm.label" class="input" placeholder="Label (e.g. S55)" />
|
|
233
|
+
<input v-model="sprintForm.theme" class="input input--wide" placeholder="Theme (e.g. Impact Feature Launch)" />
|
|
234
|
+
<input v-model="sprintForm.startDate" class="input" type="date" placeholder="Start date" />
|
|
235
|
+
<input v-model="sprintForm.endDate" class="input" type="date" placeholder="End date" />
|
|
236
|
+
<button class="btn btn--primary" @click="handleAddSprint" :disabled="!sprintForm.id.trim() || !sprintForm.label.trim()">Add</button>
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
<!-- Loading -->
|
|
241
|
+
<div v-if="loading" class="admin-loading">Loading...</div>
|
|
242
|
+
|
|
243
|
+
<!-- Sprint cards -->
|
|
244
|
+
<div v-else class="sprint-list">
|
|
245
|
+
<div v-for="s in sprints" :key="s.id" class="sprint-card">
|
|
246
|
+
<!-- Sprint header -->
|
|
247
|
+
<div class="sprint-card-header">
|
|
248
|
+
<template v-if="editingSprint === s.id">
|
|
249
|
+
<div class="edit-row">
|
|
250
|
+
<input v-model="editSprintData.label" class="input input--sm" placeholder="Label" />
|
|
251
|
+
<input v-model="editSprintData.theme" class="input" placeholder="Theme" />
|
|
252
|
+
<input v-model="editSprintData.startDate" class="input input--sm" type="date" />
|
|
253
|
+
<input v-model="editSprintData.endDate" class="input input--sm" type="date" />
|
|
254
|
+
<button class="btn btn--sm btn--primary" @click="saveEditSprint(s.id)">Save</button>
|
|
255
|
+
<button class="btn btn--sm" @click="editingSprint = null">Cancel</button>
|
|
256
|
+
</div>
|
|
257
|
+
</template>
|
|
258
|
+
<template v-else>
|
|
259
|
+
<div class="sprint-info">
|
|
260
|
+
<span class="sprint-label">{{ s.label }}</span>
|
|
261
|
+
<span class="sprint-theme-text">{{ s.theme }}</span>
|
|
262
|
+
<span class="lifecycle-badge" :class="'lc--' + (s.status || (s.active ? 'active' : 'planning'))">
|
|
263
|
+
{{ s.status === 'closed' ? 'Closed' : s.status === 'active' || s.active ? 'Active' : 'Planning' }}
|
|
264
|
+
</span>
|
|
265
|
+
</div>
|
|
266
|
+
<div v-if="s.startDate || s.endDate" class="sprint-dates">
|
|
267
|
+
{{ s.startDate ?? '?' }} ~ {{ s.endDate ?? '?' }}
|
|
268
|
+
</div>
|
|
269
|
+
<div class="sprint-actions">
|
|
270
|
+
<button class="btn btn--sm" @click="startEditSprint(s)">Edit</button>
|
|
271
|
+
<button v-if="!s.active" class="btn btn--sm btn--ok" @click="handleSetActive(s.id)">Activate</button>
|
|
272
|
+
<button v-else class="btn btn--sm" disabled>Currently Active</button>
|
|
273
|
+
<button class="btn btn--sm btn--danger" @click="handleDeleteSprint(s.id, s.label)">Delete</button>
|
|
274
|
+
</div>
|
|
275
|
+
<!-- Lifecycle actions -->
|
|
276
|
+
<div class="sprint-lifecycle">
|
|
277
|
+
<button
|
|
278
|
+
v-if="s.status === 'planning' || (!s.status && !s.active)"
|
|
279
|
+
class="btn btn--sm btn--lifecycle"
|
|
280
|
+
@click="router.push(`/kickoff/${s.id}`)"
|
|
281
|
+
>Kickoff</button>
|
|
282
|
+
<button
|
|
283
|
+
v-if="s.status === 'active' || s.active"
|
|
284
|
+
class="btn btn--sm btn--lifecycle"
|
|
285
|
+
@click="router.push(`/close/${s.id}`)"
|
|
286
|
+
>Close</button>
|
|
287
|
+
<button
|
|
288
|
+
v-if="s.status === 'closed'"
|
|
289
|
+
class="btn btn--sm btn--lifecycle"
|
|
290
|
+
@click="router.push(`/retro/${s.id}`)"
|
|
291
|
+
>Retro</button>
|
|
292
|
+
</div>
|
|
293
|
+
</template>
|
|
294
|
+
</div>
|
|
295
|
+
|
|
296
|
+
<!-- Epics table -->
|
|
297
|
+
<div class="epic-section">
|
|
298
|
+
<table class="epic-table">
|
|
299
|
+
<thead>
|
|
300
|
+
<tr>
|
|
301
|
+
<th>ID</th>
|
|
302
|
+
<th>Label</th>
|
|
303
|
+
<th>Badge</th>
|
|
304
|
+
<th>Category</th>
|
|
305
|
+
<th>Description</th>
|
|
306
|
+
<th>Actions</th>
|
|
307
|
+
</tr>
|
|
308
|
+
</thead>
|
|
309
|
+
<tbody>
|
|
310
|
+
<tr v-for="e in epicsForSprint(s.id)" :key="e.id">
|
|
311
|
+
<template v-if="editingEpic === `${s.id}:${e.id}`">
|
|
312
|
+
<td class="td-id">{{ e.id }}</td>
|
|
313
|
+
<td><input v-model="editEpicData.label" class="input input--sm" /></td>
|
|
314
|
+
<td><input v-model="editEpicData.badge" class="input input--xs" placeholder="-" /></td>
|
|
315
|
+
<td>
|
|
316
|
+
<select v-model="editEpicData.category" class="input input--xs">
|
|
317
|
+
<option value="policy">policy</option>
|
|
318
|
+
<option value="wireframe">wireframe</option>
|
|
319
|
+
</select>
|
|
320
|
+
</td>
|
|
321
|
+
<td><input v-model="editEpicData.description" class="input input--sm" /></td>
|
|
322
|
+
<td class="td-actions">
|
|
323
|
+
<button class="btn btn--sm btn--primary" @click="saveEditEpic(s.id, e.id)">Save</button>
|
|
324
|
+
<button class="btn btn--sm" @click="editingEpic = null">Cancel</button>
|
|
325
|
+
</td>
|
|
326
|
+
</template>
|
|
327
|
+
<template v-else>
|
|
328
|
+
<td class="td-id">{{ e.id }}</td>
|
|
329
|
+
<td class="td-label">{{ e.label }}</td>
|
|
330
|
+
<td><span v-if="e.badge" class="epic-badge">{{ e.badge }}</span></td>
|
|
331
|
+
<td class="td-cat">{{ e.category }}</td>
|
|
332
|
+
<td class="td-desc">{{ e.description }}</td>
|
|
333
|
+
<td class="td-actions">
|
|
334
|
+
<button class="btn btn--sm" @click="startEditEpic(e)">Edit</button>
|
|
335
|
+
<button class="btn btn--sm btn--ok" @click="startCarryover(e)">Carryover</button>
|
|
336
|
+
<button class="btn btn--sm btn--danger" @click="handleDeleteEpic(s.id, e.id)">Delete</button>
|
|
337
|
+
</td>
|
|
338
|
+
</template>
|
|
339
|
+
</tr>
|
|
340
|
+
</tbody>
|
|
341
|
+
</table>
|
|
342
|
+
|
|
343
|
+
<!-- Carryover form -->
|
|
344
|
+
<div v-if="carryoverEpic && epicsForSprint(s.id).some(e => (e.uid ?? `${e.sprint}:${e.id}`) === carryoverEpic)" class="add-epic-form">
|
|
345
|
+
<span class="carryover-label">Carryover:</span>
|
|
346
|
+
<select v-model="carryoverForm.targetSprint" class="input input--sm">
|
|
347
|
+
<option value="" disabled>Target sprint</option>
|
|
348
|
+
<option v-for="sp in sprints.filter(x => x.id !== s.id)" :key="sp.id" :value="sp.id">{{ sp.label }}</option>
|
|
349
|
+
</select>
|
|
350
|
+
<input v-model="carryoverForm.newEpicId" class="input input--xs" placeholder="New ID (E-01)" />
|
|
351
|
+
<input v-model="carryoverForm.newLabel" class="input input--sm" placeholder="Label" />
|
|
352
|
+
<input v-model="carryoverForm.newBadge" class="input input--xs" placeholder="Badge" />
|
|
353
|
+
<button class="btn btn--sm btn--primary" @click="handleCarryover" :disabled="!carryoverForm.targetSprint || !carryoverForm.newEpicId">Move</button>
|
|
354
|
+
<button class="btn btn--sm" @click="carryoverEpic = null">Cancel</button>
|
|
355
|
+
</div>
|
|
356
|
+
|
|
357
|
+
<!-- Add epic form (inline) -->
|
|
358
|
+
<div v-if="epicFormSprint === s.id" class="add-epic-form">
|
|
359
|
+
<input v-model="epicForm.epicId" class="input input--xs" placeholder="ID (E-07)" />
|
|
360
|
+
<input v-model="epicForm.label" class="input input--sm" placeholder="Label" />
|
|
361
|
+
<input v-model="epicForm.badge" class="input input--xs" placeholder="Badge" />
|
|
362
|
+
<select v-model="epicForm.category" class="input input--xs">
|
|
363
|
+
<option value="policy">policy</option>
|
|
364
|
+
<option value="wireframe">wireframe</option>
|
|
365
|
+
</select>
|
|
366
|
+
<input v-model="epicForm.description" class="input" placeholder="Description" />
|
|
367
|
+
<button class="btn btn--sm btn--primary" @click="handleAddEpic" :disabled="!epicForm.epicId.trim() || !epicForm.label.trim()">Add</button>
|
|
368
|
+
<button class="btn btn--sm" @click="epicFormSprint = null">Cancel</button>
|
|
369
|
+
</div>
|
|
370
|
+
<button v-else class="btn btn--sm add-epic-btn" @click="showEpicForm(s.id)">+ Add Epic</button>
|
|
371
|
+
</div>
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
</div>
|
|
375
|
+
</template>
|
|
376
|
+
|
|
377
|
+
<style scoped>
|
|
378
|
+
.admin {
|
|
379
|
+
max-width: 1100px;
|
|
380
|
+
margin: 0 auto;
|
|
381
|
+
padding: 32px 24px;
|
|
382
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
383
|
+
}
|
|
384
|
+
.admin-header { margin-bottom: 24px; }
|
|
385
|
+
.admin-header h1 { font-size: 24px; font-weight: 700; color: #1e293b; margin-bottom: 4px; }
|
|
386
|
+
.admin-subtitle { font-size: 13px; color: #94a3b8; }
|
|
387
|
+
|
|
388
|
+
.admin-status {
|
|
389
|
+
background: #ecfdf5; border: 1px solid #a7f3d0; color: #065f46;
|
|
390
|
+
padding: 10px 16px; border-radius: 8px; font-size: 13px; font-weight: 500;
|
|
391
|
+
margin-bottom: 16px;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
.top-actions {
|
|
395
|
+
display: flex; gap: 8px; margin-bottom: 20px;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
.admin-card {
|
|
399
|
+
background: #fff; border: 1px solid #e2e8f0; border-radius: 12px;
|
|
400
|
+
padding: 20px 24px; margin-bottom: 20px;
|
|
401
|
+
}
|
|
402
|
+
.admin-card h2 { font-size: 16px; font-weight: 600; color: #1e293b; margin-bottom: 16px; }
|
|
403
|
+
|
|
404
|
+
.form-grid {
|
|
405
|
+
display: flex; gap: 8px; flex-wrap: wrap; align-items: center;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
.sprint-list { display: flex; flex-direction: column; gap: 16px; }
|
|
409
|
+
|
|
410
|
+
.sprint-card {
|
|
411
|
+
background: #fff; border: 1px solid #e2e8f0; border-radius: 12px;
|
|
412
|
+
overflow: hidden;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
.sprint-card-header {
|
|
416
|
+
padding: 16px 20px;
|
|
417
|
+
border-bottom: 1px solid #f1f5f9;
|
|
418
|
+
background: #f8fafc;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
.sprint-info {
|
|
422
|
+
display: flex; align-items: center; gap: 10px; margin-bottom: 4px;
|
|
423
|
+
}
|
|
424
|
+
.sprint-label { font-size: 16px; font-weight: 700; color: #1e293b; }
|
|
425
|
+
.sprint-theme-text { font-size: 13px; color: #64748b; }
|
|
426
|
+
.sprint-dates { font-size: 12px; color: #94a3b8; margin-bottom: 8px; }
|
|
427
|
+
.sprint-actions { display: flex; gap: 4px; }
|
|
428
|
+
|
|
429
|
+
.edit-row {
|
|
430
|
+
display: flex; gap: 6px; flex-wrap: wrap; align-items: center;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
.epic-section { padding: 12px 20px 16px; }
|
|
434
|
+
|
|
435
|
+
.epic-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
436
|
+
.epic-table th {
|
|
437
|
+
text-align: left; padding: 6px 8px; font-size: 11px; font-weight: 600;
|
|
438
|
+
color: #64748b; text-transform: uppercase; letter-spacing: 0.5px;
|
|
439
|
+
border-bottom: 2px solid #e2e8f0;
|
|
440
|
+
}
|
|
441
|
+
.epic-table td {
|
|
442
|
+
padding: 8px; border-bottom: 1px solid #f1f5f9; color: #475569;
|
|
443
|
+
vertical-align: middle;
|
|
444
|
+
}
|
|
445
|
+
.epic-table tr:hover td { background: #f8fafc; }
|
|
446
|
+
|
|
447
|
+
.td-id { font-weight: 700; color: #3b82f6; font-size: 12px; white-space: nowrap; }
|
|
448
|
+
.td-label { font-weight: 600; color: #1e293b; }
|
|
449
|
+
.td-cat { font-size: 11px; color: #94a3b8; }
|
|
450
|
+
.td-desc { font-size: 12px; color: #64748b; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
451
|
+
.td-actions { display: flex; gap: 4px; flex-wrap: nowrap; }
|
|
452
|
+
|
|
453
|
+
.epic-badge {
|
|
454
|
+
display: inline-block; padding: 1px 6px; border-radius: 3px;
|
|
455
|
+
font-size: 10px; font-weight: 600; text-transform: uppercase;
|
|
456
|
+
background: #eff6ff; color: #3b82f6;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
.add-epic-form {
|
|
460
|
+
display: flex; gap: 6px; flex-wrap: wrap; align-items: center;
|
|
461
|
+
margin-top: 8px; padding-top: 8px; border-top: 1px solid #f1f5f9;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
.add-epic-btn { margin-top: 8px; }
|
|
465
|
+
.carryover-label { font-size: 12px; font-weight: 600; color: #f59e0b; white-space: nowrap; }
|
|
466
|
+
|
|
467
|
+
/* -- Shared -- */
|
|
468
|
+
.input {
|
|
469
|
+
padding: 6px 10px; border: 1px solid #e2e8f0; border-radius: 6px;
|
|
470
|
+
font-size: 13px; flex: 1; min-width: 80px;
|
|
471
|
+
}
|
|
472
|
+
.input--wide { flex: 2; }
|
|
473
|
+
.input--sm { max-width: 160px; flex: none; }
|
|
474
|
+
.input--xs { max-width: 80px; flex: none; }
|
|
475
|
+
.input:focus { outline: none; border-color: #3b82f6; }
|
|
476
|
+
|
|
477
|
+
select.input { cursor: pointer; }
|
|
478
|
+
|
|
479
|
+
.btn {
|
|
480
|
+
padding: 6px 14px; border: 1px solid #e2e8f0; border-radius: 6px;
|
|
481
|
+
font-size: 13px; font-weight: 500; cursor: pointer; background: #fff; color: #475569;
|
|
482
|
+
white-space: nowrap; transition: all 0.15s;
|
|
483
|
+
}
|
|
484
|
+
.btn:hover { background: #f1f5f9; }
|
|
485
|
+
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
486
|
+
.btn--primary { background: #1e293b; color: #fff; border-color: #1e293b; }
|
|
487
|
+
.btn--primary:hover { background: #334155; }
|
|
488
|
+
.btn--sm { padding: 4px 10px; font-size: 11px; }
|
|
489
|
+
.btn--ok { color: #22c55e; border-color: #86efac; }
|
|
490
|
+
.btn--ok:hover { background: #f0fdf4; }
|
|
491
|
+
.btn--danger { color: #ef4444; border-color: #fca5a5; }
|
|
492
|
+
.btn--danger:hover { background: #fef2f2; }
|
|
493
|
+
|
|
494
|
+
.badge {
|
|
495
|
+
display: inline-block; padding: 2px 8px; border-radius: 10px;
|
|
496
|
+
font-size: 10px; font-weight: 600; text-transform: uppercase;
|
|
497
|
+
}
|
|
498
|
+
.badge--active { background: #ecfdf5; color: #059669; }
|
|
499
|
+
|
|
500
|
+
.admin-loading {
|
|
501
|
+
padding: 40px; text-align: center; color: #94a3b8; font-size: 14px;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s; }
|
|
505
|
+
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
|
506
|
+
|
|
507
|
+
@media (max-width: 767px) {
|
|
508
|
+
.admin { padding: 16px; }
|
|
509
|
+
.form-grid { flex-direction: column; }
|
|
510
|
+
.input--sm, .input--xs { max-width: none; }
|
|
511
|
+
.td-actions { flex-wrap: wrap; }
|
|
512
|
+
.epic-table { display: block; overflow-x: auto; }
|
|
513
|
+
}
|
|
514
|
+
.sprint-lifecycle { display: flex; align-items: center; gap: 8px; margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(0,0,0,0.06); }
|
|
515
|
+
.lifecycle-badge { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 6px; }
|
|
516
|
+
.lc--planning { background: #e0e7ff; color: #4338ca; }
|
|
517
|
+
.lc--active { background: #dcfce7; color: #16a34a; }
|
|
518
|
+
.lc--closed { background: #f3f4f6; color: #6b7280; }
|
|
519
|
+
.btn--lifecycle { background: #eff6ff; color: #2563eb; border: 1px solid #bfdbfe; }
|
|
520
|
+
.btn--lifecycle:hover { background: #dbeafe; }
|
|
521
|
+
</style>
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed, onMounted } from 'vue'
|
|
3
|
+
import { useRouter } from 'vue-router'
|
|
4
|
+
import { apiGet } from '@/composables/useTurso'
|
|
5
|
+
import VelocityChart from '@/components/VelocityChart.vue'
|
|
6
|
+
|
|
7
|
+
const router = useRouter()
|
|
8
|
+
|
|
9
|
+
interface TimelineItem {
|
|
10
|
+
id: string; label: string; theme: string
|
|
11
|
+
status: 'planning' | 'active' | 'closed'
|
|
12
|
+
startDate: string | null; endDate: string | null
|
|
13
|
+
velocity: number | null; teamSize: number | null
|
|
14
|
+
storyCount: number; doneCount: number
|
|
15
|
+
totalSP: number; doneSP: number; completionRate: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const timeline = ref<TimelineItem[]>([])
|
|
19
|
+
|
|
20
|
+
const chartData = computed(() => {
|
|
21
|
+
return timeline.value
|
|
22
|
+
.filter(s => s.status === 'closed' || s.status === 'active')
|
|
23
|
+
.reverse()
|
|
24
|
+
.map(s => ({
|
|
25
|
+
label: s.label,
|
|
26
|
+
planned: s.totalSP,
|
|
27
|
+
actual: s.doneSP,
|
|
28
|
+
}))
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
function statusLabel(s: string) {
|
|
32
|
+
return { planning: 'Planning', active: 'Active', closed: 'Closed' }[s] ?? s
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function statusClass(s: string) {
|
|
36
|
+
return `status--${s}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function duration(start: string | null, end: string | null): string {
|
|
40
|
+
if (!start || !end) return '-'
|
|
41
|
+
const days = Math.ceil((new Date(end).getTime() - new Date(start).getTime()) / (1000 * 60 * 60 * 24))
|
|
42
|
+
return `${days} days`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
onMounted(async () => {
|
|
46
|
+
const { data } = await apiGet<{ timeline: TimelineItem[] }>('/api/v2/nav/sprints/timeline')
|
|
47
|
+
if (data?.timeline) {
|
|
48
|
+
// active -> planning -> closed
|
|
49
|
+
const order = { active: 0, planning: 1, closed: 2 } as Record<string, number>
|
|
50
|
+
timeline.value = data.timeline.sort((a: TimelineItem, b: TimelineItem) => (order[a.status] ?? 9) - (order[b.status] ?? 9))
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
<template>
|
|
56
|
+
<div class="timeline-page">
|
|
57
|
+
<h1>Sprint Timeline</h1>
|
|
58
|
+
|
|
59
|
+
<!-- Velocity Trend Chart -->
|
|
60
|
+
<div v-if="chartData.length >= 2" class="chart-card">
|
|
61
|
+
<h2>Velocity Trend</h2>
|
|
62
|
+
<VelocityChart :data="chartData" />
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<div class="timeline">
|
|
66
|
+
<div v-for="s in timeline" :key="s.id"
|
|
67
|
+
class="timeline-card" :class="statusClass(s.status)"
|
|
68
|
+
@click="router.push(`/board/${s.id}`)">
|
|
69
|
+
|
|
70
|
+
<div class="card-header">
|
|
71
|
+
<span class="card-label">{{ s.label }}</span>
|
|
72
|
+
<span class="card-status">{{ statusLabel(s.status) }}</span>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<div class="card-theme">{{ s.theme || '-' }}</div>
|
|
76
|
+
|
|
77
|
+
<div class="card-stats">
|
|
78
|
+
<div class="stat">
|
|
79
|
+
<span class="stat-value">{{ s.doneCount }}/{{ s.storyCount }}</span>
|
|
80
|
+
<span class="stat-label">Stories</span>
|
|
81
|
+
</div>
|
|
82
|
+
<div class="stat">
|
|
83
|
+
<span class="stat-value">{{ s.doneSP }}/{{ s.totalSP }}</span>
|
|
84
|
+
<span class="stat-label">SP</span>
|
|
85
|
+
</div>
|
|
86
|
+
<div class="stat">
|
|
87
|
+
<span class="stat-value">{{ s.completionRate }}%</span>
|
|
88
|
+
<span class="stat-label">Completion</span>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<div class="card-bar">
|
|
93
|
+
<div class="bar-fill" :style="{ width: s.completionRate + '%' }" />
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<div class="card-meta">
|
|
97
|
+
<span v-if="s.startDate">{{ s.startDate }} ~ {{ s.endDate }} ({{ duration(s.startDate, s.endDate) }})</span>
|
|
98
|
+
<span v-if="s.velocity">velocity: {{ s.velocity }}</span>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</template>
|
|
104
|
+
|
|
105
|
+
<style scoped>
|
|
106
|
+
.timeline-page { max-width: 800px; margin: 0 auto; padding: 24px; background: var(--bg); min-height: 100vh; }
|
|
107
|
+
.timeline-page h1 { font-size: 22px; font-weight: 700; margin-bottom: 20px; }
|
|
108
|
+
.chart-card {
|
|
109
|
+
background: rgba(255,255,255,0.25);
|
|
110
|
+
backdrop-filter: blur(40px) saturate(1.8);
|
|
111
|
+
border: 1px solid rgba(255,255,255,0.45);
|
|
112
|
+
border-radius: 16px; padding: 20px; margin-bottom: 24px;
|
|
113
|
+
box-shadow: 0 2px 12px rgba(0,0,0,0.03), inset 0 1px 0 rgba(255,255,255,0.5);
|
|
114
|
+
}
|
|
115
|
+
.chart-card h2 { font-size: 16px; font-weight: 600; margin-bottom: 12px; }
|
|
116
|
+
|
|
117
|
+
.timeline { display: flex; flex-direction: column; gap: 12px; }
|
|
118
|
+
|
|
119
|
+
.timeline-card {
|
|
120
|
+
background: rgba(255,255,255,0.25);
|
|
121
|
+
backdrop-filter: blur(40px) saturate(1.8);
|
|
122
|
+
-webkit-backdrop-filter: blur(40px) saturate(1.8);
|
|
123
|
+
border: 1px solid rgba(255,255,255,0.45);
|
|
124
|
+
border-radius: 16px;
|
|
125
|
+
padding: 20px;
|
|
126
|
+
cursor: pointer;
|
|
127
|
+
transition: all 0.2s;
|
|
128
|
+
box-shadow: 0 2px 12px rgba(0,0,0,0.03), inset 0 1px 0 rgba(255,255,255,0.5);
|
|
129
|
+
}
|
|
130
|
+
.timeline-card:hover { transform: translateY(-2px); box-shadow: 0 4px 16px rgba(0,0,0,0.05); }
|
|
131
|
+
|
|
132
|
+
.timeline-card.status--active { border-left: 4px solid #3B82F6; }
|
|
133
|
+
.timeline-card.status--planning { border-left: 4px solid #F59E0B; }
|
|
134
|
+
.timeline-card.status--closed { border-left: 4px solid #94A3B8; opacity: 0.8; }
|
|
135
|
+
|
|
136
|
+
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
|
|
137
|
+
.card-label { font-size: 18px; font-weight: 700; }
|
|
138
|
+
.card-status { font-size: 12px; font-weight: 600; padding: 2px 8px; border-radius: 6px; }
|
|
139
|
+
.status--active .card-status { background: rgba(59,130,246,0.12); color: #2563EB; }
|
|
140
|
+
.status--planning .card-status { background: rgba(245,158,11,0.12); color: #D97706; }
|
|
141
|
+
.status--closed .card-status { background: rgba(148,163,184,0.12); color: #64748B; }
|
|
142
|
+
|
|
143
|
+
.card-theme { font-size: 14px; color: var(--text-secondary); margin-bottom: 12px; }
|
|
144
|
+
|
|
145
|
+
.card-stats { display: flex; gap: 24px; margin-bottom: 8px; }
|
|
146
|
+
.stat { display: flex; flex-direction: column; }
|
|
147
|
+
.stat-value { font-size: 16px; font-weight: 700; }
|
|
148
|
+
.stat-label { font-size: 11px; color: var(--text-muted); }
|
|
149
|
+
|
|
150
|
+
.card-bar { height: 6px; background: rgba(0,0,0,0.06); border-radius: 3px; overflow: hidden; margin-bottom: 8px; }
|
|
151
|
+
.bar-fill { height: 100%; background: #3B82F6; border-radius: 3px; transition: width 0.3s; }
|
|
152
|
+
|
|
153
|
+
.card-meta { font-size: 11px; color: var(--text-muted); display: flex; gap: 12px; }
|
|
154
|
+
@media (max-width: 640px) {
|
|
155
|
+
.timeline-page { padding: 16px; }
|
|
156
|
+
.card-stats { gap: 12px; flex-wrap: wrap; }
|
|
157
|
+
.card-meta { flex-direction: column; }
|
|
158
|
+
}
|
|
159
|
+
</style>
|