popilot 0.6.0 → 0.7.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/bin/cli.mjs +204 -2
- package/lib/doctor.mjs +38 -1
- package/lib/hydrate.mjs +15 -0
- package/lib/scaffold.mjs +5 -0
- package/lib/setup-wizard.mjs +35 -2
- package/package.json +1 -1
- package/scaffold/.context/project.yaml.example +19 -0
- package/scaffold/mcp-pm/package.json +19 -0
- package/scaffold/mcp-pm/src/api-client.ts +69 -0
- package/scaffold/mcp-pm/src/index.ts +660 -0
- package/scaffold/mcp-pm/tsconfig.json +14 -0
- package/scaffold/pm-api/package.json +21 -0
- package/scaffold/pm-api/sql/schema-core.sql +331 -0
- package/scaffold/pm-api/sql/schema-docs.sql +25 -0
- package/scaffold/pm-api/sql/schema-meetings.sql +17 -0
- package/scaffold/pm-api/sql/schema-rewards.sql +16 -0
- package/scaffold/pm-api/src/auth.ts +28 -0
- package/scaffold/pm-api/src/blockchain/adapter.ts +20 -0
- package/scaffold/pm-api/src/blockchain/tron.ts +62 -0
- package/scaffold/pm-api/src/db/adapter.ts +36 -0
- package/scaffold/pm-api/src/db/turso.ts +147 -0
- package/scaffold/pm-api/src/index.ts +114 -0
- package/scaffold/pm-api/src/mcp-tools/dashboard.ts +40 -0
- package/scaffold/pm-api/src/mcp-tools/epic.ts +67 -0
- package/scaffold/pm-api/src/mcp-tools/event.ts +89 -0
- package/scaffold/pm-api/src/mcp-tools/index.ts +11 -0
- package/scaffold/pm-api/src/mcp-tools/initiative.ts +51 -0
- package/scaffold/pm-api/src/mcp-tools/memo.ts +164 -0
- package/scaffold/pm-api/src/mcp-tools/notification.ts +37 -0
- package/scaffold/pm-api/src/mcp-tools/retro.ts +183 -0
- package/scaffold/pm-api/src/mcp-tools/sprint.ts +204 -0
- package/scaffold/pm-api/src/mcp-tools/standup.ts +136 -0
- package/scaffold/pm-api/src/mcp-tools/story.ts +230 -0
- package/scaffold/pm-api/src/mcp-tools/task.ts +187 -0
- package/scaffold/pm-api/src/mcp-tools/utils.ts +83 -0
- package/scaffold/pm-api/src/mcp.ts +871 -0
- package/scaffold/pm-api/src/nudge.ts +283 -0
- package/scaffold/pm-api/src/routes/auth.ts +32 -0
- package/scaffold/pm-api/src/routes/v2-activity.ts +27 -0
- package/scaffold/pm-api/src/routes/v2-admin.ts +165 -0
- package/scaffold/pm-api/src/routes/v2-dashboard.ts +189 -0
- package/scaffold/pm-api/src/routes/v2-docs.ts +34 -0
- package/scaffold/pm-api/src/routes/v2-initiatives.ts +118 -0
- package/scaffold/pm-api/src/routes/v2-kickoff.ts +265 -0
- package/scaffold/pm-api/src/routes/v2-meetings.ts +324 -0
- package/scaffold/pm-api/src/routes/v2-memos.ts +257 -0
- package/scaffold/pm-api/src/routes/v2-nav.ts +260 -0
- package/scaffold/pm-api/src/routes/v2-notifications.ts +79 -0
- package/scaffold/pm-api/src/routes/v2-page-content.ts +35 -0
- package/scaffold/pm-api/src/routes/v2-pm.ts +380 -0
- package/scaffold/pm-api/src/routes/v2-policy.ts +58 -0
- package/scaffold/pm-api/src/routes/v2-retro.ts +221 -0
- package/scaffold/pm-api/src/routes/v2-rewards.ts +132 -0
- package/scaffold/pm-api/src/routes/v2-scenarios.ts +48 -0
- package/scaffold/pm-api/src/routes/v2-search.ts +32 -0
- package/scaffold/pm-api/src/routes/v2-standup.ts +127 -0
- package/scaffold/pm-api/src/routes/v2-user.ts +38 -0
- package/scaffold/pm-api/src/types.ts +11 -0
- package/scaffold/pm-api/src/utils/activity.ts +22 -0
- package/scaffold/pm-api/src/utils/admin.ts +9 -0
- package/scaffold/pm-api/src/utils/agent-notify.ts +62 -0
- package/scaffold/pm-api/src/utils/assignee.ts +69 -0
- package/scaffold/pm-api/src/utils/db.ts +45 -0
- package/scaffold/pm-api/src/utils/initiative.ts +23 -0
- package/scaffold/pm-api/src/utils/sprint-lifecycle.ts +96 -0
- package/scaffold/pm-api/tsconfig.json +15 -0
- package/scaffold/pm-api/wrangler.toml.hbs +11 -0
- package/scaffold/spec-site/package-lock.json +40 -0
- package/scaffold/spec-site/package.json +4 -1
- package/scaffold/spec-site/src/api/types.ts +6 -0
- package/scaffold/spec-site/src/components/AppHeader.vue +429 -55
- package/scaffold/spec-site/src/components/MemberSelect.vue +48 -0
- package/scaffold/spec-site/src/components/NotificationDropdown.vue +116 -0
- package/scaffold/spec-site/src/components/SearchModal.vue +102 -0
- package/scaffold/spec-site/src/components/VelocityChart.vue +77 -0
- package/scaffold/spec-site/src/composables/pmTypes.ts +15 -2
- package/scaffold/spec-site/src/composables/useDashboard.ts +221 -0
- package/scaffold/spec-site/src/composables/useMediaQuery.ts +28 -0
- package/scaffold/spec-site/src/composables/useNotification.ts +200 -0
- package/scaffold/spec-site/src/composables/usePmStore.ts +48 -1
- package/scaffold/spec-site/src/composables/useRetro.ts +6 -0
- package/scaffold/spec-site/src/composables/useStandup.ts +201 -0
- package/scaffold/spec-site/src/composables/useTheme.ts +37 -0
- package/scaffold/spec-site/src/composables/useUser.ts +19 -1
- package/scaffold/spec-site/src/features.ts +108 -0
- package/scaffold/spec-site/src/pages/AdminPage.vue +299 -0
- package/scaffold/spec-site/src/pages/DashboardPage.vue +650 -0
- package/scaffold/spec-site/src/pages/DocsHub.vue +157 -0
- package/scaffold/spec-site/src/pages/InboxPage.vue +156 -0
- package/scaffold/spec-site/src/pages/MeetingsPage.vue +294 -0
- package/scaffold/spec-site/src/pages/MyPage.vue +343 -0
- package/scaffold/spec-site/src/pages/RewardsPage.vue +266 -0
- package/scaffold/spec-site/src/pages/board/BoardAdmin.vue +422 -0
- package/scaffold/spec-site/src/pages/board/BoardEpicSection.vue +54 -0
- package/scaffold/spec-site/src/pages/board/BoardPage.vue +884 -0
- package/scaffold/spec-site/src/pages/board/BoardStoryCard.vue +67 -0
- package/scaffold/spec-site/src/pages/board/BoardTaskItem.vue +52 -0
- package/scaffold/spec-site/src/pages/board/MyTasksPage.vue +202 -0
- package/scaffold/spec-site/src/pages/board/SprintClose.vue +167 -0
- package/scaffold/spec-site/src/pages/board/SprintColumn.vue +49 -0
- package/scaffold/spec-site/src/pages/board/SprintKickoff.vue +389 -0
- package/scaffold/spec-site/src/pages/board/StatusBadge.vue +52 -0
- package/scaffold/spec-site/src/pages/board/StoryDetailPanel.vue +495 -0
- package/scaffold/spec-site/src/pages/board/TaskCard.vue +42 -0
- package/scaffold/spec-site/src/pages/retro/RetroCard.vue +36 -2
- package/scaffold/spec-site/src/pages/retro/RetroHeader.vue +82 -66
- package/scaffold/spec-site/src/pages/retro/RetroPage.vue +47 -18
- package/scaffold/spec-site/src/pages/standup/StandupEntryCard.vue +551 -0
- package/scaffold/spec-site/src/pages/standup/StandupForm.vue +68 -0
- package/scaffold/spec-site/src/pages/standup/StandupList.vue +71 -0
- package/scaffold/spec-site/src/pages/standup/StandupPage.vue +225 -0
- package/scaffold/spec-site/src/router.ts +141 -0
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref, nextTick, watch, onMounted, onUnmounted } from 'vue'
|
|
3
|
+
import type { PmStory, StoryStatus } from '@/composables/usePmStore'
|
|
4
|
+
import { getActiveSprint } from '@/composables/useNavStore'
|
|
5
|
+
import {
|
|
6
|
+
getTasksForStory, getEpicById, updateStoryStatus, updateStory, addTask,
|
|
7
|
+
STORY_STATUSES, STORY_STATUS_LABELS, PRIORITY_LABELS,
|
|
8
|
+
} from '@/composables/usePmStore'
|
|
9
|
+
import { useAuth } from '@/composables/useAuth'
|
|
10
|
+
import StatusBadge from './StatusBadge.vue'
|
|
11
|
+
import BoardTaskItem from './BoardTaskItem.vue'
|
|
12
|
+
|
|
13
|
+
const props = defineProps<{ story: PmStory }>()
|
|
14
|
+
const emit = defineEmits<{ close: []; updated: [] }>()
|
|
15
|
+
|
|
16
|
+
const { authUser } = useAuth()
|
|
17
|
+
|
|
18
|
+
// Inline task add
|
|
19
|
+
const showAddTask = ref(false)
|
|
20
|
+
const newTaskTitle = ref('')
|
|
21
|
+
const addingTask = ref(false)
|
|
22
|
+
const taskInput = ref<HTMLInputElement | null>(null)
|
|
23
|
+
|
|
24
|
+
async function openAddTask() {
|
|
25
|
+
showAddTask.value = true
|
|
26
|
+
await nextTick()
|
|
27
|
+
taskInput.value?.focus()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function handleAddTask() {
|
|
31
|
+
const title = newTaskTitle.value.trim()
|
|
32
|
+
if (!title || addingTask.value) return
|
|
33
|
+
addingTask.value = true
|
|
34
|
+
await addTask({ storyId: props.story.id, title })
|
|
35
|
+
newTaskTitle.value = ''
|
|
36
|
+
addingTask.value = false
|
|
37
|
+
emit('updated')
|
|
38
|
+
await nextTick()
|
|
39
|
+
taskInput.value?.focus()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function cancelAddTask() {
|
|
43
|
+
showAddTask.value = false
|
|
44
|
+
newTaskTitle.value = ''
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const storyTasks = computed(() => getTasksForStory(props.story.id))
|
|
48
|
+
const doneCount = computed(() => storyTasks.value.filter(t => t.status === 'done').length)
|
|
49
|
+
const epicTitle = computed(() => props.story.epicId ? getEpicById(props.story.epicId)?.title ?? null : null)
|
|
50
|
+
|
|
51
|
+
// AC check-off
|
|
52
|
+
interface AcItem { text: string; checked: boolean }
|
|
53
|
+
|
|
54
|
+
function parseAcItems(raw: string | null): AcItem[] {
|
|
55
|
+
if (!raw) return []
|
|
56
|
+
const lines = raw.includes('\n')
|
|
57
|
+
? raw.split('\n').map(l => l.trim()).filter(Boolean)
|
|
58
|
+
: [raw.trim()]
|
|
59
|
+
return lines.map(line => {
|
|
60
|
+
if (line.startsWith('[x] ') || line.startsWith('[X] ')) return { text: line.slice(4), checked: true }
|
|
61
|
+
if (line.startsWith('[ ] ')) return { text: line.slice(4), checked: false }
|
|
62
|
+
return { text: line, checked: false }
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function serializeAc(items: AcItem[]): string {
|
|
67
|
+
return items.map(i => `${i.checked ? '[x]' : '[ ]'} ${i.text}`).join('\n')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const acItems = computed(() => parseAcItems(props.story.acceptanceCriteria))
|
|
71
|
+
const acDoneCount = computed(() => acItems.value.filter(i => i.checked).length)
|
|
72
|
+
|
|
73
|
+
async function handleMergeOk() {
|
|
74
|
+
if (!confirm('Mark as Merge OK? Story status will change to done.')) return
|
|
75
|
+
await updateStory(props.story.id, { status: 'done' } as any)
|
|
76
|
+
emit('updated')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function toggleAc(idx: number) {
|
|
80
|
+
const items = [...acItems.value]
|
|
81
|
+
items[idx] = { ...items[idx], checked: !items[idx].checked }
|
|
82
|
+
await updateStory(props.story.id, { acceptanceCriteria: serializeAc(items) })
|
|
83
|
+
emit('updated')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// DoR checklist (gate for ready-for-dev)
|
|
87
|
+
const showDorModal = ref(false)
|
|
88
|
+
const dorChecks = ref({ specDone: false, acExists: false, mockupReady: false })
|
|
89
|
+
const pendingStatus = ref<StoryStatus | null>(null)
|
|
90
|
+
|
|
91
|
+
function resetDor() { dorChecks.value = { specDone: false, acExists: false, mockupReady: false } }
|
|
92
|
+
|
|
93
|
+
const dorAllChecked = computed(() =>
|
|
94
|
+
dorChecks.value.specDone && dorChecks.value.acExists && dorChecks.value.mockupReady
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
async function confirmDor() {
|
|
98
|
+
if (!dorAllChecked.value || !pendingStatus.value) return
|
|
99
|
+
await updateStoryStatus(props.story.id, pendingStatus.value)
|
|
100
|
+
showDorModal.value = false
|
|
101
|
+
pendingStatus.value = null
|
|
102
|
+
resetDor()
|
|
103
|
+
emit('updated')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Status dropdown
|
|
107
|
+
const statusDropOpen = ref(false)
|
|
108
|
+
const statusTrigger = ref<HTMLElement | null>(null)
|
|
109
|
+
const STATUS_COLORS: Record<string, string> = {
|
|
110
|
+
draft: '#94a3b8', backlog: '#a78bfa', ready: '#3b82f6',
|
|
111
|
+
'in-progress': '#f59e0b', review: '#8b5cf6', done: '#22c55e',
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function selectStatus(status: StoryStatus) {
|
|
115
|
+
statusDropOpen.value = false
|
|
116
|
+
if (status === props.story.status) return
|
|
117
|
+
if (status === 'ready' && props.story.status !== 'ready') {
|
|
118
|
+
pendingStatus.value = status
|
|
119
|
+
resetDor()
|
|
120
|
+
showDorModal.value = true
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
await updateStoryStatus(props.story.id, status)
|
|
124
|
+
emit('updated')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Assignee multi-select
|
|
128
|
+
const assigneeDropOpen = ref(false)
|
|
129
|
+
const assigneeTrigger = ref<HTMLElement | null>(null)
|
|
130
|
+
|
|
131
|
+
const assigneeList = computed(() =>
|
|
132
|
+
props.story.assignee ? props.story.assignee.split(',').map(s => s.trim()).filter(Boolean) : []
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
const assigneeDisplay = computed(() =>
|
|
136
|
+
assigneeList.value.length === 0 ? 'Unassigned' : assigneeList.value.join(', ')
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
async function toggleAssignee(name: string) {
|
|
140
|
+
const current = new Set(assigneeList.value)
|
|
141
|
+
if (current.has(name)) current.delete(name)
|
|
142
|
+
else current.add(name)
|
|
143
|
+
const value = current.size > 0 ? [...current].join(',') : null
|
|
144
|
+
await updateStory(props.story.id, { assignee: value })
|
|
145
|
+
emit('updated')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Dropdown position
|
|
149
|
+
function menuStyle(trigger: HTMLElement | null): Record<string, string> {
|
|
150
|
+
if (!trigger) return {}
|
|
151
|
+
const rect = trigger.getBoundingClientRect()
|
|
152
|
+
return { position: 'fixed', top: `${rect.bottom + 4}px`, left: `${rect.left}px`, width: `${rect.width}px`, zIndex: '9999' }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Figma link editing
|
|
156
|
+
const editingFigma = ref(false)
|
|
157
|
+
const figmaUrlDraft = ref('')
|
|
158
|
+
const figmaInput = ref<HTMLInputElement | null>(null)
|
|
159
|
+
|
|
160
|
+
const figmaLabel = computed(() => {
|
|
161
|
+
if (!props.story.figmaUrl) return ''
|
|
162
|
+
try {
|
|
163
|
+
const url = new URL(props.story.figmaUrl)
|
|
164
|
+
const parts = url.pathname.split('/')
|
|
165
|
+
return parts[parts.length - 1]?.replace(/-/g, ' ') || 'Figma'
|
|
166
|
+
} catch { return 'Figma' }
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
async function startEditFigma() {
|
|
170
|
+
figmaUrlDraft.value = props.story.figmaUrl ?? ''
|
|
171
|
+
editingFigma.value = true
|
|
172
|
+
await nextTick()
|
|
173
|
+
figmaInput.value?.focus()
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function saveFigma() {
|
|
177
|
+
const url = figmaUrlDraft.value.trim() || null
|
|
178
|
+
editingFigma.value = false
|
|
179
|
+
if (url === props.story.figmaUrl) return
|
|
180
|
+
await updateStory(props.story.id, { figmaUrl: url })
|
|
181
|
+
emit('updated')
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Close dropdown on outside click
|
|
185
|
+
function onDocClick(e: MouseEvent) {
|
|
186
|
+
const target = e.target as HTMLElement
|
|
187
|
+
if (!target.closest('.field-dropdown') && !target.closest('.field-menu-portal')) {
|
|
188
|
+
statusDropOpen.value = false
|
|
189
|
+
assigneeDropOpen.value = false
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const panelBodyRef = ref<HTMLElement | null>(null)
|
|
193
|
+
|
|
194
|
+
async function updateDate(field: 'startDate' | 'dueDate', value: string) {
|
|
195
|
+
await updateStory(props.story.id, { [field]: value || null } as any)
|
|
196
|
+
emit('updated')
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function assignToSprint() {
|
|
200
|
+
const activeSprint = getActiveSprint().id
|
|
201
|
+
await updateStory(props.story.id, { sprint: activeSprint })
|
|
202
|
+
emit('updated')
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function unassignFromSprint() {
|
|
206
|
+
await updateStory(props.story.id, { sprint: null } as any)
|
|
207
|
+
emit('updated')
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
onMounted(async () => {
|
|
211
|
+
document.addEventListener('click', onDocClick)
|
|
212
|
+
const { pmEpics, loadEpics } = await import('@/composables/usePmStore')
|
|
213
|
+
if (!pmEpics.value.length) await loadEpics()
|
|
214
|
+
nextTick(() => { if (panelBodyRef.value) panelBodyRef.value.scrollTop = 0 })
|
|
215
|
+
})
|
|
216
|
+
onUnmounted(() => document.removeEventListener('click', onDocClick))
|
|
217
|
+
</script>
|
|
218
|
+
|
|
219
|
+
<template>
|
|
220
|
+
<div class="panel-overlay" @click.self="emit('close')">
|
|
221
|
+
<div class="panel">
|
|
222
|
+
<div class="panel-header">
|
|
223
|
+
<div class="panel-header-top">
|
|
224
|
+
<div class="panel-badges">
|
|
225
|
+
<StatusBadge :label="PRIORITY_LABELS[story.priority]" type="priority" :value="story.priority" />
|
|
226
|
+
<span class="panel-area">{{ story.area }}</span>
|
|
227
|
+
<span v-if="story.storyPoints" class="panel-points">{{ story.storyPoints }} SP</span>
|
|
228
|
+
</div>
|
|
229
|
+
<button class="close-btn" @click="emit('close')">×</button>
|
|
230
|
+
</div>
|
|
231
|
+
<h2 class="panel-title">{{ story.title }}</h2>
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
<div ref="panelBodyRef" class="panel-body">
|
|
235
|
+
<!-- Property fields -->
|
|
236
|
+
<div class="field-grid">
|
|
237
|
+
<!-- Status dropdown -->
|
|
238
|
+
<div class="field-row">
|
|
239
|
+
<span class="field-label">Status</span>
|
|
240
|
+
<div ref="statusTrigger" class="field-dropdown" @click.stop="statusDropOpen = !statusDropOpen; assigneeDropOpen = false">
|
|
241
|
+
<button class="field-value field-value--clickable">
|
|
242
|
+
<span class="status-dot" :style="{ background: STATUS_COLORS[story.status] }"></span>
|
|
243
|
+
{{ STORY_STATUS_LABELS[story.status] }}
|
|
244
|
+
<span class="field-chevron">▾</span>
|
|
245
|
+
</button>
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
<!-- Assignee -->
|
|
249
|
+
<div class="field-row">
|
|
250
|
+
<span class="field-label">Assign</span>
|
|
251
|
+
<div ref="assigneeTrigger" class="field-dropdown" @click.stop="assigneeDropOpen = !assigneeDropOpen; statusDropOpen = false">
|
|
252
|
+
<button class="field-value field-value--clickable">
|
|
253
|
+
{{ assigneeDisplay }}
|
|
254
|
+
<span class="field-chevron">▾</span>
|
|
255
|
+
</button>
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
<!-- Epic -->
|
|
259
|
+
<div v-if="epicTitle" class="field-row">
|
|
260
|
+
<span class="field-label">Epic</span>
|
|
261
|
+
<span class="field-value">{{ epicTitle }}</span>
|
|
262
|
+
</div>
|
|
263
|
+
<!-- Dates -->
|
|
264
|
+
<div class="field-row">
|
|
265
|
+
<span class="field-label">Start</span>
|
|
266
|
+
<input type="date" class="field-date-input" :value="story.startDate ?? ''" @change="updateDate('startDate', ($event.target as HTMLInputElement).value)" />
|
|
267
|
+
</div>
|
|
268
|
+
<div class="field-row">
|
|
269
|
+
<span class="field-label">Due</span>
|
|
270
|
+
<input type="date" class="field-date-input" :value="story.dueDate ?? ''" @change="updateDate('dueDate', ($event.target as HTMLInputElement).value)" />
|
|
271
|
+
</div>
|
|
272
|
+
<!-- Sprint assignment -->
|
|
273
|
+
<div class="field-row">
|
|
274
|
+
<span class="field-label">Sprint</span>
|
|
275
|
+
<div class="field-value sprint-assign">
|
|
276
|
+
<span v-if="story.sprint">{{ story.sprint }}</span>
|
|
277
|
+
<span v-else class="field-placeholder">Backlog</span>
|
|
278
|
+
<button v-if="story.sprint" class="sprint-btn sprint-btn--remove" @click.stop="unassignFromSprint" title="Remove from sprint">Remove</button>
|
|
279
|
+
<button v-else class="sprint-btn sprint-btn--assign" @click.stop="assignToSprint" title="Assign to current sprint">Assign</button>
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
<!-- Figma link -->
|
|
283
|
+
<div class="field-row">
|
|
284
|
+
<span class="field-label">Figma</span>
|
|
285
|
+
<div v-if="!editingFigma" class="field-value field-value--clickable" @click="startEditFigma">
|
|
286
|
+
<a v-if="story.figmaUrl" :href="story.figmaUrl" target="_blank" rel="noopener" class="figma-link" @click.stop>{{ figmaLabel }}</a>
|
|
287
|
+
<span v-else class="field-placeholder">+ Add link</span>
|
|
288
|
+
</div>
|
|
289
|
+
<div v-else class="figma-edit">
|
|
290
|
+
<input ref="figmaInput" v-model="figmaUrlDraft" class="figma-input" placeholder="https://www.figma.com/..." @keydown.enter="saveFigma" @keydown.escape="editingFigma = false" />
|
|
291
|
+
<button class="figma-save" @click="saveFigma">Save</button>
|
|
292
|
+
<button class="figma-cancel" @click="editingFigma = false">×</button>
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
</div>
|
|
296
|
+
|
|
297
|
+
<!-- Dropdown menus (Teleport to body) -->
|
|
298
|
+
<Teleport to="body">
|
|
299
|
+
<div v-if="statusDropOpen" class="field-menu-portal" :style="menuStyle(statusTrigger)" @click.stop>
|
|
300
|
+
<div class="field-menu">
|
|
301
|
+
<div v-for="s in STORY_STATUSES" :key="s" class="field-menu-item" :class="{ active: s === story.status }" @click="selectStatus(s)">
|
|
302
|
+
<span class="status-dot" :style="{ background: STATUS_COLORS[s] }"></span>
|
|
303
|
+
{{ STORY_STATUS_LABELS[s] }}
|
|
304
|
+
</div>
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
<div v-if="assigneeDropOpen" class="field-menu-portal" :style="menuStyle(assigneeTrigger)" @click.stop>
|
|
308
|
+
<div class="field-menu">
|
|
309
|
+
<div class="field-menu-item" style="color:#94a3b8;" @click="toggleAssignee('')">
|
|
310
|
+
(Use admin member list for options)
|
|
311
|
+
</div>
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
</Teleport>
|
|
315
|
+
|
|
316
|
+
<!-- Description -->
|
|
317
|
+
<section v-if="story.description" class="panel-section">
|
|
318
|
+
<h3>Description</h3>
|
|
319
|
+
<div class="desc-text">{{ story.description }}</div>
|
|
320
|
+
</section>
|
|
321
|
+
|
|
322
|
+
<!-- Acceptance Criteria -->
|
|
323
|
+
<section v-if="acItems.length > 0" class="panel-section">
|
|
324
|
+
<h3>Acceptance Criteria <span class="task-counter">{{ acDoneCount }}/{{ acItems.length }}</span></h3>
|
|
325
|
+
<div class="ac-list">
|
|
326
|
+
<label v-for="(ac, i) in acItems" :key="i" class="ac-item" :class="{ 'ac-item--done': ac.checked }">
|
|
327
|
+
<input type="checkbox" :checked="ac.checked" class="ac-check" @change="toggleAc(i)" />
|
|
328
|
+
<span class="ac-text">{{ ac.text }}</span>
|
|
329
|
+
</label>
|
|
330
|
+
</div>
|
|
331
|
+
<button
|
|
332
|
+
v-if="(story.status === 'review' || story.status === 'qa') && acItems.length > 0"
|
|
333
|
+
class="btn merge-ok-btn"
|
|
334
|
+
:class="{ 'merge-ok-active': acDoneCount === acItems.length }"
|
|
335
|
+
:disabled="acDoneCount !== acItems.length"
|
|
336
|
+
@click="handleMergeOk"
|
|
337
|
+
>
|
|
338
|
+
{{ acDoneCount === acItems.length ? 'Merge OK -- All AC passed' : `AC ${acDoneCount}/${acItems.length} passed` }}
|
|
339
|
+
</button>
|
|
340
|
+
</section>
|
|
341
|
+
|
|
342
|
+
<!-- No AC free-form QA -->
|
|
343
|
+
<section v-if="acItems.length === 0 && (story.status === 'review' || story.status === 'qa')" class="panel-section">
|
|
344
|
+
<h3>QA Checklist</h3>
|
|
345
|
+
<p class="qa-note">No acceptance criteria defined. Verify manually before merging.</p>
|
|
346
|
+
<button class="btn merge-ok-btn merge-ok-active" @click="handleMergeOk">Merge OK</button>
|
|
347
|
+
</section>
|
|
348
|
+
|
|
349
|
+
<!-- Tasks -->
|
|
350
|
+
<section class="panel-section">
|
|
351
|
+
<div class="task-header">
|
|
352
|
+
<h3>Tasks <span v-if="storyTasks.length > 0" class="task-counter">{{ doneCount }}/{{ storyTasks.length }}</span></h3>
|
|
353
|
+
<button v-if="!showAddTask" class="add-task-btn" @click="openAddTask">+ Add</button>
|
|
354
|
+
</div>
|
|
355
|
+
<!-- Related PRs -->
|
|
356
|
+
<div v-if="story.relatedPrs?.length" class="panel-prs">
|
|
357
|
+
<div class="panel-section-title">Linked PRs</div>
|
|
358
|
+
<div v-for="pr in story.relatedPrs" :key="pr.prNumber" class="pr-item">
|
|
359
|
+
<a :href="pr.prUrl" target="_blank" class="pr-link">#{{ pr.prNumber }}</a>
|
|
360
|
+
<span class="pr-title-text">{{ pr.prTitle }}</span>
|
|
361
|
+
<span class="pr-status" :class="'pr--' + pr.status">{{ pr.status }}</span>
|
|
362
|
+
</div>
|
|
363
|
+
</div>
|
|
364
|
+
<div v-if="storyTasks.length === 0 && !showAddTask" class="panel-empty">No tasks</div>
|
|
365
|
+
<div v-if="storyTasks.length > 0" class="panel-tasks">
|
|
366
|
+
<BoardTaskItem v-for="t in storyTasks" :key="t.id" :task="t" @updated="emit('updated')" />
|
|
367
|
+
</div>
|
|
368
|
+
<!-- Inline task add -->
|
|
369
|
+
<div v-if="showAddTask" class="add-task-form">
|
|
370
|
+
<input ref="taskInput" v-model="newTaskTitle" class="add-task-input" placeholder="Task title (Enter to add)" @keydown.enter.prevent="handleAddTask" @keydown.escape="cancelAddTask" :disabled="addingTask" />
|
|
371
|
+
<div class="add-task-actions">
|
|
372
|
+
<button class="add-task-submit" @click="handleAddTask" :disabled="!newTaskTitle.trim() || addingTask">Add</button>
|
|
373
|
+
<button class="add-task-cancel" @click="cancelAddTask">Cancel</button>
|
|
374
|
+
</div>
|
|
375
|
+
</div>
|
|
376
|
+
</section>
|
|
377
|
+
|
|
378
|
+
<!-- Meta -->
|
|
379
|
+
<div class="panel-meta">
|
|
380
|
+
<span>Created: {{ story.createdAt?.split('T')[0] ?? '-' }}</span>
|
|
381
|
+
<span>Updated: {{ story.updatedAt?.split('T')[0] ?? '-' }}</span>
|
|
382
|
+
</div>
|
|
383
|
+
</div>
|
|
384
|
+
</div>
|
|
385
|
+
|
|
386
|
+
<!-- DoR Checklist Modal -->
|
|
387
|
+
<div v-if="showDorModal" class="dor-overlay" @click.self="showDorModal = false">
|
|
388
|
+
<div class="dor-modal">
|
|
389
|
+
<h3 class="dor-title">Definition of Ready</h3>
|
|
390
|
+
<p class="dor-desc">Confirm the following before marking as ready.</p>
|
|
391
|
+
<div class="dor-checks">
|
|
392
|
+
<label class="dor-check"><input type="checkbox" v-model="dorChecks.specDone" /><span>Epic spec is finalized?</span></label>
|
|
393
|
+
<label class="dor-check"><input type="checkbox" v-model="dorChecks.acExists" /><span>Acceptance criteria defined?</span></label>
|
|
394
|
+
<label class="dor-check"><input type="checkbox" v-model="dorChecks.mockupReady" /><span>Mockup / screen spec ready?</span></label>
|
|
395
|
+
</div>
|
|
396
|
+
<div class="dor-actions">
|
|
397
|
+
<button class="dor-confirm" :disabled="!dorAllChecked" @click="confirmDor">Mark as Ready</button>
|
|
398
|
+
<button class="dor-cancel" @click="showDorModal = false">Cancel</button>
|
|
399
|
+
</div>
|
|
400
|
+
</div>
|
|
401
|
+
</div>
|
|
402
|
+
</div>
|
|
403
|
+
</template>
|
|
404
|
+
|
|
405
|
+
<style scoped>
|
|
406
|
+
.panel-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.3); z-index: 900; display: flex; justify-content: flex-end; }
|
|
407
|
+
.panel { width: 480px; max-width: 100vw; height: 100vh; max-height: 100vh; background: #f8f8fb; box-shadow: -4px 0 24px rgba(0,0,0,0.08); overflow: hidden; padding: 0; display: flex; flex-direction: column; animation: slide-in 0.2s ease-out; }
|
|
408
|
+
.panel-header { background: #f8f8fb; padding: 20px 24px 16px; border-bottom: 1px solid rgba(0,0,0,0.06); flex-shrink: 0; position: relative; }
|
|
409
|
+
.panel-header-top { display: flex; justify-content: space-between; align-items: flex-start; }
|
|
410
|
+
.close-btn { position: absolute; top: 16px; right: 16px; width: 32px; height: 32px; border: none; background: none; font-size: 22px; color: #94a3b8; cursor: pointer; border-radius: 6px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
|
411
|
+
.close-btn:hover { background: rgba(0,0,0,0.04); color: var(--text-primary); }
|
|
412
|
+
.panel-header .panel-title { margin: 8px 32px 0 0; font-size: 16px; font-weight: 700; line-height: 1.4; word-break: break-word; }
|
|
413
|
+
.panel-body { flex: 1 1 0; min-height: 0; overflow-y: auto; overflow-x: hidden; padding: 12px 20px 32px; display: flex; flex-direction: column; gap: 4px; }
|
|
414
|
+
@keyframes slide-in { from { transform: translateX(100%); } to { transform: translateX(0); } }
|
|
415
|
+
.panel-badges { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
|
416
|
+
.panel-area { font-size: 11px; font-weight: 600; color: var(--text-secondary); background: rgba(0,0,0,0.04); padding: 2px 8px; border-radius: 4px; }
|
|
417
|
+
.panel-points { font-size: 12px; font-weight: 700; color: #3b82f6; }
|
|
418
|
+
.field-grid { display: flex; flex-direction: column; gap: 1px; background: #fff; border-radius: 10px; padding: 4px 0; overflow: hidden; flex-shrink: 0; }
|
|
419
|
+
.field-row { display: flex; align-items: center; background: rgba(255,255,255,0.25); padding: 0; min-height: 38px; }
|
|
420
|
+
.field-label { width: 64px; flex-shrink: 0; padding: 8px 12px; font-size: 11px; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.3px; }
|
|
421
|
+
.field-value { flex: 1; padding: 8px 12px; font-size: 13px; color: var(--text-primary); font-weight: 500; border: none; background: none; text-align: left; font-family: inherit; }
|
|
422
|
+
.field-date-input { flex: 1; padding: 6px 10px; border: 1px solid rgba(0,0,0,0.08); border-radius: 6px; font-size: 13px; background: transparent; font-family: inherit; color: var(--text-primary); }
|
|
423
|
+
.sprint-assign { display: flex; align-items: center; gap: 8px; }
|
|
424
|
+
.sprint-btn { border: none; border-radius: 6px; padding: 2px 8px; font-size: 11px; cursor: pointer; font-weight: 500; }
|
|
425
|
+
.sprint-btn--assign { background: rgba(59,130,246,0.12); color: #2563EB; }
|
|
426
|
+
.sprint-btn--remove { background: rgba(239,68,68,0.12); color: #dc2626; }
|
|
427
|
+
.field-value--clickable { cursor: pointer; display: flex; align-items: center; gap: 6px; border-radius: 4px; transition: background 0.1s; }
|
|
428
|
+
.field-value--clickable:hover { background: rgba(0,0,0,0.02); }
|
|
429
|
+
.field-chevron { font-size: 10px; color: #94a3b8; margin-left: auto; }
|
|
430
|
+
.field-dropdown { position: relative; flex: 1; }
|
|
431
|
+
.field-menu { background: #fff; border: 1px solid rgba(0,0,0,0.1); border-radius: 8px; box-shadow: 0 4px 16px rgba(0,0,0,0.15); padding: 4px; max-height: 240px; overflow-y: auto; }
|
|
432
|
+
.field-menu-item { display: flex; align-items: center; gap: 8px; padding: 7px 10px; font-size: 13px; color: #475569; border-radius: 6px; cursor: pointer; transition: all 0.1s; }
|
|
433
|
+
.field-menu-item:hover { background: rgba(0,0,0,0.04); color: var(--text-primary); }
|
|
434
|
+
.field-menu-item.active { background: #eff6ff; color: #3b82f6; font-weight: 600; }
|
|
435
|
+
.status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
|
436
|
+
.field-placeholder { color: #94a3b8; font-size: 12px; }
|
|
437
|
+
.figma-link { color: #a259ff; font-size: 13px; font-weight: 500; text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
438
|
+
.figma-link:hover { text-decoration: underline; }
|
|
439
|
+
.figma-edit { flex: 1; display: flex; align-items: center; gap: 4px; padding: 4px 8px; }
|
|
440
|
+
.figma-input { flex: 1; padding: 4px 8px; border: 1px solid rgba(0,0,0,0.06); border-radius: 4px; font-size: 12px; font-family: inherit; color: var(--text-primary); min-width: 0; }
|
|
441
|
+
.figma-input:focus { outline: none; border-color: #a259ff; }
|
|
442
|
+
.figma-save { padding: 3px 8px; background: var(--text-primary); color: #fff; border: none; border-radius: 4px; font-size: 11px; font-weight: 600; cursor: pointer; white-space: nowrap; }
|
|
443
|
+
.figma-cancel { width: 24px; height: 24px; border: none; background: none; color: #94a3b8; font-size: 16px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
|
444
|
+
.panel-section { background: #fff; border-radius: 10px; padding: 14px 16px; flex-shrink: 0; }
|
|
445
|
+
.panel-section h3 { font-size: 12px; font-weight: 700; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
|
|
446
|
+
.task-counter { font-weight: 400; color: #94a3b8; margin-left: 4px; }
|
|
447
|
+
.desc-text { font-size: 13px; color: #334155; line-height: 1.7; padding: 10px 12px; background: rgba(0,0,0,0.02); border-radius: 6px; border-left: 3px solid rgba(0,0,0,0.06); white-space: pre-wrap; }
|
|
448
|
+
.task-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
|
449
|
+
.task-header h3 { margin-bottom: 0; }
|
|
450
|
+
.add-task-btn { padding: 3px 10px; border: 1px dashed #cbd5e1; border-radius: 4px; background: none; font-size: 11px; font-weight: 600; color: var(--text-secondary); cursor: pointer; transition: all 0.15s; }
|
|
451
|
+
.add-task-btn:hover { border-color: #3b82f6; color: #3b82f6; }
|
|
452
|
+
.add-task-form { margin-top: 6px; display: flex; flex-direction: column; gap: 6px; }
|
|
453
|
+
.add-task-input { width: 100%; padding: 6px 10px; border: 1px solid rgba(0,0,0,0.06); border-radius: 6px; font-size: 12px; font-family: inherit; color: var(--text-primary); box-sizing: border-box; }
|
|
454
|
+
.add-task-input:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.1); }
|
|
455
|
+
.add-task-actions { display: flex; gap: 6px; }
|
|
456
|
+
.add-task-submit { padding: 4px 12px; border: none; border-radius: 4px; background: var(--text-primary); color: #fff; font-size: 11px; font-weight: 600; cursor: pointer; }
|
|
457
|
+
.add-task-submit:disabled { background: #cbd5e1; cursor: not-allowed; }
|
|
458
|
+
.add-task-cancel { padding: 4px 12px; border: 1px solid rgba(0,0,0,0.06); border-radius: 4px; background: rgba(255,255,255,0.25); color: var(--text-secondary); font-size: 11px; cursor: pointer; }
|
|
459
|
+
.ac-list { display: flex; flex-direction: column; gap: 4px; }
|
|
460
|
+
.ac-item { display: flex; align-items: flex-start; gap: 8px; padding: 6px 10px; background: rgba(0,0,0,0.02); border-radius: 6px; border-left: 3px solid rgba(0,0,0,0.06); cursor: pointer; transition: all 0.15s; }
|
|
461
|
+
.ac-item:hover { background: rgba(0,0,0,0.04); }
|
|
462
|
+
.ac-item--done { border-left-color: #22c55e; }
|
|
463
|
+
.ac-item--done .ac-text { text-decoration: line-through; color: #94a3b8; }
|
|
464
|
+
.ac-check { margin-top: 2px; accent-color: #22c55e; cursor: pointer; flex-shrink: 0; }
|
|
465
|
+
.ac-text { font-size: 13px; color: #334155; line-height: 1.5; }
|
|
466
|
+
.panel-empty { font-size: 13px; color: #cbd5e1; padding: 8px 0; }
|
|
467
|
+
.panel-tasks { display: flex; flex-direction: column; gap: 2px; }
|
|
468
|
+
.panel-meta { margin-top: auto; padding-top: 12px; border-top: 1px solid rgba(0,0,0,0.04); display: flex; gap: 16px; font-size: 11px; color: #94a3b8; }
|
|
469
|
+
.panel-prs { margin-bottom: 16px; }
|
|
470
|
+
.panel-section-title { font-size: 12px; font-weight: 600; color: var(--text-muted, #888); margin-bottom: 8px; }
|
|
471
|
+
.pr-item { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 13px; }
|
|
472
|
+
.pr-link { color: #3b82f6; font-weight: 600; text-decoration: none; }
|
|
473
|
+
.pr-link:hover { text-decoration: underline; }
|
|
474
|
+
.pr-title-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
475
|
+
.pr-status { font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 4px; }
|
|
476
|
+
.pr--open { background: #dcfce7; color: #16a34a; }
|
|
477
|
+
.pr--merged { background: #e0e7ff; color: #4338ca; }
|
|
478
|
+
.pr--closed { background: #f3f4f6; color: #6b7280; }
|
|
479
|
+
.merge-ok-btn { width: 100%; margin-top: 12px; padding: 8px; border-radius: 8px; font-size: 13px; font-weight: 600; background: #e5e7eb; color: #6b7280; border: none; cursor: not-allowed; }
|
|
480
|
+
.merge-ok-active { background: #22c55e; color: #fff; cursor: pointer; }
|
|
481
|
+
.merge-ok-active:hover { background: #16a34a; }
|
|
482
|
+
.qa-note { font-size: 12px; color: var(--text-muted, #888); margin-bottom: 12px; }
|
|
483
|
+
.dor-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 1000; display: flex; align-items: center; justify-content: center; }
|
|
484
|
+
.dor-modal { background: #fff; border-radius: 12px; padding: 24px; width: 400px; max-width: 92vw; box-shadow: 0 8px 32px rgba(0,0,0,0.2); }
|
|
485
|
+
.dor-title { font-size: 16px; font-weight: 700; color: var(--text-primary); margin-bottom: 4px; }
|
|
486
|
+
.dor-desc { font-size: 12px; color: var(--text-secondary); margin-bottom: 16px; }
|
|
487
|
+
.dor-checks { display: flex; flex-direction: column; gap: 10px; margin-bottom: 20px; }
|
|
488
|
+
.dor-check { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #334155; cursor: pointer; }
|
|
489
|
+
.dor-check input { accent-color: #22c55e; cursor: pointer; }
|
|
490
|
+
.dor-actions { display: flex; gap: 8px; justify-content: flex-end; }
|
|
491
|
+
.dor-confirm { padding: 8px 16px; background: var(--text-primary); color: #fff; border: none; border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer; }
|
|
492
|
+
.dor-confirm:disabled { background: #cbd5e1; cursor: not-allowed; }
|
|
493
|
+
.dor-cancel { padding: 8px 16px; background: rgba(255,255,255,0.25); color: var(--text-secondary); border: 1px solid rgba(0,0,0,0.06); border-radius: 6px; font-size: 13px; cursor: pointer; }
|
|
494
|
+
@media (max-width: 767px) { .panel { width: 100vw; } }
|
|
495
|
+
</style>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { PmStory } from '@/composables/usePmStore'
|
|
3
|
+
|
|
4
|
+
defineProps<{ story: PmStory }>()
|
|
5
|
+
defineEmits<{ (e: 'click'): void }>()
|
|
6
|
+
|
|
7
|
+
function statusColor(status: string): string {
|
|
8
|
+
const map: Record<string, string> = { done: '#22c55e', 'in-progress': '#f59e0b', review: '#8b5cf6', backlog: '#94a3b8', draft: '#94a3b8', blocked: '#ef4444' }
|
|
9
|
+
return map[status] || '#6b7280'
|
|
10
|
+
}
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<template>
|
|
14
|
+
<div class="task-card" @click="$emit('click')" draggable="true">
|
|
15
|
+
<div class="task-header">
|
|
16
|
+
<span class="task-id">S{{ story.id }}</span>
|
|
17
|
+
<span class="task-status-dot" :style="{ background: statusColor(story.status) }" />
|
|
18
|
+
</div>
|
|
19
|
+
<div class="task-title">{{ story.title }}</div>
|
|
20
|
+
<div class="task-footer">
|
|
21
|
+
<span v-if="story.assignee" class="task-assignee">{{ story.assignee }}</span>
|
|
22
|
+
<span v-if="story.storyPoints" class="task-sp">{{ story.storyPoints }} SP</span>
|
|
23
|
+
<span class="task-priority" :class="'priority--' + story.priority">{{ story.priority }}</span>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
</template>
|
|
27
|
+
|
|
28
|
+
<style scoped>
|
|
29
|
+
.task-card { padding: 12px 14px; background: var(--card-bg, #fff); border: 1px solid var(--border-light, #e2e8f0); border-radius: 10px; cursor: pointer; transition: all 0.15s; }
|
|
30
|
+
.task-card:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); transform: translateY(-1px); }
|
|
31
|
+
.task-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
|
|
32
|
+
.task-id { font-size: 11px; font-weight: 600; color: var(--text-muted); }
|
|
33
|
+
.task-status-dot { width: 8px; height: 8px; border-radius: 50%; }
|
|
34
|
+
.task-title { font-size: 13px; font-weight: 500; color: var(--text-primary); line-height: 1.4; margin-bottom: 8px; }
|
|
35
|
+
.task-footer { display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--text-secondary); }
|
|
36
|
+
.task-assignee { font-weight: 500; }
|
|
37
|
+
.task-sp { background: rgba(59,130,246,0.1); color: #3b82f6; padding: 1px 6px; border-radius: 4px; font-weight: 600; }
|
|
38
|
+
.task-priority { padding: 1px 6px; border-radius: 4px; font-weight: 600; font-size: 10px; }
|
|
39
|
+
.priority--high { background: rgba(239,68,68,0.1); color: #ef4444; }
|
|
40
|
+
.priority--medium { background: rgba(245,158,11,0.1); color: #f59e0b; }
|
|
41
|
+
.priority--low { background: rgba(34,197,94,0.1); color: #22c55e; }
|
|
42
|
+
</style>
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { RetroItem, RetroPhase } from '@/composables/useRetro'
|
|
3
3
|
|
|
4
|
+
const AUTHOR_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316']
|
|
5
|
+
|
|
6
|
+
function authorColor(name: string): string {
|
|
7
|
+
let hash = 0
|
|
8
|
+
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash)
|
|
9
|
+
return AUTHOR_COLORS[Math.abs(hash) % AUTHOR_COLORS.length]
|
|
10
|
+
}
|
|
11
|
+
|
|
4
12
|
const props = defineProps<{
|
|
5
13
|
item: RetroItem
|
|
6
14
|
phase: RetroPhase
|
|
@@ -15,10 +23,13 @@ const emit = defineEmits<{
|
|
|
15
23
|
</script>
|
|
16
24
|
|
|
17
25
|
<template>
|
|
18
|
-
<div class="retro-card" :class="{ voted: item.hasVoted }">
|
|
26
|
+
<div class="retro-card" :class="{ voted: item.hasVoted, mine: item.author === currentUser }">
|
|
19
27
|
<div class="card-content">{{ item.content }}</div>
|
|
20
28
|
<div class="card-footer">
|
|
21
|
-
<
|
|
29
|
+
<div class="card-author-wrap">
|
|
30
|
+
<span class="card-author-dot" :style="{ background: authorColor(item.author) }">{{ item.author.charAt(0) }}</span>
|
|
31
|
+
<span class="card-author">{{ item.author }}</span>
|
|
32
|
+
</div>
|
|
22
33
|
<div class="card-actions">
|
|
23
34
|
<button
|
|
24
35
|
v-if="phase === 'vote' || phase === 'discuss'"
|
|
@@ -71,12 +82,35 @@ const emit = defineEmits<{
|
|
|
71
82
|
gap: 8px;
|
|
72
83
|
}
|
|
73
84
|
|
|
85
|
+
.card-author-wrap {
|
|
86
|
+
display: flex;
|
|
87
|
+
align-items: center;
|
|
88
|
+
gap: 5px;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.card-author-dot {
|
|
92
|
+
width: 18px;
|
|
93
|
+
height: 18px;
|
|
94
|
+
border-radius: 50%;
|
|
95
|
+
display: flex;
|
|
96
|
+
align-items: center;
|
|
97
|
+
justify-content: center;
|
|
98
|
+
color: #fff;
|
|
99
|
+
font-size: 9px;
|
|
100
|
+
font-weight: 700;
|
|
101
|
+
flex-shrink: 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
74
104
|
.card-author {
|
|
75
105
|
font-size: 12px;
|
|
76
106
|
color: var(--text-muted);
|
|
77
107
|
font-weight: 500;
|
|
78
108
|
}
|
|
79
109
|
|
|
110
|
+
.retro-card.mine {
|
|
111
|
+
border-left: 3px solid var(--primary);
|
|
112
|
+
}
|
|
113
|
+
|
|
80
114
|
.card-actions {
|
|
81
115
|
display: flex;
|
|
82
116
|
align-items: center;
|