requ-mcp 0.2.0 → 0.5.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/README.md +64 -3
- package/dist/coverage.d.ts +28 -4
- package/dist/coverage.js +48 -6
- package/dist/coverage.js.map +1 -1
- package/dist/export-import.d.ts +10 -0
- package/dist/export-import.js +127 -0
- package/dist/export-import.js.map +1 -0
- package/dist/index.js +663 -58
- package/dist/index.js.map +1 -1
- package/dist/public/app.js +914 -0
- package/dist/public/index.html +1418 -0
- package/dist/public/style.css +458 -0
- package/dist/schema.d.ts +666 -28
- package/dist/schema.js +90 -29
- package/dist/schema.js.map +1 -1
- package/dist/sqlite-store.d.ts +43 -0
- package/dist/sqlite-store.js +210 -0
- package/dist/sqlite-store.js.map +1 -0
- package/dist/storage.d.ts +16 -4
- package/dist/storage.js +53 -19
- package/dist/storage.js.map +1 -1
- package/dist/web-api.d.ts +9 -0
- package/dist/web-api.js +789 -0
- package/dist/web-api.js.map +1 -0
- package/package.json +9 -5
|
@@ -0,0 +1,1418 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>requ — Requirements Traceability</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
|
10
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
11
|
+
<script>
|
|
12
|
+
tailwind.config = {
|
|
13
|
+
theme: {
|
|
14
|
+
extend: {
|
|
15
|
+
fontFamily: { sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'] }
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
</script>
|
|
20
|
+
<link rel="stylesheet" href="/public/style.css">
|
|
21
|
+
</head>
|
|
22
|
+
<body class="bg-slate-50 font-sans text-slate-800" x-data="requApp" x-init="init()" x-cloak>
|
|
23
|
+
|
|
24
|
+
<!-- Skip navigation — accessibility -->
|
|
25
|
+
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-[100] focus:bg-white focus:text-indigo-700 focus:px-4 focus:py-2 focus:rounded focus:shadow-lg focus:font-medium focus:text-sm">Skip to main content</a>
|
|
26
|
+
|
|
27
|
+
<!-- ═══════════════════════════════════════════════════════════
|
|
28
|
+
HEADER
|
|
29
|
+
═══════════════════════════════════════════════════════════════ -->
|
|
30
|
+
<header class="fixed top-0 left-0 right-0 z-50 bg-slate-900 text-white shadow-lg" style="height:56px;">
|
|
31
|
+
<div class="flex items-center justify-between h-full px-5 max-w-screen-2xl mx-auto">
|
|
32
|
+
<!-- Logo + project name / switcher -->
|
|
33
|
+
<div class="flex items-center gap-3 shrink-0">
|
|
34
|
+
<span class="text-indigo-400 font-bold text-xl select-none" aria-hidden="true">◈</span>
|
|
35
|
+
<h1 class="font-semibold text-white tracking-tight text-base">requ<span class="sr-only"> — Requirements dashboard</span></h1>
|
|
36
|
+
<span class="text-slate-500 text-xs font-mono" x-show="appVersion" x-text="'v' + appVersion" aria-label="version"></span>
|
|
37
|
+
<!-- Static name when single project; dropdown when multiple -->
|
|
38
|
+
<template x-if="projects.length <= 1">
|
|
39
|
+
<span class="text-slate-500 text-xs hidden sm:inline" x-text="config?.name ? '/ ' + config.name : ''"></span>
|
|
40
|
+
</template>
|
|
41
|
+
<template x-if="projects.length > 1">
|
|
42
|
+
<select
|
|
43
|
+
class="filter-select filter-select--dark text-xs bg-slate-800 border-slate-700 focus:border-indigo-400"
|
|
44
|
+
style="width:140px;"
|
|
45
|
+
:value="activeProject ? activeProject.slug : ''"
|
|
46
|
+
@change="switchProject($event.target.value)"
|
|
47
|
+
aria-label="Switch project">
|
|
48
|
+
<template x-for="p in projects" :key="p.slug">
|
|
49
|
+
<option :value="p.slug"
|
|
50
|
+
:selected="activeProject && activeProject.slug === p.slug"
|
|
51
|
+
x-text="p.slug"></option>
|
|
52
|
+
</template>
|
|
53
|
+
</select>
|
|
54
|
+
</template>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<!-- Right side: phase + stats + live -->
|
|
58
|
+
<div class="flex items-center gap-4 shrink-0">
|
|
59
|
+
<!-- Active phase badge -->
|
|
60
|
+
<div x-show="summary?.activePhase" class="flex items-center gap-2 bg-slate-800 rounded-full px-3 py-1">
|
|
61
|
+
<span class="pulse-dot"></span>
|
|
62
|
+
<span class="text-xs font-medium text-slate-200" x-text="activePhaseLabel() || summary?.activePhase"></span>
|
|
63
|
+
</div>
|
|
64
|
+
<!-- Mini stats (hidden on mobile) -->
|
|
65
|
+
<div class="hidden sm:flex items-center gap-4 text-xs text-slate-400">
|
|
66
|
+
<span>
|
|
67
|
+
<span class="text-white font-semibold" x-text="summaryVal('requirements')"></span> reqs
|
|
68
|
+
</span>
|
|
69
|
+
<span>
|
|
70
|
+
<span class="text-white font-semibold" x-text="summaryVal('stories')"></span> stories
|
|
71
|
+
</span>
|
|
72
|
+
<span class="text-indigo-400 font-semibold" x-text="pct(summaryVal('verifiedPct')) + '% verified'"></span>
|
|
73
|
+
</div>
|
|
74
|
+
<!-- Export / Import buttons -->
|
|
75
|
+
<button
|
|
76
|
+
@click="exportProject()"
|
|
77
|
+
class="flex items-center gap-1.5 text-xs text-slate-400 hover:text-slate-200 transition-colors"
|
|
78
|
+
title="Export project data as JSON"
|
|
79
|
+
aria-label="Export project data as JSON">
|
|
80
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
81
|
+
<path d="M8 2v9M4 7l4 4 4-4M2 13h12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
82
|
+
</svg>
|
|
83
|
+
<span class="hidden md:inline">Export</span>
|
|
84
|
+
</button>
|
|
85
|
+
<button
|
|
86
|
+
@click="importDialogOpen = true"
|
|
87
|
+
class="flex items-center gap-1.5 text-xs text-slate-400 hover:text-slate-200 transition-colors"
|
|
88
|
+
title="Import project data from JSON"
|
|
89
|
+
aria-label="Import project data from JSON">
|
|
90
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
91
|
+
<path d="M8 14V5M4 9l4-4 4 4M2 3h12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
92
|
+
</svg>
|
|
93
|
+
<span class="hidden md:inline">Import</span>
|
|
94
|
+
</button>
|
|
95
|
+
<!-- SSE live dot -->
|
|
96
|
+
<div class="flex items-center gap-1.5 text-xs text-slate-500" title="Live updates">
|
|
97
|
+
<span class="w-2 h-2 rounded-full bg-green-500 opacity-80" style="animation: pulse-ring 2.5s ease-out infinite;" aria-hidden="true"></span>
|
|
98
|
+
<span class="hidden sm:inline" aria-hidden="true">live</span>
|
|
99
|
+
<span class="sr-only">Live updates active</span>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</header>
|
|
104
|
+
|
|
105
|
+
<!-- ═══════════════════════════════════════════════════════════
|
|
106
|
+
TAB NAV
|
|
107
|
+
═══════════════════════════════════════════════════════════════ -->
|
|
108
|
+
<nav aria-label="Tab navigation" x-show="!notInitialized" class="fixed left-0 right-0 z-40 bg-white border-b border-slate-200 shadow-sm tab-scroll" style="top:56px;">
|
|
109
|
+
<div role="tablist" class="tab-list flex px-4 max-w-screen-2xl mx-auto" style="height:44px;">
|
|
110
|
+
<template x-if="projects.length > 1">
|
|
111
|
+
<button
|
|
112
|
+
id="tab-global"
|
|
113
|
+
role="tab"
|
|
114
|
+
aria-controls="panel-global"
|
|
115
|
+
:aria-selected="tab === 'global'"
|
|
116
|
+
:tabindex="tab === 'global' ? 0 : -1"
|
|
117
|
+
@click="navTo('global')"
|
|
118
|
+
@keydown.arrow-right.prevent="shiftFocus(1)"
|
|
119
|
+
@keydown.arrow-left.prevent="shiftFocus(-1)"
|
|
120
|
+
@keydown.home.prevent="shiftFocus(-999)"
|
|
121
|
+
@keydown.end.prevent="shiftFocus(999)"
|
|
122
|
+
class="px-4 h-full text-sm font-medium border-b-2 transition-colors whitespace-nowrap"
|
|
123
|
+
:class="tab === 'global'
|
|
124
|
+
? 'border-indigo-600 text-indigo-700'
|
|
125
|
+
: 'border-transparent text-slate-500 hover:text-slate-800 hover:border-slate-300'">
|
|
126
|
+
All Projects
|
|
127
|
+
</button>
|
|
128
|
+
</template>
|
|
129
|
+
<template x-for="t in [
|
|
130
|
+
{id:'overview', label:'Overview'},
|
|
131
|
+
{id:'requirements', label:'Requirements'},
|
|
132
|
+
{id:'stories', label:'Stories'},
|
|
133
|
+
{id:'coverage', label:'Coverage'},
|
|
134
|
+
{id:'components', label:'Components'},
|
|
135
|
+
{id:'vcs', label:'VCS'},
|
|
136
|
+
]" :key="t.id">
|
|
137
|
+
<button
|
|
138
|
+
:id="'tab-' + t.id"
|
|
139
|
+
role="tab"
|
|
140
|
+
:aria-controls="'panel-' + t.id"
|
|
141
|
+
:aria-selected="tab === t.id"
|
|
142
|
+
:tabindex="tab === t.id ? 0 : -1"
|
|
143
|
+
@click="navTo(t.id)"
|
|
144
|
+
@keydown.arrow-right.prevent="shiftFocus(1)"
|
|
145
|
+
@keydown.arrow-left.prevent="shiftFocus(-1)"
|
|
146
|
+
@keydown.home.prevent="shiftFocus(-999)"
|
|
147
|
+
@keydown.end.prevent="shiftFocus(999)"
|
|
148
|
+
class="px-4 h-full text-sm font-medium border-b-2 transition-colors whitespace-nowrap"
|
|
149
|
+
:class="tab === t.id
|
|
150
|
+
? 'border-indigo-600 text-indigo-700'
|
|
151
|
+
: 'border-transparent text-slate-500 hover:text-slate-800 hover:border-slate-300'"
|
|
152
|
+
x-text="t.label"
|
|
153
|
+
></button>
|
|
154
|
+
</template>
|
|
155
|
+
</div>
|
|
156
|
+
</nav>
|
|
157
|
+
|
|
158
|
+
<!-- ═══════════════════════════════════════════════════════════
|
|
159
|
+
MAIN CONTENT
|
|
160
|
+
═══════════════════════════════════════════════════════════════ -->
|
|
161
|
+
<main id="main-content" :class="notInitialized ? 'pt-8' : 'pt-28'" class="pb-16 min-h-screen">
|
|
162
|
+
<div class="max-w-screen-2xl mx-auto px-4 sm:px-6">
|
|
163
|
+
|
|
164
|
+
<!-- Setup card — shown when project is not initialized -->
|
|
165
|
+
<div x-show="notInitialized" class="flex items-center justify-center py-12">
|
|
166
|
+
<div class="bg-white border border-slate-200 rounded-xl shadow-sm w-full max-w-lg p-8">
|
|
167
|
+
<div class="flex items-center gap-3 mb-6">
|
|
168
|
+
<div class="w-9 h-9 rounded-lg bg-indigo-50 flex items-center justify-center shrink-0">
|
|
169
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#4f46e5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="18" x2="12" y2="12"/><line x1="9" y1="15" x2="15" y2="15"/></svg>
|
|
170
|
+
</div>
|
|
171
|
+
<div>
|
|
172
|
+
<h2 class="text-lg font-semibold text-slate-800">Set up your project</h2>
|
|
173
|
+
<p class="text-sm text-slate-500 mt-0.5">Initialize requ to start tracking requirements.</p>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
<!-- Error message -->
|
|
178
|
+
<div x-show="setupError" class="mb-4 bg-red-50 border border-red-200 text-red-700 text-sm rounded-lg px-4 py-3" x-text="setupError" role="alert"></div>
|
|
179
|
+
|
|
180
|
+
<div class="space-y-4">
|
|
181
|
+
<!-- Project name -->
|
|
182
|
+
<div>
|
|
183
|
+
<label for="setup-name" class="block text-sm font-medium text-slate-700 mb-1">Project name</label>
|
|
184
|
+
<input id="setup-name" type="text" class="search-input w-full" style="max-width:100%"
|
|
185
|
+
placeholder="My Project" x-model="setupName" :disabled="setupSubmitting" autocomplete="off">
|
|
186
|
+
</div>
|
|
187
|
+
<!-- Project key -->
|
|
188
|
+
<div>
|
|
189
|
+
<label for="setup-key" class="block text-sm font-medium text-slate-700 mb-1">
|
|
190
|
+
Project key
|
|
191
|
+
<span class="text-slate-400 font-normal ml-1">— unique short identifier</span>
|
|
192
|
+
</label>
|
|
193
|
+
<input id="setup-key" type="text" class="search-input w-full" style="max-width:100%;text-transform:uppercase"
|
|
194
|
+
placeholder="e.g. AUTH, BILLING, MVP" x-model="setupKey" :disabled="setupSubmitting"
|
|
195
|
+
@input="setupKey = setupKey.toUpperCase()" autocomplete="off" maxlength="20">
|
|
196
|
+
<p class="text-xs text-slate-400 mt-1">Uppercase letters, digits, hyphens. Used as a stable project identifier.</p>
|
|
197
|
+
</div>
|
|
198
|
+
<!-- Brief -->
|
|
199
|
+
<div>
|
|
200
|
+
<label for="setup-brief" class="block text-sm font-medium text-slate-700 mb-1">
|
|
201
|
+
Brief
|
|
202
|
+
<span class="text-slate-400 font-normal ml-1">— optional, supports Markdown</span>
|
|
203
|
+
</label>
|
|
204
|
+
<textarea id="setup-brief" rows="3"
|
|
205
|
+
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 resize-none focus:outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 transition"
|
|
206
|
+
placeholder="A short description of what this project is about…"
|
|
207
|
+
x-model="setupBrief" :disabled="setupSubmitting"></textarea>
|
|
208
|
+
</div>
|
|
209
|
+
<!-- Initial phase -->
|
|
210
|
+
<div>
|
|
211
|
+
<label for="setup-phase" class="block text-sm font-medium text-slate-700 mb-1">
|
|
212
|
+
Initial phase name
|
|
213
|
+
<span class="text-slate-400 font-normal ml-1">— optional</span>
|
|
214
|
+
</label>
|
|
215
|
+
<input id="setup-phase" type="text" class="search-input w-full" style="max-width:100%"
|
|
216
|
+
placeholder="e.g. Phase 1 MVP" x-model="setupPhase" :disabled="setupSubmitting" autocomplete="off">
|
|
217
|
+
<p class="text-xs text-slate-400 mt-1">A first phase will be created and set as active.</p>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
<div class="mt-6 flex items-center gap-3">
|
|
222
|
+
<button class="btn-primary flex items-center gap-2" @click="submitInit()" :disabled="setupSubmitting">
|
|
223
|
+
<span x-show="setupSubmitting" class="spinner" style="width:13px;height:13px;border-width:2px;" aria-hidden="true"></span>
|
|
224
|
+
<span x-text="setupSubmitting ? 'Initializing…' : 'Initialize project'"></span>
|
|
225
|
+
</button>
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
<!-- Tab panels — hidden while project is not initialized -->
|
|
231
|
+
<div x-show="!notInitialized">
|
|
232
|
+
|
|
233
|
+
<!-- ══════════════════════════════════════════════════════
|
|
234
|
+
TAB: ALL PROJECTS (global dashboard)
|
|
235
|
+
══════════════════════════════════════════════════════════ -->
|
|
236
|
+
<div id="panel-global" role="tabpanel" aria-labelledby="tab-global" tabindex="0" x-show="tab === 'global'" class="tab-panel space-y-6">
|
|
237
|
+
|
|
238
|
+
<!-- Aggregate KPI strip -->
|
|
239
|
+
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
240
|
+
<div class="kpi-card">
|
|
241
|
+
<div class="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Total Requirements</div>
|
|
242
|
+
<div class="text-3xl font-bold text-slate-900" x-text="globalTotalReqs()"></div>
|
|
243
|
+
<div class="text-xs text-slate-500 mt-1">across all projects</div>
|
|
244
|
+
</div>
|
|
245
|
+
<div class="kpi-card">
|
|
246
|
+
<div class="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Total Stories</div>
|
|
247
|
+
<div class="text-3xl font-bold text-slate-900" x-text="globalTotalStories()"></div>
|
|
248
|
+
<div class="text-xs text-slate-500 mt-1">across all projects</div>
|
|
249
|
+
</div>
|
|
250
|
+
<div class="kpi-card">
|
|
251
|
+
<div class="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Verified</div>
|
|
252
|
+
<div class="text-3xl font-bold text-green-600"
|
|
253
|
+
x-text="pct(globalWeightedPct('verifiedPct')) + '%'"></div>
|
|
254
|
+
<div class="text-xs text-slate-500 mt-1">weighted average</div>
|
|
255
|
+
<div class="progress-mini mt-2">
|
|
256
|
+
<div class="progress-mini-fill bg-green-500"
|
|
257
|
+
:style="'width:' + globalWeightedPct('verifiedPct') + '%'"></div>
|
|
258
|
+
</div>
|
|
259
|
+
</div>
|
|
260
|
+
<div class="kpi-card border-indigo-100 bg-gradient-to-br from-indigo-50 to-white">
|
|
261
|
+
<div class="text-xs font-semibold text-indigo-600 uppercase tracking-wide mb-1">Story Coverage</div>
|
|
262
|
+
<div class="text-4xl font-bold text-indigo-600"
|
|
263
|
+
x-text="pct(globalWeightedPct('storyCoveragePct')) + '%'"></div>
|
|
264
|
+
<div class="text-xs text-slate-500 mt-1">weighted average</div>
|
|
265
|
+
<div class="progress-mini mt-2">
|
|
266
|
+
<div class="progress-mini-fill bg-indigo-500"
|
|
267
|
+
:style="'width:' + globalWeightedPct('storyCoveragePct') + '%'"></div>
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
<!-- Per-project table -->
|
|
273
|
+
<div class="section-card">
|
|
274
|
+
<div class="section-card-header">
|
|
275
|
+
Projects
|
|
276
|
+
<span class="text-xs font-normal text-slate-500 hidden sm:inline">click a row to open that project</span>
|
|
277
|
+
</div>
|
|
278
|
+
|
|
279
|
+
<template x-if="loading.global && globalSummary.length === 0">
|
|
280
|
+
<div class="p-6 space-y-3">
|
|
281
|
+
<template x-for="i in [1,2,3]" :key="i">
|
|
282
|
+
<div class="skeleton skeleton-md"></div>
|
|
283
|
+
</template>
|
|
284
|
+
</div>
|
|
285
|
+
</template>
|
|
286
|
+
|
|
287
|
+
<template x-if="!loading.global && globalSummary.length === 0">
|
|
288
|
+
<div class="empty-state">
|
|
289
|
+
<svg width="40" height="40" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true">
|
|
290
|
+
<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/>
|
|
291
|
+
<rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>
|
|
292
|
+
</svg>
|
|
293
|
+
<h3>No initialized projects</h3>
|
|
294
|
+
</div>
|
|
295
|
+
</template>
|
|
296
|
+
|
|
297
|
+
<template x-if="globalSummary.length > 0">
|
|
298
|
+
<div class="data-table-wrap">
|
|
299
|
+
<table class="data-table">
|
|
300
|
+
<thead><tr>
|
|
301
|
+
<th scope="col">Project</th>
|
|
302
|
+
<th scope="col" style="width:110px">Active Phase</th>
|
|
303
|
+
<th scope="col" class="text-center" style="width:90px">Reqs</th>
|
|
304
|
+
<th scope="col" class="text-center" style="width:90px">Stories</th>
|
|
305
|
+
<th scope="col" style="width:170px">Verified %</th>
|
|
306
|
+
<th scope="col" style="width:170px">Story Coverage</th>
|
|
307
|
+
</tr></thead>
|
|
308
|
+
<tbody>
|
|
309
|
+
<template x-for="proj in globalSummary" :key="proj.slug">
|
|
310
|
+
<tr @click="switchProject(proj.slug); navTo('overview')">
|
|
311
|
+
<td>
|
|
312
|
+
<div class="font-semibold text-slate-800" x-text="proj.name"></div>
|
|
313
|
+
<code class="chip chip-indigo mt-0.5 inline-block" x-text="proj.slug"></code>
|
|
314
|
+
</td>
|
|
315
|
+
<td>
|
|
316
|
+
<span x-show="proj.activePhase" class="badge badge-green" x-text="proj.activePhase"></span>
|
|
317
|
+
<span x-show="!proj.activePhase" class="text-slate-400 text-xs">—</span>
|
|
318
|
+
</td>
|
|
319
|
+
<td class="text-center font-semibold text-slate-700" x-text="proj.requirements"></td>
|
|
320
|
+
<td class="text-center font-semibold text-slate-700" x-text="proj.stories"></td>
|
|
321
|
+
<td>
|
|
322
|
+
<div class="flex items-center gap-2">
|
|
323
|
+
<div class="progress-track flex-1">
|
|
324
|
+
<div class="progress-fill progress-fill--green"
|
|
325
|
+
:style="'width:' + (proj.verifiedPct || 0) + '%'"></div>
|
|
326
|
+
</div>
|
|
327
|
+
<span class="text-xs font-semibold text-slate-600 w-10 text-right"
|
|
328
|
+
x-text="pct(proj.verifiedPct) + '%'"></span>
|
|
329
|
+
</div>
|
|
330
|
+
</td>
|
|
331
|
+
<td>
|
|
332
|
+
<div class="flex items-center gap-2">
|
|
333
|
+
<div class="progress-track flex-1">
|
|
334
|
+
<div class="progress-fill progress-fill--indigo"
|
|
335
|
+
:style="'width:' + (proj.storyCoveragePct || 0) + '%'"></div>
|
|
336
|
+
</div>
|
|
337
|
+
<span class="text-xs font-semibold text-slate-600 w-10 text-right"
|
|
338
|
+
x-text="pct(proj.storyCoveragePct) + '%'"></span>
|
|
339
|
+
</div>
|
|
340
|
+
</td>
|
|
341
|
+
</tr>
|
|
342
|
+
</template>
|
|
343
|
+
</tbody>
|
|
344
|
+
</table>
|
|
345
|
+
</div>
|
|
346
|
+
</template>
|
|
347
|
+
</div>
|
|
348
|
+
|
|
349
|
+
</div>
|
|
350
|
+
|
|
351
|
+
<!-- ══════════════════════════════════════════════════════
|
|
352
|
+
TAB: OVERVIEW
|
|
353
|
+
══════════════════════════════════════════════════════════ -->
|
|
354
|
+
<div id="panel-overview" role="tabpanel" aria-labelledby="tab-overview" tabindex="0" x-show="tab === 'overview'" class="tab-panel space-y-6">
|
|
355
|
+
|
|
356
|
+
<!-- Project brief -->
|
|
357
|
+
<div class="section-card" x-show="config && (config.brief || !briefEditing)">
|
|
358
|
+
<div class="px-5 py-4 flex items-start gap-3">
|
|
359
|
+
|
|
360
|
+
<!-- View mode -->
|
|
361
|
+
<div class="flex-1 min-w-0" x-show="!briefEditing">
|
|
362
|
+
|
|
363
|
+
<!-- Project key badge -->
|
|
364
|
+
<div x-show="config && config.key" class="mb-3">
|
|
365
|
+
<span class="inline-flex items-center font-mono text-xs font-semibold tracking-wider bg-indigo-50 text-indigo-700 border border-indigo-100 rounded px-2 py-0.5 select-all"
|
|
366
|
+
:title="'Project key: ' + (config && config.key || '')"
|
|
367
|
+
x-text="config && config.key"></span>
|
|
368
|
+
</div>
|
|
369
|
+
|
|
370
|
+
<!-- Brief content with expand/collapse -->
|
|
371
|
+
<div x-show="config && config.brief">
|
|
372
|
+
<div class="brief-content text-sm brief-collapse"
|
|
373
|
+
:class="briefExpanded ? 'brief-expanded' : ''"
|
|
374
|
+
x-html="renderMarkdown((config && config.brief) || '')"
|
|
375
|
+
x-ref="briefContent"
|
|
376
|
+
x-effect="config && config.brief && $nextTick(() => { briefOverflows = $refs.briefContent ? $refs.briefContent.scrollHeight > $refs.briefContent.clientHeight + 4 : false; })">
|
|
377
|
+
</div>
|
|
378
|
+
<button x-show="briefOverflows || briefExpanded"
|
|
379
|
+
class="mt-2 text-xs text-indigo-500 hover:text-indigo-700 transition-colors flex items-center gap-1 font-medium"
|
|
380
|
+
@click="briefExpanded = !briefExpanded"
|
|
381
|
+
x-text="briefExpanded ? '↑ Show less' : '↓ Show more'">
|
|
382
|
+
</button>
|
|
383
|
+
</div>
|
|
384
|
+
|
|
385
|
+
<p x-show="!config || !config.brief" class="text-sm text-slate-400 italic">No description yet. Click the pencil to add one.</p>
|
|
386
|
+
</div>
|
|
387
|
+
|
|
388
|
+
<!-- Edit mode -->
|
|
389
|
+
<div class="flex-1 min-w-0 space-y-2" x-show="briefEditing">
|
|
390
|
+
<textarea rows="4"
|
|
391
|
+
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 resize-y focus:outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 transition"
|
|
392
|
+
placeholder="Describe this project (Markdown supported)…"
|
|
393
|
+
x-model="briefDraft" :disabled="briefSaving"
|
|
394
|
+
x-ref="briefTextarea"
|
|
395
|
+
@keydown.escape="briefEditing = false"></textarea>
|
|
396
|
+
<p x-show="briefError" class="text-xs text-red-600" x-text="briefError"></p>
|
|
397
|
+
<div class="flex items-center gap-2">
|
|
398
|
+
<button class="btn-primary text-xs flex items-center gap-1.5" @click="saveBrief()" :disabled="briefSaving">
|
|
399
|
+
<span x-show="briefSaving" class="spinner" style="width:11px;height:11px;border-width:2px;" aria-hidden="true"></span>
|
|
400
|
+
<span x-text="briefSaving ? 'Saving…' : 'Save'"></span>
|
|
401
|
+
</button>
|
|
402
|
+
<button class="btn-secondary text-xs" @click="briefEditing = false; briefError = null" :disabled="briefSaving">Cancel</button>
|
|
403
|
+
</div>
|
|
404
|
+
</div>
|
|
405
|
+
|
|
406
|
+
<!-- Edit pencil button (view mode only) -->
|
|
407
|
+
<button x-show="!briefEditing"
|
|
408
|
+
@click="briefDraft = (config && config.brief) || ''; briefEditing = true; $nextTick(function() { $refs.briefTextarea && $refs.briefTextarea.focus(); })"
|
|
409
|
+
class="shrink-0 p-1.5 text-slate-300 hover:text-indigo-500 transition-colors rounded"
|
|
410
|
+
aria-label="Edit project description" title="Edit description">
|
|
411
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
412
|
+
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/>
|
|
413
|
+
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
|
414
|
+
</svg>
|
|
415
|
+
</button>
|
|
416
|
+
</div>
|
|
417
|
+
</div>
|
|
418
|
+
|
|
419
|
+
<!-- KPI Cards -->
|
|
420
|
+
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
421
|
+
|
|
422
|
+
<div class="kpi-card">
|
|
423
|
+
<div class="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Requirements</div>
|
|
424
|
+
<div class="text-3xl font-bold text-slate-900" x-text="summaryVal('requirements')"></div>
|
|
425
|
+
<div class="text-xs text-slate-500 mt-1">
|
|
426
|
+
<span class="text-green-600 font-semibold" x-text="pct(summaryVal('verifiedPct')) + '%'"></span> verified
|
|
427
|
+
</div>
|
|
428
|
+
<div class="progress-mini mt-2">
|
|
429
|
+
<div class="progress-mini-fill bg-green-500" :style="'width:' + summaryVal('verifiedPct') + '%'"></div>
|
|
430
|
+
</div>
|
|
431
|
+
</div>
|
|
432
|
+
|
|
433
|
+
<div class="kpi-card">
|
|
434
|
+
<div class="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Stories</div>
|
|
435
|
+
<div class="text-3xl font-bold text-slate-900" x-text="summaryVal('stories')"></div>
|
|
436
|
+
<div class="text-xs text-slate-500 mt-1">
|
|
437
|
+
<span class="text-indigo-600 font-semibold" x-text="pct(summaryVal('storyCoveragePct')) + '%'"></span> linked
|
|
438
|
+
</div>
|
|
439
|
+
<div class="progress-mini mt-2">
|
|
440
|
+
<div class="progress-mini-fill bg-indigo-500" :style="'width:' + summaryVal('storyCoveragePct') + '%'"></div>
|
|
441
|
+
</div>
|
|
442
|
+
</div>
|
|
443
|
+
|
|
444
|
+
<div class="kpi-card">
|
|
445
|
+
<div class="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Scenarios</div>
|
|
446
|
+
<div class="text-3xl font-bold text-slate-900">
|
|
447
|
+
<span x-text="summaryVal('scenariosPassing')"></span>
|
|
448
|
+
<span class="text-slate-300 text-xl">/</span>
|
|
449
|
+
<span class="text-xl text-slate-400" x-text="summaryVal('scenariosLinked')"></span>
|
|
450
|
+
</div>
|
|
451
|
+
<div class="text-xs text-slate-500 mt-1">passing / linked</div>
|
|
452
|
+
<div class="progress-mini mt-2">
|
|
453
|
+
<div class="progress-mini-fill bg-emerald-500"
|
|
454
|
+
:style="'width:' + (summaryVal('scenariosLinked') > 0 ? (summaryVal('scenariosPassing') / summaryVal('scenariosLinked') * 100).toFixed(1) : 0) + '%'">
|
|
455
|
+
</div>
|
|
456
|
+
</div>
|
|
457
|
+
</div>
|
|
458
|
+
|
|
459
|
+
<div class="kpi-card border-indigo-100 bg-gradient-to-br from-indigo-50 to-white">
|
|
460
|
+
<div class="text-xs font-semibold text-indigo-500 uppercase tracking-wide mb-1">Verified</div>
|
|
461
|
+
<div class="text-4xl font-bold text-indigo-600" x-text="pct(summaryVal('verifiedPct')) + '%'"></div>
|
|
462
|
+
<div class="text-xs text-slate-500 mt-1">of all requirements</div>
|
|
463
|
+
<div class="progress-mini mt-2">
|
|
464
|
+
<div class="progress-mini-fill bg-indigo-500" :style="'width:' + summaryVal('verifiedPct') + '%'"></div>
|
|
465
|
+
</div>
|
|
466
|
+
</div>
|
|
467
|
+
</div>
|
|
468
|
+
|
|
469
|
+
<!-- Charts row -->
|
|
470
|
+
<div class="grid grid-cols-1 xl:grid-cols-3 gap-5">
|
|
471
|
+
|
|
472
|
+
<!-- Coverage Trend -->
|
|
473
|
+
<div class="section-card xl:col-span-2">
|
|
474
|
+
<div class="section-card-header">
|
|
475
|
+
Coverage Trend
|
|
476
|
+
<span class="text-xs font-normal text-slate-400">by phase</span>
|
|
477
|
+
</div>
|
|
478
|
+
<div class="p-4">
|
|
479
|
+
<template x-if="loading.trend">
|
|
480
|
+
<div class="chart-container flex items-center justify-center">
|
|
481
|
+
<div class="spinner"></div>
|
|
482
|
+
</div>
|
|
483
|
+
</template>
|
|
484
|
+
<template x-if="!loading.trend && (!trend || trend.length === 0)">
|
|
485
|
+
<div class="empty-state chart-container">
|
|
486
|
+
<svg width="36" height="36" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
|
487
|
+
<h3>No trend data yet</h3>
|
|
488
|
+
<p>Complete phases to see coverage over time.</p>
|
|
489
|
+
</div>
|
|
490
|
+
</template>
|
|
491
|
+
<template x-if="!loading.trend && trend && trend.length > 0">
|
|
492
|
+
<div class="chart-container">
|
|
493
|
+
<canvas x-ref="trendCanvas" x-init="$nextTick(() => initTrendChart($refs.trendCanvas))"></canvas>
|
|
494
|
+
</div>
|
|
495
|
+
</template>
|
|
496
|
+
</div>
|
|
497
|
+
</div>
|
|
498
|
+
|
|
499
|
+
<!-- Donut by component -->
|
|
500
|
+
<div class="section-card">
|
|
501
|
+
<div class="section-card-header">
|
|
502
|
+
By Component
|
|
503
|
+
<span class="text-xs font-normal text-slate-400">verified %</span>
|
|
504
|
+
</div>
|
|
505
|
+
<div class="p-4">
|
|
506
|
+
<template x-if="loading.coverage">
|
|
507
|
+
<div class="chart-container-sm flex items-center justify-center">
|
|
508
|
+
<div class="spinner"></div>
|
|
509
|
+
</div>
|
|
510
|
+
</template>
|
|
511
|
+
<template x-if="!loading.coverage && coverage && (coverage.byComponent || []).length > 0">
|
|
512
|
+
<div class="chart-container-sm">
|
|
513
|
+
<canvas x-ref="donutCanvas" x-init="$nextTick(() => initDonutChart($refs.donutCanvas))"></canvas>
|
|
514
|
+
</div>
|
|
515
|
+
</template>
|
|
516
|
+
<template x-if="!loading.coverage && (!coverage || (coverage.byComponent || []).length === 0)">
|
|
517
|
+
<div class="empty-state" style="height:200px;">
|
|
518
|
+
<h3>No component data</h3>
|
|
519
|
+
</div>
|
|
520
|
+
</template>
|
|
521
|
+
</div>
|
|
522
|
+
</div>
|
|
523
|
+
</div>
|
|
524
|
+
|
|
525
|
+
<!-- Gaps summary -->
|
|
526
|
+
<div class="section-card">
|
|
527
|
+
<div class="section-card-header">
|
|
528
|
+
Coverage Gaps
|
|
529
|
+
<button @click="navTo('coverage')" class="text-xs text-indigo-600 hover:text-indigo-800 font-medium">See full report →</button>
|
|
530
|
+
</div>
|
|
531
|
+
<div class="p-4">
|
|
532
|
+
<template x-if="loading.gaps">
|
|
533
|
+
<div class="grid grid-cols-3 gap-3">
|
|
534
|
+
<div class="skeleton skeleton-lg"></div>
|
|
535
|
+
<div class="skeleton skeleton-lg"></div>
|
|
536
|
+
<div class="skeleton skeleton-lg"></div>
|
|
537
|
+
</div>
|
|
538
|
+
</template>
|
|
539
|
+
<template x-if="!loading.gaps">
|
|
540
|
+
<div>
|
|
541
|
+
<div x-show="gaps && (gaps.requirementsWithoutStory || []).length === 0 && (gaps.storiesWithoutScenario || []).length === 0 && (gaps.storiesNotCovered || []).length === 0"
|
|
542
|
+
class="flex items-center gap-2 text-green-700 text-sm font-medium bg-green-50 border border-green-200 rounded-lg px-4 py-3">
|
|
543
|
+
<span>✓</span> No gaps — all requirements have stories and all stories are covered.
|
|
544
|
+
</div>
|
|
545
|
+
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4"
|
|
546
|
+
x-show="!gaps || (gaps.requirementsWithoutStory || []).length > 0 || (gaps.storiesWithoutScenario || []).length > 0 || (gaps.storiesNotCovered || []).length > 0">
|
|
547
|
+
<button @click="navTo('coverage')" class="gap-panel-red text-left hover:opacity-90 transition-opacity">
|
|
548
|
+
<div class="text-2xl font-bold text-red-700" x-text="(gaps?.requirementsWithoutStory || []).length"></div>
|
|
549
|
+
<div class="text-sm font-semibold text-red-800 mt-0.5">Requirements without stories</div>
|
|
550
|
+
<div class="text-xs text-red-600 mt-1">need user story coverage</div>
|
|
551
|
+
</button>
|
|
552
|
+
<button @click="navTo('coverage')" class="gap-panel-amber text-left hover:opacity-90 transition-opacity">
|
|
553
|
+
<div class="text-2xl font-bold text-amber-700" x-text="(gaps?.storiesWithoutScenario || []).length"></div>
|
|
554
|
+
<div class="text-sm font-semibold text-amber-800 mt-0.5">Stories without scenarios</div>
|
|
555
|
+
<div class="text-xs text-amber-600 mt-1">need test scenarios</div>
|
|
556
|
+
</button>
|
|
557
|
+
<button @click="navTo('coverage')" class="gap-panel-slate text-left hover:opacity-90 transition-opacity">
|
|
558
|
+
<div class="text-2xl font-bold text-slate-700" x-text="(gaps?.storiesNotCovered || []).length"></div>
|
|
559
|
+
<div class="text-sm font-semibold text-slate-700 mt-0.5">Stories not covered</div>
|
|
560
|
+
<div class="text-xs text-slate-500 mt-1">failing or pending scenarios</div>
|
|
561
|
+
</button>
|
|
562
|
+
</div>
|
|
563
|
+
</div>
|
|
564
|
+
</template>
|
|
565
|
+
</div>
|
|
566
|
+
</div>
|
|
567
|
+
|
|
568
|
+
<!-- Phases strip -->
|
|
569
|
+
<div x-show="phases.length > 0" class="section-card">
|
|
570
|
+
<div class="section-card-header">Phases</div>
|
|
571
|
+
<div class="p-4 flex flex-wrap gap-3">
|
|
572
|
+
<template x-for="phase in [...phases].sort((a,b) => a.order - b.order)" :key="phase.id">
|
|
573
|
+
<div class="flex items-center gap-2 bg-slate-50 border border-slate-200 rounded-lg px-4 py-2.5">
|
|
574
|
+
<template x-if="phase.status === 'active'"><span class="pulse-dot"></span></template>
|
|
575
|
+
<template x-if="phase.status === 'completed'">
|
|
576
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#16a34a" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
|
577
|
+
</template>
|
|
578
|
+
<template x-if="phase.status === 'planned'"><span class="w-2 h-2 rounded-full bg-slate-300"></span></template>
|
|
579
|
+
<span class="text-sm font-medium text-slate-700" x-text="phase.name || phase.id"></span>
|
|
580
|
+
<span class="badge" :class="phaseBadge(phase.status)" x-text="phase.status"></span>
|
|
581
|
+
</div>
|
|
582
|
+
</template>
|
|
583
|
+
</div>
|
|
584
|
+
</div>
|
|
585
|
+
</div>
|
|
586
|
+
|
|
587
|
+
<!-- ══════════════════════════════════════════════════════
|
|
588
|
+
TAB: REQUIREMENTS
|
|
589
|
+
══════════════════════════════════════════════════════════ -->
|
|
590
|
+
<div id="panel-requirements" role="tabpanel" aria-labelledby="tab-requirements" tabindex="0" x-show="tab === 'requirements'" class="tab-panel space-y-4">
|
|
591
|
+
|
|
592
|
+
<!-- Filters -->
|
|
593
|
+
<div class="bg-white border border-slate-200 rounded-xl p-4 flex flex-wrap items-center gap-3">
|
|
594
|
+
<!-- Search -->
|
|
595
|
+
<div class="relative">
|
|
596
|
+
<svg class="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
|
597
|
+
<input type="text" class="search-input" placeholder="Search ID, title, tag…" x-model="reqSearch" aria-label="Search requirements">
|
|
598
|
+
</div>
|
|
599
|
+
<!-- Status pills -->
|
|
600
|
+
<div class="flex gap-1 flex-wrap">
|
|
601
|
+
<template x-for="opt in [{v:'all',l:'All'},{v:'active',l:'Active'},{v:'deprecated',l:'Deprecated'}]" :key="opt.v">
|
|
602
|
+
<button class="filter-pill" :class="reqStatusFilter === opt.v ? 'active' : ''"
|
|
603
|
+
@click="reqStatusFilter = opt.v" x-text="opt.l"></button>
|
|
604
|
+
</template>
|
|
605
|
+
</div>
|
|
606
|
+
<div class="h-4 w-px bg-slate-200 hidden sm:block"></div>
|
|
607
|
+
<!-- Priority pills -->
|
|
608
|
+
<div class="flex gap-1 flex-wrap">
|
|
609
|
+
<template x-for="opt in [{v:'all',l:'All'},{v:'critical',l:'Critical'},{v:'high',l:'High'},{v:'medium',l:'Medium'},{v:'low',l:'Low'}]" :key="opt.v">
|
|
610
|
+
<button class="filter-pill text-xs" :class="reqPriorityFilter === opt.v ? 'active' : ''"
|
|
611
|
+
@click="reqPriorityFilter = opt.v" x-text="opt.l"></button>
|
|
612
|
+
</template>
|
|
613
|
+
</div>
|
|
614
|
+
<!-- Component -->
|
|
615
|
+
<select class="filter-select" x-model="reqComponentFilter" aria-label="Filter by component">
|
|
616
|
+
<option value="all">All Components</option>
|
|
617
|
+
<template x-for="c in reqComponentOptions()" :key="c">
|
|
618
|
+
<option :value="c" x-text="componentName(c)"></option>
|
|
619
|
+
</template>
|
|
620
|
+
</select>
|
|
621
|
+
<!-- Phase -->
|
|
622
|
+
<select class="filter-select" x-model="reqPhaseFilter" aria-label="Filter by phase">
|
|
623
|
+
<option value="all">All Phases</option>
|
|
624
|
+
<option value="(none)">Unassigned</option>
|
|
625
|
+
<template x-for="ph in [...phases].sort((a,b) => a.order - b.order)" :key="ph.id">
|
|
626
|
+
<option :value="ph.id" x-text="ph.name || ph.id"></option>
|
|
627
|
+
</template>
|
|
628
|
+
</select>
|
|
629
|
+
<!-- Sort -->
|
|
630
|
+
<div class="ml-auto flex items-center gap-1 text-xs">
|
|
631
|
+
<span class="text-slate-400 mr-1">Sort:</span>
|
|
632
|
+
<template x-for="opt in [{v:'id',l:'ID'},{v:'priority',l:'Priority'},{v:'status',l:'Status'}]" :key="opt.v">
|
|
633
|
+
<button class="filter-pill text-xs" :class="reqSortBy === opt.v ? 'active' : ''"
|
|
634
|
+
@click="sortReqBy(opt.v)" x-text="opt.l"></button>
|
|
635
|
+
</template>
|
|
636
|
+
</div>
|
|
637
|
+
</div>
|
|
638
|
+
|
|
639
|
+
<!-- Count -->
|
|
640
|
+
<div class="text-sm text-slate-500 px-1">
|
|
641
|
+
Showing <span class="font-semibold text-slate-700" x-text="filteredRequirements().length"></span>
|
|
642
|
+
of <span x-text="requirements.length"></span> requirements
|
|
643
|
+
</div>
|
|
644
|
+
|
|
645
|
+
<!-- Table -->
|
|
646
|
+
<div class="section-card">
|
|
647
|
+
<template x-if="loading.requirements">
|
|
648
|
+
<div class="p-6 space-y-3">
|
|
649
|
+
<template x-for="i in [1,2,3,4,5]" :key="i">
|
|
650
|
+
<div class="skeleton skeleton-md"></div>
|
|
651
|
+
</template>
|
|
652
|
+
</div>
|
|
653
|
+
</template>
|
|
654
|
+
|
|
655
|
+
<template x-if="!loading.requirements && filteredRequirements().length === 0">
|
|
656
|
+
<div class="empty-state">
|
|
657
|
+
<svg width="40" height="40" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>
|
|
658
|
+
<h3>No requirements found</h3>
|
|
659
|
+
<p>Try adjusting your filters, or add requirements via the MCP tool.</p>
|
|
660
|
+
</div>
|
|
661
|
+
</template>
|
|
662
|
+
|
|
663
|
+
<template x-if="!loading.requirements && filteredRequirements().length > 0">
|
|
664
|
+
<div class="data-table-wrap">
|
|
665
|
+
<table class="data-table">
|
|
666
|
+
<thead>
|
|
667
|
+
<tr>
|
|
668
|
+
<th style="width:100px;" @click="sortReqBy('status')">Status</th>
|
|
669
|
+
<th style="width:110px;" @click="sortReqBy('id')">ID</th>
|
|
670
|
+
<th>Title</th>
|
|
671
|
+
<th style="width:100px;" @click="sortReqBy('priority')">Priority</th>
|
|
672
|
+
<th style="width:170px;">Components</th>
|
|
673
|
+
<th style="width:110px;">Phase</th>
|
|
674
|
+
<th style="width:120px;">Tags</th>
|
|
675
|
+
<th style="width:65px;" class="text-center">Stories</th>
|
|
676
|
+
</tr>
|
|
677
|
+
</thead>
|
|
678
|
+
<tbody>
|
|
679
|
+
<template x-for="req in filteredRequirements()" :key="req.id">
|
|
680
|
+
<tr @click="toggleReq(req.id)" :class="reqExpanded === req.id ? 'expanded' : ''">
|
|
681
|
+
<td>
|
|
682
|
+
<span class="badge" :class="reqStatusBadge(req).cls">
|
|
683
|
+
<span x-text="reqStatusBadge(req).icon"></span>
|
|
684
|
+
<span x-text="reqStatusBadge(req).label"></span>
|
|
685
|
+
</span>
|
|
686
|
+
</td>
|
|
687
|
+
<td><code class="chip chip-indigo" x-text="req.id"></code></td>
|
|
688
|
+
<td class="font-medium text-slate-800" x-text="req.title"></td>
|
|
689
|
+
<td><span class="badge" :class="priorityBadge(req.priority)" x-text="req.priority"></span></td>
|
|
690
|
+
<td>
|
|
691
|
+
<div class="flex flex-wrap gap-1">
|
|
692
|
+
<template x-for="c in (req.components || []).slice(0,3)" :key="c">
|
|
693
|
+
<span class="chip" x-text="componentName(c)"></span>
|
|
694
|
+
</template>
|
|
695
|
+
<template x-if="(req.components || []).length > 3">
|
|
696
|
+
<span class="chip text-slate-400">+<span x-text="req.components.length - 3"></span></span>
|
|
697
|
+
</template>
|
|
698
|
+
</div>
|
|
699
|
+
</td>
|
|
700
|
+
<td>
|
|
701
|
+
<template x-if="req.phase">
|
|
702
|
+
<span class="chip chip-indigo" x-text="phaseName(req.phase)"></span>
|
|
703
|
+
</template>
|
|
704
|
+
<template x-if="!req.phase">
|
|
705
|
+
<span class="text-slate-300">—</span>
|
|
706
|
+
</template>
|
|
707
|
+
</td>
|
|
708
|
+
<td>
|
|
709
|
+
<div class="flex flex-wrap gap-1">
|
|
710
|
+
<template x-for="tag in (req.tags || []).slice(0,2)" :key="tag">
|
|
711
|
+
<span class="chip" x-text="tag"></span>
|
|
712
|
+
</template>
|
|
713
|
+
<template x-if="(req.tags || []).length > 2">
|
|
714
|
+
<span class="chip text-slate-400">+<span x-text="req.tags.length - 2"></span></span>
|
|
715
|
+
</template>
|
|
716
|
+
</div>
|
|
717
|
+
</td>
|
|
718
|
+
<td class="text-center text-sm text-slate-500" x-text="reqCoverage(req)?.storyIds?.length ?? '—'"></td>
|
|
719
|
+
</tr>
|
|
720
|
+
<!-- Expanded detail -->
|
|
721
|
+
<template x-if="reqExpanded === req.id">
|
|
722
|
+
<tr class="detail-row">
|
|
723
|
+
<td colspan="8">
|
|
724
|
+
<div class="detail-content">
|
|
725
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
|
726
|
+
<div>
|
|
727
|
+
<div class="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Description</div>
|
|
728
|
+
<p class="text-sm text-slate-700 leading-relaxed" x-text="req.description || 'No description provided.'"></p>
|
|
729
|
+
<div class="mt-3 flex gap-4 text-xs text-slate-500">
|
|
730
|
+
<span>Source: <span class="text-slate-700 font-medium" x-text="req.source || '—'"></span></span>
|
|
731
|
+
<span>Status: <span class="text-slate-700 font-medium" x-text="req.status"></span></span>
|
|
732
|
+
</div>
|
|
733
|
+
<div class="mt-2 flex flex-wrap gap-1">
|
|
734
|
+
<template x-for="tag in (req.tags || [])" :key="tag">
|
|
735
|
+
<span class="chip" x-text="tag"></span>
|
|
736
|
+
</template>
|
|
737
|
+
</div>
|
|
738
|
+
</div>
|
|
739
|
+
<div>
|
|
740
|
+
<div class="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Linked Stories</div>
|
|
741
|
+
<template x-if="(reqCoverage(req)?.storyIds || []).length === 0">
|
|
742
|
+
<p class="text-sm text-slate-400 italic">No stories linked yet.</p>
|
|
743
|
+
</template>
|
|
744
|
+
<div class="flex flex-wrap gap-2">
|
|
745
|
+
<template x-for="sid in (reqCoverage(req)?.storyIds || [])" :key="sid">
|
|
746
|
+
<button class="chip chip-indigo hover:bg-indigo-100 transition-colors"
|
|
747
|
+
@click.stop="navTo('stories'); storySearch = sid"
|
|
748
|
+
x-text="sid"></button>
|
|
749
|
+
</template>
|
|
750
|
+
</div>
|
|
751
|
+
</div>
|
|
752
|
+
</div>
|
|
753
|
+
</div>
|
|
754
|
+
</td>
|
|
755
|
+
</tr>
|
|
756
|
+
</template>
|
|
757
|
+
</template>
|
|
758
|
+
</tbody>
|
|
759
|
+
</table>
|
|
760
|
+
</div>
|
|
761
|
+
</template>
|
|
762
|
+
</div>
|
|
763
|
+
</div>
|
|
764
|
+
|
|
765
|
+
<!-- ══════════════════════════════════════════════════════
|
|
766
|
+
TAB: STORIES
|
|
767
|
+
══════════════════════════════════════════════════════════ -->
|
|
768
|
+
<div id="panel-stories" role="tabpanel" aria-labelledby="tab-stories" tabindex="0" x-show="tab === 'stories'" class="tab-panel space-y-4">
|
|
769
|
+
|
|
770
|
+
<!-- Filters -->
|
|
771
|
+
<div class="bg-white border border-slate-200 rounded-xl p-4 flex flex-wrap items-center gap-3">
|
|
772
|
+
<div class="relative">
|
|
773
|
+
<svg class="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
|
774
|
+
<input type="text" class="search-input" placeholder="Search ID or title…" x-model="storySearch" aria-label="Search stories">
|
|
775
|
+
</div>
|
|
776
|
+
<div class="flex gap-1 flex-wrap">
|
|
777
|
+
<template x-for="opt in [{v:'all',l:'All'},{v:'draft',l:'Draft'},{v:'ready',l:'Ready'},{v:'in_progress',l:'In Progress'},{v:'done',l:'Done'}]" :key="opt.v">
|
|
778
|
+
<button class="filter-pill text-xs" :class="storyStatusFilter === opt.v ? 'active' : ''"
|
|
779
|
+
@click="storyStatusFilter = opt.v" x-text="opt.l"></button>
|
|
780
|
+
</template>
|
|
781
|
+
</div>
|
|
782
|
+
<!-- Phase -->
|
|
783
|
+
<select class="filter-select" x-model="storyPhaseFilter" aria-label="Filter by phase">
|
|
784
|
+
<option value="all">All Phases</option>
|
|
785
|
+
<option value="(none)">Unassigned</option>
|
|
786
|
+
<template x-for="ph in [...phases].sort((a,b) => a.order - b.order)" :key="ph.id">
|
|
787
|
+
<option :value="ph.id" x-text="ph.name || ph.id"></option>
|
|
788
|
+
</template>
|
|
789
|
+
</select>
|
|
790
|
+
<div class="ml-auto text-sm text-slate-500">
|
|
791
|
+
<span class="font-semibold text-slate-700" x-text="filteredStories().length"></span> of <span x-text="stories.length"></span>
|
|
792
|
+
</div>
|
|
793
|
+
</div>
|
|
794
|
+
|
|
795
|
+
<!-- Table -->
|
|
796
|
+
<div class="section-card">
|
|
797
|
+
<template x-if="loading.stories">
|
|
798
|
+
<div class="p-6 space-y-3">
|
|
799
|
+
<template x-for="i in [1,2,3,4]" :key="i"><div class="skeleton skeleton-md"></div></template>
|
|
800
|
+
</div>
|
|
801
|
+
</template>
|
|
802
|
+
|
|
803
|
+
<template x-if="!loading.stories && filteredStories().length === 0">
|
|
804
|
+
<div class="empty-state">
|
|
805
|
+
<svg width="40" height="40" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
|
806
|
+
<h3>No stories found</h3>
|
|
807
|
+
<p>Adjust filters or add user stories via the MCP tool.</p>
|
|
808
|
+
</div>
|
|
809
|
+
</template>
|
|
810
|
+
|
|
811
|
+
<template x-if="!loading.stories && filteredStories().length > 0">
|
|
812
|
+
<div class="data-table-wrap">
|
|
813
|
+
<table class="data-table">
|
|
814
|
+
<thead>
|
|
815
|
+
<tr>
|
|
816
|
+
<th style="width:120px;">ID</th>
|
|
817
|
+
<th>Title</th>
|
|
818
|
+
<th style="width:110px;">Status</th>
|
|
819
|
+
<th style="width:110px;">Phase</th>
|
|
820
|
+
<th style="width:55px;" class="text-center">ACs</th>
|
|
821
|
+
<th style="width:190px;">Requirements</th>
|
|
822
|
+
<th style="width:110px;">Coverage</th>
|
|
823
|
+
</tr>
|
|
824
|
+
</thead>
|
|
825
|
+
<tbody>
|
|
826
|
+
<template x-for="story in filteredStories()" :key="story.id">
|
|
827
|
+
<tr @click="toggleStory(story.id)" :class="storyExpanded === story.id ? 'expanded' : ''">
|
|
828
|
+
<td><code class="chip chip-indigo" x-text="story.id"></code></td>
|
|
829
|
+
<td class="font-medium text-slate-800" x-text="story.title"></td>
|
|
830
|
+
<td>
|
|
831
|
+
<span class="badge" :class="storyStatusBadge(story.status)"
|
|
832
|
+
x-text="story.status ? story.status.replace('_', ' ') : '—'"></span>
|
|
833
|
+
</td>
|
|
834
|
+
<td>
|
|
835
|
+
<template x-if="story.phase">
|
|
836
|
+
<span class="chip chip-indigo" x-text="phaseName(story.phase)"></span>
|
|
837
|
+
</template>
|
|
838
|
+
<template x-if="!story.phase">
|
|
839
|
+
<span class="text-slate-300">—</span>
|
|
840
|
+
</template>
|
|
841
|
+
</td>
|
|
842
|
+
<td class="text-center text-slate-500 text-sm" x-text="(story.acceptanceCriteria || []).length"></td>
|
|
843
|
+
<td>
|
|
844
|
+
<div class="flex flex-wrap gap-1">
|
|
845
|
+
<template x-for="rid in (story.requirements || []).slice(0,3)" :key="rid">
|
|
846
|
+
<span class="chip chip-indigo text-xs" x-text="rid"></span>
|
|
847
|
+
</template>
|
|
848
|
+
<template x-if="(story.requirements || []).length > 3">
|
|
849
|
+
<span class="chip text-slate-400">+<span x-text="story.requirements.length - 3"></span></span>
|
|
850
|
+
</template>
|
|
851
|
+
</div>
|
|
852
|
+
</td>
|
|
853
|
+
<td>
|
|
854
|
+
<span class="badge" :class="coverageBadge(storyCoverage(story))"
|
|
855
|
+
x-text="coverageLabel(storyCoverage(story))"></span>
|
|
856
|
+
</td>
|
|
857
|
+
</tr>
|
|
858
|
+
<!-- Expanded detail -->
|
|
859
|
+
<template x-if="storyExpanded === story.id">
|
|
860
|
+
<tr class="detail-row">
|
|
861
|
+
<td colspan="7">
|
|
862
|
+
<div class="detail-content">
|
|
863
|
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-5">
|
|
864
|
+
<div>
|
|
865
|
+
<div class="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Description</div>
|
|
866
|
+
<p class="text-sm text-slate-700 leading-relaxed" x-text="story.description || 'No description.'"></p>
|
|
867
|
+
</div>
|
|
868
|
+
<div>
|
|
869
|
+
<div class="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
|
870
|
+
Acceptance Criteria (<span x-text="(story.acceptanceCriteria || []).length"></span>)
|
|
871
|
+
</div>
|
|
872
|
+
<template x-if="(story.acceptanceCriteria || []).length === 0">
|
|
873
|
+
<p class="text-sm text-slate-400 italic">None defined.</p>
|
|
874
|
+
</template>
|
|
875
|
+
<ul class="space-y-1.5">
|
|
876
|
+
<template x-for="ac in (story.acceptanceCriteria || [])" :key="ac.id">
|
|
877
|
+
<li class="flex gap-2 text-sm text-slate-700">
|
|
878
|
+
<span class="text-green-500 shrink-0 mt-0.5">✓</span>
|
|
879
|
+
<span x-text="ac.text"></span>
|
|
880
|
+
</li>
|
|
881
|
+
</template>
|
|
882
|
+
</ul>
|
|
883
|
+
</div>
|
|
884
|
+
<div>
|
|
885
|
+
<div class="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Scenarios</div>
|
|
886
|
+
<template x-if="!storyCoverage(story) || (storyCoverage(story)?.scenarios || []).length === 0">
|
|
887
|
+
<p class="text-sm text-slate-400 italic">No scenarios linked.</p>
|
|
888
|
+
</template>
|
|
889
|
+
<ul class="space-y-1.5">
|
|
890
|
+
<template x-for="sc in (storyCoverage(story)?.scenarios || [])" :key="sc.name">
|
|
891
|
+
<li class="text-xs text-slate-700 flex items-center gap-2">
|
|
892
|
+
<span :class="sc.status==='pass'?'text-green-600':sc.status==='fail'?'text-red-500':'text-amber-500'"
|
|
893
|
+
x-text="sc.status==='pass'?'✓':sc.status==='fail'?'✗':'○'"></span>
|
|
894
|
+
<span x-text="sc.name"></span>
|
|
895
|
+
</li>
|
|
896
|
+
</template>
|
|
897
|
+
</ul>
|
|
898
|
+
</div>
|
|
899
|
+
</div>
|
|
900
|
+
</div>
|
|
901
|
+
</td>
|
|
902
|
+
</tr>
|
|
903
|
+
</template>
|
|
904
|
+
</template>
|
|
905
|
+
</tbody>
|
|
906
|
+
</table>
|
|
907
|
+
</div>
|
|
908
|
+
</template>
|
|
909
|
+
</div>
|
|
910
|
+
</div>
|
|
911
|
+
|
|
912
|
+
<!-- ══════════════════════════════════════════════════════
|
|
913
|
+
TAB: COVERAGE
|
|
914
|
+
══════════════════════════════════════════════════════════ -->
|
|
915
|
+
<div id="panel-coverage" role="tabpanel" aria-labelledby="tab-coverage" tabindex="0" x-show="tab === 'coverage'" class="tab-panel space-y-5">
|
|
916
|
+
|
|
917
|
+
<!-- Controls -->
|
|
918
|
+
<div class="bg-white border border-slate-200 rounded-xl p-4 flex flex-wrap items-center gap-4">
|
|
919
|
+
<div class="flex items-center gap-2">
|
|
920
|
+
<label for="coverage-phase" class="text-sm font-medium text-slate-600">Phase:</label>
|
|
921
|
+
<select id="coverage-phase" class="filter-select" x-model="coveragePhase" aria-label="Select phase for coverage">
|
|
922
|
+
<option value="">All phases</option>
|
|
923
|
+
<template x-for="ph in [...phases].sort((a,b) => a.order - b.order)" :key="ph.id">
|
|
924
|
+
<option :value="ph.id" x-text="ph.name || ph.id"></option>
|
|
925
|
+
</template>
|
|
926
|
+
</select>
|
|
927
|
+
</div>
|
|
928
|
+
<div class="flex items-center gap-2">
|
|
929
|
+
<span class="text-sm font-medium text-slate-600" aria-hidden="true">Mode:</span>
|
|
930
|
+
<div class="toggle-group" role="group" aria-label="Coverage mode">
|
|
931
|
+
<button class="toggle-btn" :class="coverageMode === 'cumulative' ? 'active' : ''"
|
|
932
|
+
:aria-pressed="coverageMode === 'cumulative'"
|
|
933
|
+
@click="coverageMode = 'cumulative'">Cumulative</button>
|
|
934
|
+
<button class="toggle-btn" :class="coverageMode === 'strict' ? 'active' : ''"
|
|
935
|
+
:aria-pressed="coverageMode === 'strict'"
|
|
936
|
+
@click="coverageMode = 'strict'">Strict</button>
|
|
937
|
+
</div>
|
|
938
|
+
</div>
|
|
939
|
+
<button class="btn-primary" @click="refreshCoverage()" :disabled="loading.coverage || loading.gaps">
|
|
940
|
+
<template x-if="loading.coverage || loading.gaps">
|
|
941
|
+
<span class="spinner" style="width:13px;height:13px;border-width:2px;"></span>
|
|
942
|
+
</template>
|
|
943
|
+
<template x-if="!loading.coverage && !loading.gaps">
|
|
944
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></svg>
|
|
945
|
+
</template>
|
|
946
|
+
Refresh
|
|
947
|
+
</button>
|
|
948
|
+
</div>
|
|
949
|
+
|
|
950
|
+
<!-- Summary stats -->
|
|
951
|
+
<template x-if="coverage?.summary">
|
|
952
|
+
<div class="section-card">
|
|
953
|
+
<div class="section-card-header">Summary</div>
|
|
954
|
+
<div class="grid grid-cols-2 sm:grid-cols-4 divide-x divide-slate-100">
|
|
955
|
+
<div class="cov-stat">
|
|
956
|
+
<div class="value text-green-700" x-text="pct(coverage.summary.verifiedPct) + '%'"></div>
|
|
957
|
+
<div class="label">Verified</div>
|
|
958
|
+
</div>
|
|
959
|
+
<div class="cov-stat">
|
|
960
|
+
<div class="value text-indigo-700" x-text="pct(coverage.summary.storyCoveragePct) + '%'"></div>
|
|
961
|
+
<div class="label">Story Coverage</div>
|
|
962
|
+
</div>
|
|
963
|
+
<div class="cov-stat">
|
|
964
|
+
<div class="value text-slate-700"
|
|
965
|
+
x-text="(coverage.summary.requirementsWithStory ?? 0) + ' / ' + (coverage.summary.requirementsTotal ?? 0)"></div>
|
|
966
|
+
<div class="label">Reqs with Story</div>
|
|
967
|
+
</div>
|
|
968
|
+
<div class="cov-stat">
|
|
969
|
+
<div class="value text-emerald-700"
|
|
970
|
+
x-text="(coverage.summary.scenariosPassing ?? 0) + ' / ' + (coverage.summary.scenariosLinked ?? 0)"></div>
|
|
971
|
+
<div class="label">Passing Scenarios</div>
|
|
972
|
+
</div>
|
|
973
|
+
</div>
|
|
974
|
+
</div>
|
|
975
|
+
</template>
|
|
976
|
+
|
|
977
|
+
<!-- Component breakdown -->
|
|
978
|
+
<template x-if="coverage && (coverage.byComponent || []).length > 0">
|
|
979
|
+
<div class="section-card">
|
|
980
|
+
<div class="section-card-header">By Component</div>
|
|
981
|
+
<div class="data-table-wrap">
|
|
982
|
+
<table class="data-table">
|
|
983
|
+
<thead>
|
|
984
|
+
<tr>
|
|
985
|
+
<th>Component</th>
|
|
986
|
+
<th class="text-center" style="width:100px;">Total Reqs</th>
|
|
987
|
+
<th class="text-center" style="width:100px;">With Story</th>
|
|
988
|
+
<th class="text-center" style="width:100px;">Verified</th>
|
|
989
|
+
<th style="width:200px;">Verified %</th>
|
|
990
|
+
</tr>
|
|
991
|
+
</thead>
|
|
992
|
+
<tbody>
|
|
993
|
+
<template x-for="row in (coverage.byComponent || [])" :key="row.component">
|
|
994
|
+
<tr class="cursor-default">
|
|
995
|
+
<td>
|
|
996
|
+
<span class="font-medium text-slate-700" x-text="componentName(row.component)"></span>
|
|
997
|
+
<code class="chip chip-indigo ml-1" x-text="row.component"></code>
|
|
998
|
+
</td>
|
|
999
|
+
<td class="text-center text-slate-600" x-text="row.requirements"></td>
|
|
1000
|
+
<td class="text-center text-slate-600" x-text="row.withStory"></td>
|
|
1001
|
+
<td class="text-center text-slate-600" x-text="row.verified"></td>
|
|
1002
|
+
<td>
|
|
1003
|
+
<div class="flex items-center gap-2">
|
|
1004
|
+
<div class="progress-track flex-1">
|
|
1005
|
+
<div class="progress-fill progress-fill--green"
|
|
1006
|
+
:style="'width:' + (row.verifiedPct ?? 0) + '%'"></div>
|
|
1007
|
+
</div>
|
|
1008
|
+
<span class="text-xs font-semibold text-slate-600 w-12 text-right"
|
|
1009
|
+
x-text="pct(row.verifiedPct) + '%'"></span>
|
|
1010
|
+
</div>
|
|
1011
|
+
</td>
|
|
1012
|
+
</tr>
|
|
1013
|
+
</template>
|
|
1014
|
+
</tbody>
|
|
1015
|
+
</table>
|
|
1016
|
+
</div>
|
|
1017
|
+
</div>
|
|
1018
|
+
</template>
|
|
1019
|
+
|
|
1020
|
+
<!-- Gaps panels -->
|
|
1021
|
+
<template x-if="gaps">
|
|
1022
|
+
<div class="space-y-4">
|
|
1023
|
+
<h3 class="text-sm font-semibold text-slate-500 uppercase tracking-wide">Coverage Gaps</h3>
|
|
1024
|
+
|
|
1025
|
+
<!-- All clear -->
|
|
1026
|
+
<div x-show="(gaps.requirementsWithoutStory || []).length === 0 && (gaps.storiesWithoutScenario || []).length === 0 && (gaps.storiesNotCovered || []).length === 0"
|
|
1027
|
+
class="bg-green-50 border border-green-200 rounded-xl p-4 text-green-700 text-sm font-medium flex items-center gap-2">
|
|
1028
|
+
<span>✓</span> No gaps — great coverage!
|
|
1029
|
+
</div>
|
|
1030
|
+
|
|
1031
|
+
<!-- Reqs without stories -->
|
|
1032
|
+
<div class="gap-panel-red">
|
|
1033
|
+
<div class="flex items-center gap-2 mb-3">
|
|
1034
|
+
<span class="text-sm font-semibold text-red-800">
|
|
1035
|
+
Requirements without stories (<span x-text="(gaps.requirementsWithoutStory || []).length"></span>)
|
|
1036
|
+
</span>
|
|
1037
|
+
</div>
|
|
1038
|
+
<template x-if="(gaps.requirementsWithoutStory || []).length === 0">
|
|
1039
|
+
<p class="text-sm text-green-700 font-medium">All requirements have at least one story.</p>
|
|
1040
|
+
</template>
|
|
1041
|
+
<div class="flex flex-wrap gap-2">
|
|
1042
|
+
<template x-for="r in (gaps.requirementsWithoutStory || [])" :key="r.id">
|
|
1043
|
+
<div class="bg-white border border-red-200 rounded-lg px-3 py-2 text-xs cursor-pointer hover:border-red-400 transition-colors"
|
|
1044
|
+
@click="navTo('requirements'); reqSearch = r.id">
|
|
1045
|
+
<div class="font-semibold text-red-700" x-text="r.id"></div>
|
|
1046
|
+
<div class="text-slate-600 mt-0.5 truncate max-w-xs" x-text="r.title"></div>
|
|
1047
|
+
<span class="badge badge-red mt-1" x-text="r.priority"></span>
|
|
1048
|
+
</div>
|
|
1049
|
+
</template>
|
|
1050
|
+
</div>
|
|
1051
|
+
</div>
|
|
1052
|
+
|
|
1053
|
+
<!-- Stories without scenarios -->
|
|
1054
|
+
<div class="gap-panel-amber">
|
|
1055
|
+
<div class="flex items-center gap-2 mb-3">
|
|
1056
|
+
<span class="text-sm font-semibold text-amber-800">
|
|
1057
|
+
Stories without scenarios (<span x-text="(gaps.storiesWithoutScenario || []).length"></span>)
|
|
1058
|
+
</span>
|
|
1059
|
+
</div>
|
|
1060
|
+
<template x-if="(gaps.storiesWithoutScenario || []).length === 0">
|
|
1061
|
+
<p class="text-sm text-green-700 font-medium">All stories have test scenarios.</p>
|
|
1062
|
+
</template>
|
|
1063
|
+
<div class="flex flex-wrap gap-2">
|
|
1064
|
+
<template x-for="s in (gaps.storiesWithoutScenario || [])" :key="s.id">
|
|
1065
|
+
<div class="bg-white border border-amber-200 rounded-lg px-3 py-2 text-xs cursor-pointer hover:border-amber-400 transition-colors"
|
|
1066
|
+
@click="navTo('stories'); storySearch = s.id">
|
|
1067
|
+
<div class="font-semibold text-amber-700" x-text="s.id"></div>
|
|
1068
|
+
<div class="text-slate-600 mt-0.5 truncate max-w-xs" x-text="s.title"></div>
|
|
1069
|
+
</div>
|
|
1070
|
+
</template>
|
|
1071
|
+
</div>
|
|
1072
|
+
</div>
|
|
1073
|
+
|
|
1074
|
+
<!-- Stories not covered -->
|
|
1075
|
+
<div class="gap-panel-slate">
|
|
1076
|
+
<div class="flex items-center gap-2 mb-3">
|
|
1077
|
+
<span class="text-sm font-semibold text-slate-700">
|
|
1078
|
+
Stories not covered (<span x-text="(gaps.storiesNotCovered || []).length"></span>)
|
|
1079
|
+
</span>
|
|
1080
|
+
</div>
|
|
1081
|
+
<template x-if="(gaps.storiesNotCovered || []).length === 0">
|
|
1082
|
+
<p class="text-sm text-green-700 font-medium">All stories are covered.</p>
|
|
1083
|
+
</template>
|
|
1084
|
+
<div class="flex flex-wrap gap-2">
|
|
1085
|
+
<template x-for="s in (gaps.storiesNotCovered || [])" :key="s.id">
|
|
1086
|
+
<div class="bg-white border border-slate-200 rounded-lg px-3 py-2 text-xs cursor-pointer hover:border-slate-400 transition-colors"
|
|
1087
|
+
@click="navTo('stories'); storySearch = s.id">
|
|
1088
|
+
<div class="font-semibold text-slate-700" x-text="s.id"></div>
|
|
1089
|
+
<div class="text-slate-600 mt-0.5 truncate max-w-xs" x-text="s.title"></div>
|
|
1090
|
+
<div class="flex gap-2 mt-1">
|
|
1091
|
+
<template x-if="(s.failing || []).length > 0">
|
|
1092
|
+
<span class="badge badge-red"><span x-text="s.failing.length"></span> failing</span>
|
|
1093
|
+
</template>
|
|
1094
|
+
<template x-if="(s.pending || []).length > 0">
|
|
1095
|
+
<span class="badge badge-amber"><span x-text="s.pending.length"></span> pending</span>
|
|
1096
|
+
</template>
|
|
1097
|
+
</div>
|
|
1098
|
+
</div>
|
|
1099
|
+
</template>
|
|
1100
|
+
</div>
|
|
1101
|
+
</div>
|
|
1102
|
+
</div>
|
|
1103
|
+
</template>
|
|
1104
|
+
|
|
1105
|
+
<!-- Story detail toggle -->
|
|
1106
|
+
<template x-if="coverage && (coverage.stories || []).length > 0">
|
|
1107
|
+
<div class="section-card">
|
|
1108
|
+
<div class="section-card-header">
|
|
1109
|
+
Story Coverage Detail
|
|
1110
|
+
<button class="btn-secondary text-xs" @click="showStoriesDetail = !showStoriesDetail"
|
|
1111
|
+
x-text="showStoriesDetail ? 'Hide' : 'Show all stories'"></button>
|
|
1112
|
+
</div>
|
|
1113
|
+
<template x-if="showStoriesDetail">
|
|
1114
|
+
<div class="data-table-wrap">
|
|
1115
|
+
<table class="data-table">
|
|
1116
|
+
<thead>
|
|
1117
|
+
<tr>
|
|
1118
|
+
<th style="width:120px;">ID</th>
|
|
1119
|
+
<th>Title</th>
|
|
1120
|
+
<th style="width:110px;">Status</th>
|
|
1121
|
+
<th style="width:80px;" class="text-center">Passing</th>
|
|
1122
|
+
<th style="width:80px;" class="text-center">Failing</th>
|
|
1123
|
+
<th style="width:80px;" class="text-center">Pending</th>
|
|
1124
|
+
<th style="width:110px;">Coverage</th>
|
|
1125
|
+
</tr>
|
|
1126
|
+
</thead>
|
|
1127
|
+
<tbody>
|
|
1128
|
+
<template x-for="sc in (coverage.stories || [])" :key="sc.id">
|
|
1129
|
+
<tr class="cursor-default">
|
|
1130
|
+
<td><code class="chip chip-indigo" x-text="sc.id"></code></td>
|
|
1131
|
+
<td class="font-medium text-slate-700 text-sm" x-text="sc.title"></td>
|
|
1132
|
+
<td><span class="badge" :class="storyStatusBadge(sc.status)"
|
|
1133
|
+
x-text="sc.status ? sc.status.replace('_',' ') : '—'"></span></td>
|
|
1134
|
+
<td class="text-center text-green-600 font-semibold text-sm" x-text="sc.passing ?? 0"></td>
|
|
1135
|
+
<td class="text-center text-red-500 font-semibold text-sm" x-text="sc.failing ?? 0"></td>
|
|
1136
|
+
<td class="text-center text-amber-500 font-semibold text-sm" x-text="sc.pending ?? 0"></td>
|
|
1137
|
+
<td><span class="badge" :class="coverageBadge(sc)" x-text="coverageLabel(sc)"></span></td>
|
|
1138
|
+
</tr>
|
|
1139
|
+
</template>
|
|
1140
|
+
</tbody>
|
|
1141
|
+
</table>
|
|
1142
|
+
</div>
|
|
1143
|
+
</template>
|
|
1144
|
+
</div>
|
|
1145
|
+
</template>
|
|
1146
|
+
|
|
1147
|
+
<!-- Loading -->
|
|
1148
|
+
<template x-if="loading.coverage || loading.gaps">
|
|
1149
|
+
<div class="flex items-center justify-center py-16">
|
|
1150
|
+
<div class="spinner"></div>
|
|
1151
|
+
</div>
|
|
1152
|
+
</template>
|
|
1153
|
+
</div>
|
|
1154
|
+
|
|
1155
|
+
<!-- ══════════════════════════════════════════════════════
|
|
1156
|
+
TAB: COMPONENTS
|
|
1157
|
+
══════════════════════════════════════════════════════════ -->
|
|
1158
|
+
<div id="panel-components" role="tabpanel" aria-labelledby="tab-components" tabindex="0" x-show="tab === 'components'" class="tab-panel">
|
|
1159
|
+
|
|
1160
|
+
<template x-if="loading.components">
|
|
1161
|
+
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
1162
|
+
<template x-for="i in [1,2,3,4,5,6,7,8]" :key="i">
|
|
1163
|
+
<div class="component-card space-y-3">
|
|
1164
|
+
<div class="skeleton skeleton-lg w-3/4"></div>
|
|
1165
|
+
<div class="skeleton skeleton-md"></div>
|
|
1166
|
+
<div class="skeleton skeleton-sm w-1/2"></div>
|
|
1167
|
+
</div>
|
|
1168
|
+
</template>
|
|
1169
|
+
</div>
|
|
1170
|
+
</template>
|
|
1171
|
+
|
|
1172
|
+
<template x-if="!loading.components && components.length === 0">
|
|
1173
|
+
<div class="empty-state mt-20">
|
|
1174
|
+
<svg width="44" height="44" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
|
|
1175
|
+
<h3>No components defined</h3>
|
|
1176
|
+
<p>Add components via the MCP tool to organize requirements by domain.</p>
|
|
1177
|
+
</div>
|
|
1178
|
+
</template>
|
|
1179
|
+
|
|
1180
|
+
<template x-if="!loading.components && components.length > 0">
|
|
1181
|
+
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
1182
|
+
<template x-for="comp in components" :key="comp.id">
|
|
1183
|
+
<div class="component-card">
|
|
1184
|
+
<div class="flex items-start justify-between mb-3">
|
|
1185
|
+
<div>
|
|
1186
|
+
<div class="font-semibold text-slate-900 text-base" x-text="comp.name"></div>
|
|
1187
|
+
<code class="chip chip-indigo mt-1 inline-block" x-text="comp.id"></code>
|
|
1188
|
+
</div>
|
|
1189
|
+
<span class="badge" :class="comp.status === 'active' ? 'badge-green' : 'badge-slate'"
|
|
1190
|
+
x-text="comp.status"></span>
|
|
1191
|
+
</div>
|
|
1192
|
+
<p class="text-sm text-slate-500 leading-relaxed mb-3 line-clamp-3" x-text="comp.description || 'No description.'"></p>
|
|
1193
|
+
<div x-show="(comp.domainTags || []).length > 0" class="flex flex-wrap gap-1 mb-3">
|
|
1194
|
+
<template x-for="tag in (comp.domainTags || [])" :key="tag">
|
|
1195
|
+
<span class="chip chip-amber" x-text="tag"></span>
|
|
1196
|
+
</template>
|
|
1197
|
+
</div>
|
|
1198
|
+
<!-- Stats -->
|
|
1199
|
+
<div class="pt-3 border-t border-slate-100 flex items-center gap-2">
|
|
1200
|
+
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" class="text-slate-400"><path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>
|
|
1201
|
+
<span class="text-xs text-slate-500">
|
|
1202
|
+
<span class="font-semibold text-slate-700" x-text="reqCountForComponent(comp.id)"></span> requirements
|
|
1203
|
+
</span>
|
|
1204
|
+
<!-- Coverage % from byComponent -->
|
|
1205
|
+
<template x-if="coverage">
|
|
1206
|
+
<span class="ml-auto text-xs">
|
|
1207
|
+
<template x-for="bc in (coverage.byComponent || []).filter(b => b.component === comp.id)" :key="bc.component">
|
|
1208
|
+
<span class="font-semibold"
|
|
1209
|
+
:class="bc.verifiedPct >= 80 ? 'text-green-600' : bc.verifiedPct >= 40 ? 'text-amber-600' : 'text-red-500'"
|
|
1210
|
+
x-text="pct(bc.verifiedPct) + '% verified'"></span>
|
|
1211
|
+
</template>
|
|
1212
|
+
</span>
|
|
1213
|
+
</template>
|
|
1214
|
+
</div>
|
|
1215
|
+
</div>
|
|
1216
|
+
</template>
|
|
1217
|
+
</div>
|
|
1218
|
+
</template>
|
|
1219
|
+
</div>
|
|
1220
|
+
|
|
1221
|
+
<!-- ══════════════════════════════════════════════════════
|
|
1222
|
+
TAB: VCS
|
|
1223
|
+
══════════════════════════════════════════════════════════ -->
|
|
1224
|
+
<div id="panel-vcs" role="tabpanel" aria-labelledby="tab-vcs" tabindex="0" x-show="tab === 'vcs'" class="tab-panel space-y-4">
|
|
1225
|
+
|
|
1226
|
+
<!-- Filters -->
|
|
1227
|
+
<div class="bg-white border border-slate-200 rounded-xl p-4 flex flex-wrap items-center gap-3">
|
|
1228
|
+
<div class="flex gap-1">
|
|
1229
|
+
<template x-for="opt in [{v:'all',l:'All'},{v:'branch',l:'Branches'},{v:'mr',l:'MRs'}]" :key="opt.v">
|
|
1230
|
+
<button class="filter-pill text-xs" :class="vcsKindFilter === opt.v ? 'active' : ''"
|
|
1231
|
+
@click="vcsKindFilter = opt.v" x-text="opt.l"></button>
|
|
1232
|
+
</template>
|
|
1233
|
+
</div>
|
|
1234
|
+
<div class="h-4 w-px bg-slate-200 hidden sm:block"></div>
|
|
1235
|
+
<div class="flex gap-1">
|
|
1236
|
+
<template x-for="opt in [{v:'all',l:'All'},{v:'opened',l:'Opened'},{v:'merged',l:'Merged'},{v:'closed',l:'Closed'}]" :key="opt.v">
|
|
1237
|
+
<button class="filter-pill text-xs" :class="vcsStateFilter === opt.v ? 'active' : ''"
|
|
1238
|
+
@click="vcsStateFilter = opt.v" x-text="opt.l"></button>
|
|
1239
|
+
</template>
|
|
1240
|
+
</div>
|
|
1241
|
+
<div class="ml-auto text-sm text-slate-500">
|
|
1242
|
+
<span class="font-semibold text-slate-700" x-text="filteredVcs().length"></span> of <span x-text="vcsRefs.length"></span>
|
|
1243
|
+
</div>
|
|
1244
|
+
</div>
|
|
1245
|
+
|
|
1246
|
+
<!-- Table -->
|
|
1247
|
+
<div class="section-card">
|
|
1248
|
+
<template x-if="loading.vcs">
|
|
1249
|
+
<div class="p-6 space-y-3">
|
|
1250
|
+
<template x-for="i in [1,2,3]" :key="i"><div class="skeleton skeleton-md"></div></template>
|
|
1251
|
+
</div>
|
|
1252
|
+
</template>
|
|
1253
|
+
|
|
1254
|
+
<template x-if="!loading.vcs && filteredVcs().length === 0">
|
|
1255
|
+
<div class="empty-state">
|
|
1256
|
+
<svg width="40" height="40" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 009 9"/></svg>
|
|
1257
|
+
<h3>No VCS references recorded yet</h3>
|
|
1258
|
+
<p>Use <code class="bg-slate-100 px-1 rounded font-mono text-xs">add_vcs_ref</code> to link branches and MRs to stories and requirements.</p>
|
|
1259
|
+
</div>
|
|
1260
|
+
</template>
|
|
1261
|
+
|
|
1262
|
+
<template x-if="!loading.vcs && filteredVcs().length > 0">
|
|
1263
|
+
<div class="data-table-wrap">
|
|
1264
|
+
<table class="data-table">
|
|
1265
|
+
<thead>
|
|
1266
|
+
<tr>
|
|
1267
|
+
<th style="width:75px;">Kind</th>
|
|
1268
|
+
<th>Ref</th>
|
|
1269
|
+
<th style="width:130px;">Branch</th>
|
|
1270
|
+
<th style="width:85px;">State</th>
|
|
1271
|
+
<th style="width:130px;">Component</th>
|
|
1272
|
+
<th style="width:150px;">Stories</th>
|
|
1273
|
+
<th style="width:150px;">Reqs</th>
|
|
1274
|
+
<th style="width:55px;">Link</th>
|
|
1275
|
+
</tr>
|
|
1276
|
+
</thead>
|
|
1277
|
+
<tbody>
|
|
1278
|
+
<template x-for="ref in filteredVcs()" :key="ref.id">
|
|
1279
|
+
<tr class="cursor-default">
|
|
1280
|
+
<td>
|
|
1281
|
+
<span class="badge" :class="ref.kind === 'mr' ? 'badge-indigo' : 'badge-slate'"
|
|
1282
|
+
x-text="ref.kind.toUpperCase()"></span>
|
|
1283
|
+
</td>
|
|
1284
|
+
<td class="font-mono text-xs text-slate-700 max-w-xs truncate" x-text="ref.ref"></td>
|
|
1285
|
+
<td class="font-mono text-xs text-slate-500 truncate" x-text="ref.branch || '—'"></td>
|
|
1286
|
+
<td><span class="badge" :class="vcsBadge(ref.state)" x-text="ref.state"></span></td>
|
|
1287
|
+
<td class="text-sm text-slate-600" x-text="ref.component ? componentName(ref.component) : '—'"></td>
|
|
1288
|
+
<td>
|
|
1289
|
+
<div class="flex flex-wrap gap-1">
|
|
1290
|
+
<template x-for="sid in (ref.storyIds || []).slice(0,2)" :key="sid">
|
|
1291
|
+
<span class="chip chip-indigo text-xs cursor-pointer"
|
|
1292
|
+
@click="navTo('stories'); storySearch = sid" x-text="sid"></span>
|
|
1293
|
+
</template>
|
|
1294
|
+
<template x-if="(ref.storyIds || []).length > 2">
|
|
1295
|
+
<span class="chip text-slate-400">+<span x-text="ref.storyIds.length - 2"></span></span>
|
|
1296
|
+
</template>
|
|
1297
|
+
<span x-show="(ref.storyIds || []).length === 0" class="text-slate-400 text-xs">—</span>
|
|
1298
|
+
</div>
|
|
1299
|
+
</td>
|
|
1300
|
+
<td>
|
|
1301
|
+
<div class="flex flex-wrap gap-1">
|
|
1302
|
+
<template x-for="rid in (ref.requirementIds || []).slice(0,2)" :key="rid">
|
|
1303
|
+
<span class="chip chip-indigo text-xs cursor-pointer"
|
|
1304
|
+
@click="navTo('requirements'); reqSearch = rid" x-text="rid"></span>
|
|
1305
|
+
</template>
|
|
1306
|
+
<template x-if="(ref.requirementIds || []).length > 2">
|
|
1307
|
+
<span class="chip text-slate-400">+<span x-text="ref.requirementIds.length - 2"></span></span>
|
|
1308
|
+
</template>
|
|
1309
|
+
<span x-show="(ref.requirementIds || []).length === 0" class="text-slate-400 text-xs">—</span>
|
|
1310
|
+
</div>
|
|
1311
|
+
</td>
|
|
1312
|
+
<td>
|
|
1313
|
+
<a x-show="ref.url" :href="ref.url" target="_blank" rel="noopener"
|
|
1314
|
+
class="text-indigo-600 hover:text-indigo-800 flex items-center gap-1 text-xs font-medium">
|
|
1315
|
+
Open
|
|
1316
|
+
<svg width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
|
1317
|
+
</a>
|
|
1318
|
+
<span x-show="!ref.url" class="text-slate-300 text-xs">—</span>
|
|
1319
|
+
</td>
|
|
1320
|
+
</tr>
|
|
1321
|
+
</template>
|
|
1322
|
+
</tbody>
|
|
1323
|
+
</table>
|
|
1324
|
+
</div>
|
|
1325
|
+
</template>
|
|
1326
|
+
</div>
|
|
1327
|
+
</div>
|
|
1328
|
+
|
|
1329
|
+
</div><!-- /tab panels -->
|
|
1330
|
+
</div><!-- /container -->
|
|
1331
|
+
</main>
|
|
1332
|
+
|
|
1333
|
+
<!--
|
|
1334
|
+
Script load order — DO NOT REORDER:
|
|
1335
|
+
1. chart.js (sync) — must be defined before initTrendChart/initDonutChart fire
|
|
1336
|
+
2. app.js (sync) — registers the alpine:init listener BEFORE Alpine boots;
|
|
1337
|
+
if placed AFTER the Alpine <script defer>, the listener
|
|
1338
|
+
never fires and the entire app is silently dead
|
|
1339
|
+
3. alpinejs (defer) — fires alpine:init after DOMContentLoaded; app.js has
|
|
1340
|
+
already registered the requApp data component by then
|
|
1341
|
+
-->
|
|
1342
|
+
<!-- ═══════════════════════════════════════════════════════════
|
|
1343
|
+
IMPORT MODAL
|
|
1344
|
+
════════════════════════════════════════════════════════════ -->
|
|
1345
|
+
<div
|
|
1346
|
+
x-show="importDialogOpen"
|
|
1347
|
+
x-cloak
|
|
1348
|
+
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
1349
|
+
@keydown.escape.window="importDialogOpen = false; importResult = null"
|
|
1350
|
+
x-effect="if (importDialogOpen) $nextTick(() => $refs.importClose && $refs.importClose.focus())">
|
|
1351
|
+
<!-- Backdrop -->
|
|
1352
|
+
<div class="absolute inset-0 bg-black/50" @click="importDialogOpen = false; importResult = null"></div>
|
|
1353
|
+
<!-- Panel -->
|
|
1354
|
+
<div class="relative bg-white rounded-xl shadow-2xl w-full max-w-md p-6 z-10"
|
|
1355
|
+
role="dialog" aria-modal="true" aria-labelledby="import-dialog-title">
|
|
1356
|
+
<div class="flex items-center justify-between mb-4">
|
|
1357
|
+
<h2 id="import-dialog-title" class="font-semibold text-slate-800 text-base">Import project data</h2>
|
|
1358
|
+
<button x-ref="importClose" @click="importDialogOpen = false" class="p-2 text-slate-400 hover:text-slate-600 transition-colors" aria-label="Close">
|
|
1359
|
+
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
|
1360
|
+
<path d="M4 4l10 10M14 4L4 14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
1361
|
+
</svg>
|
|
1362
|
+
</button>
|
|
1363
|
+
</div>
|
|
1364
|
+
|
|
1365
|
+
<!-- Instructions -->
|
|
1366
|
+
<p class="text-sm text-slate-500 mb-4">Select a <code class="bg-slate-100 px-1 rounded text-xs">.json</code> file exported from another requ instance. Existing records (same ID) are skipped.</p>
|
|
1367
|
+
|
|
1368
|
+
<!-- File drop-zone.
|
|
1369
|
+
Cross-browser approach: the input is an opacity-0 overlay that covers the
|
|
1370
|
+
entire zone. The user clicks directly on the input itself — no label
|
|
1371
|
+
forwarding and no programmatic .click() are involved, so this works in
|
|
1372
|
+
Chrome, Firefox, and Safari (WebKit blocks both of those alternatives). -->
|
|
1373
|
+
<div class="relative w-full h-28" :class="importing ? 'opacity-50' : ''">
|
|
1374
|
+
<!-- Visual layer (pointer-events:none so clicks fall through to the input) -->
|
|
1375
|
+
<div class="flex flex-col items-center justify-center w-full h-full border-2 border-dashed rounded-lg transition-colors pointer-events-none"
|
|
1376
|
+
:class="importing ? 'border-slate-200' : 'border-slate-300 group-hover:border-indigo-400'">
|
|
1377
|
+
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" class="text-slate-400 mb-2" aria-hidden="true">
|
|
1378
|
+
<path d="M12 16V8M8 12l4-4 4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
1379
|
+
<path d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
1380
|
+
</svg>
|
|
1381
|
+
<span class="text-sm text-slate-500" x-text="importing ? 'Importing…' : 'Click to select a file'"></span>
|
|
1382
|
+
</div>
|
|
1383
|
+
<!-- Transparent input overlay — the actual click target.
|
|
1384
|
+
opacity:0 hides it visually; width/height:100% make it cover the zone.
|
|
1385
|
+
No clip, no visibility:hidden, no display:none — all of those can prevent
|
|
1386
|
+
Safari from opening the file picker. -->
|
|
1387
|
+
<input type="file" accept=".json"
|
|
1388
|
+
aria-label="Select a .json file to import"
|
|
1389
|
+
style="position:absolute;top:0;left:0;width:100%;height:100%;opacity:0;cursor:pointer;"
|
|
1390
|
+
@change="importFile($event)" :disabled="importing">
|
|
1391
|
+
</div>
|
|
1392
|
+
|
|
1393
|
+
<!-- Result -->
|
|
1394
|
+
<div x-show="importResult" class="mt-4">
|
|
1395
|
+
<div
|
|
1396
|
+
:class="importResult && importResult.errors && importResult.errors.length ? 'bg-red-50 border-red-200 text-red-700' : 'bg-green-50 border-green-200 text-green-700'"
|
|
1397
|
+
class="border rounded-lg px-3 py-2 text-sm"
|
|
1398
|
+
x-text="importResultSummary()">
|
|
1399
|
+
</div>
|
|
1400
|
+
</div>
|
|
1401
|
+
|
|
1402
|
+
<!-- Close button -->
|
|
1403
|
+
<div class="mt-5 flex justify-end">
|
|
1404
|
+
<button
|
|
1405
|
+
@click="importDialogOpen = false; importResult = null"
|
|
1406
|
+
class="btn-secondary text-xs">
|
|
1407
|
+
Close
|
|
1408
|
+
</button>
|
|
1409
|
+
</div>
|
|
1410
|
+
</div>
|
|
1411
|
+
</div>
|
|
1412
|
+
|
|
1413
|
+
<script src="https://cdn.jsdelivr.net/npm/marked@14/marked.min.js"></script>
|
|
1414
|
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
|
|
1415
|
+
<script src="/public/app.js"></script>
|
|
1416
|
+
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
|
|
1417
|
+
</body>
|
|
1418
|
+
</html>
|