@reqord/web 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/__tests__/components/dashboard/critical-path-display.test.tsx +61 -0
- package/src/__tests__/components/dashboard/progress-bar.test.tsx +63 -0
- package/src/__tests__/components/dashboard/project-health.test.tsx +21 -7
- package/src/__tests__/components/dashboard/status-card.test.tsx +86 -0
- package/src/__tests__/components/dashboard/warning-alert.test.tsx +6 -6
- package/src/__tests__/components/feedback/feedback-filters-improved.test.tsx +33 -0
- package/src/__tests__/components/graph/drilldown-breadcrumb.test.tsx +12 -0
- package/src/__tests__/components/graph/edge-styles.test.ts +6 -6
- package/src/__tests__/components/graph/issue-node.test.tsx +25 -6
- package/src/__tests__/components/graph/requirement-node.test.tsx +45 -0
- package/src/__tests__/components/graph/specification-node.test.tsx +27 -14
- package/src/__tests__/components/requirement/requirement-table.test.tsx +165 -0
- package/src/__tests__/components/specification/specification-table.test.tsx +189 -0
- package/src/__tests__/components/ui/badge.test.tsx +98 -0
- package/src/__tests__/components/ui/button.test.tsx +98 -0
- package/src/__tests__/components/ui/card.test.tsx +58 -0
- package/src/__tests__/components/ui/nav.test.tsx +91 -0
- package/src/__tests__/components/ui/tabs.test.tsx +53 -0
- package/src/__tests__/lib/drilldown-graph-data.test.ts +45 -3
- package/src/app/dashboard/page.tsx +29 -21
- package/src/app/globals.css +46 -0
- package/src/app/layout.tsx +4 -1
- package/src/app/requirements/loading.tsx +30 -5
- package/src/app/specifications/loading.tsx +29 -5
- package/src/components/dashboard/critical-path-display.tsx +30 -15
- package/src/components/dashboard/progress-bar.tsx +2 -4
- package/src/components/dashboard/project-health.tsx +9 -10
- package/src/components/dashboard/status-card.tsx +20 -9
- package/src/components/dashboard/warning-alert.tsx +57 -5
- package/src/components/feedback/feedback-filters.tsx +41 -12
- package/src/components/graph/drilldown-breadcrumb.tsx +1 -1
- package/src/components/graph/drilldown-graph.tsx +3 -1
- package/src/components/graph/edge-styles.ts +3 -3
- package/src/components/graph/issue-node.tsx +7 -7
- package/src/components/graph/multi-level-graph.tsx +2 -2
- package/src/components/graph/requirement-node.tsx +5 -5
- package/src/components/graph/specification-node.tsx +12 -9
- package/src/components/requirement/requirement-table.tsx +62 -18
- package/src/components/specification/specification-table.tsx +59 -17
- package/src/components/ui/badge.tsx +4 -4
- package/src/components/ui/button.tsx +43 -0
- package/src/components/ui/card.tsx +25 -0
- package/src/components/ui/nav.tsx +35 -35
- package/src/components/ui/tabs.tsx +2 -0
- package/src/lib/drilldown-graph-data.ts +23 -4
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useMemo, useState } from "react";
|
|
3
|
+
import React, { useMemo, useState } from "react";
|
|
4
4
|
import Link from "next/link";
|
|
5
5
|
import type { Specification, Status } from "@reqord/shared";
|
|
6
6
|
import { StatusBadge } from "@/components/ui/badge";
|
|
@@ -84,18 +84,37 @@ export function SpecificationTable({
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
function renderSortHeader(column: SortKey, label: string) {
|
|
87
|
-
const
|
|
87
|
+
const isActive = sortKey === column;
|
|
88
88
|
return (
|
|
89
89
|
<th
|
|
90
90
|
key={column}
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
scope="col"
|
|
92
|
+
aria-sort={isActive ? (sortDir === "asc" ? "ascending" : "descending") : undefined}
|
|
93
|
+
className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
|
93
94
|
>
|
|
94
|
-
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
onClick={() => handleSort(column)}
|
|
98
|
+
className={`inline-flex items-center gap-1 transition-colors ${
|
|
99
|
+
isActive ? "text-warm-900 font-semibold" : "hover:text-gray-900"
|
|
100
|
+
}`}
|
|
101
|
+
>
|
|
102
|
+
{label}
|
|
103
|
+
<span aria-hidden="true" className={`text-[10px] ${isActive ? "opacity-100" : "opacity-30"}`}>
|
|
104
|
+
{isActive && sortDir === "desc" ? "▼" : "▲"}
|
|
105
|
+
</span>
|
|
106
|
+
</button>
|
|
95
107
|
</th>
|
|
96
108
|
);
|
|
97
109
|
}
|
|
98
110
|
|
|
111
|
+
const hasActiveFilters = search || statusFilter !== "all";
|
|
112
|
+
|
|
113
|
+
function clearAllFilters() {
|
|
114
|
+
setSearch("");
|
|
115
|
+
setStatusFilter("all");
|
|
116
|
+
}
|
|
117
|
+
|
|
99
118
|
return (
|
|
100
119
|
<div className="space-y-4">
|
|
101
120
|
<div className="flex flex-wrap items-center gap-3">
|
|
@@ -104,12 +123,14 @@ export function SpecificationTable({
|
|
|
104
123
|
placeholder="Search by ID, title, or requirement..."
|
|
105
124
|
value={search}
|
|
106
125
|
onChange={(e) => setSearch(e.target.value)}
|
|
126
|
+
aria-label="Search specifications by ID, title, or requirement"
|
|
107
127
|
className="rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
108
128
|
/>
|
|
109
129
|
<select
|
|
110
130
|
value={statusFilter}
|
|
111
131
|
onChange={(e) => setStatusFilter(e.target.value as Status | "all")}
|
|
112
|
-
|
|
132
|
+
aria-label="Filter by status"
|
|
133
|
+
className="rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
113
134
|
>
|
|
114
135
|
{STATUS_OPTIONS.map((o) => (
|
|
115
136
|
<option key={o.value} value={o.value}>
|
|
@@ -124,7 +145,7 @@ export function SpecificationTable({
|
|
|
124
145
|
|
|
125
146
|
<div className="overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm">
|
|
126
147
|
<table className="min-w-full divide-y divide-gray-200">
|
|
127
|
-
<thead className="bg-gray-50">
|
|
148
|
+
<thead className="bg-gray-50 border-b-2 border-gray-200">
|
|
128
149
|
<tr>
|
|
129
150
|
{renderSortHeader("id", "ID")}
|
|
130
151
|
{renderSortHeader("title", "Title")}
|
|
@@ -137,14 +158,35 @@ export function SpecificationTable({
|
|
|
137
158
|
<tbody className="divide-y divide-gray-200">
|
|
138
159
|
{filtered.length === 0 ? (
|
|
139
160
|
<tr>
|
|
140
|
-
<td colSpan={6} className="px-4 py-
|
|
141
|
-
|
|
161
|
+
<td colSpan={6} className="px-4 py-12 text-center">
|
|
162
|
+
<div className="text-gray-400">
|
|
163
|
+
<svg className="mx-auto h-12 w-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
164
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
|
165
|
+
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
166
|
+
</svg>
|
|
167
|
+
<p className="mt-2 text-sm font-medium text-gray-900">No specifications found</p>
|
|
168
|
+
<p className="mt-1 text-sm text-gray-500">Try adjusting your search or filter criteria</p>
|
|
169
|
+
{hasActiveFilters && (
|
|
170
|
+
<button
|
|
171
|
+
type="button"
|
|
172
|
+
onClick={clearAllFilters}
|
|
173
|
+
className="mt-3 text-sm font-medium text-blue-600 hover:text-blue-800"
|
|
174
|
+
>
|
|
175
|
+
Clear all filters
|
|
176
|
+
</button>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
142
179
|
</td>
|
|
143
180
|
</tr>
|
|
144
181
|
) : (
|
|
145
|
-
filtered.map((spec) => (
|
|
146
|
-
<tr
|
|
147
|
-
|
|
182
|
+
filtered.map((spec, index) => (
|
|
183
|
+
<tr
|
|
184
|
+
key={spec.id}
|
|
185
|
+
className={`hover:bg-blue-50/50 transition-colors duration-150 ${
|
|
186
|
+
index % 2 === 1 ? "bg-gray-50/50" : ""
|
|
187
|
+
}`}
|
|
188
|
+
>
|
|
189
|
+
<td className="whitespace-nowrap px-4 py-3.5 text-sm font-mono">
|
|
148
190
|
<Link
|
|
149
191
|
href={`/specifications/${spec.id}`}
|
|
150
192
|
className="text-blue-600 hover:text-blue-800 hover:underline"
|
|
@@ -152,7 +194,7 @@ export function SpecificationTable({
|
|
|
152
194
|
{spec.id}
|
|
153
195
|
</Link>
|
|
154
196
|
</td>
|
|
155
|
-
<td className="px-4 py-3 text-sm">
|
|
197
|
+
<td className="px-4 py-3.5 text-sm">
|
|
156
198
|
<Link
|
|
157
199
|
href={`/specifications/${spec.id}`}
|
|
158
200
|
className="text-gray-900 hover:text-blue-600"
|
|
@@ -160,7 +202,7 @@ export function SpecificationTable({
|
|
|
160
202
|
{spec.title || <span className="italic text-gray-400">Untitled</span>}
|
|
161
203
|
</Link>
|
|
162
204
|
</td>
|
|
163
|
-
<td className="px-4 py-3 text-sm">
|
|
205
|
+
<td className="px-4 py-3.5 text-sm">
|
|
164
206
|
<Link
|
|
165
207
|
href={`/requirements/${spec.requirementId}`}
|
|
166
208
|
className="text-blue-600 hover:text-blue-800 hover:underline"
|
|
@@ -173,13 +215,13 @@ export function SpecificationTable({
|
|
|
173
215
|
</span>
|
|
174
216
|
</Link>
|
|
175
217
|
</td>
|
|
176
|
-
<td className="whitespace-nowrap px-4 py-3">
|
|
218
|
+
<td className="whitespace-nowrap px-4 py-3.5">
|
|
177
219
|
<StatusBadge status={spec.status} />
|
|
178
220
|
</td>
|
|
179
|
-
<td className="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
|
|
221
|
+
<td className="whitespace-nowrap px-4 py-3.5 text-sm text-gray-500">
|
|
180
222
|
{spec.version}
|
|
181
223
|
</td>
|
|
182
|
-
<td className="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
|
|
224
|
+
<td className="whitespace-nowrap px-4 py-3.5 text-sm text-gray-500">
|
|
183
225
|
{new Date(spec.updatedAt).toLocaleDateString("ja-JP")}
|
|
184
226
|
</td>
|
|
185
227
|
</tr>
|
|
@@ -2,10 +2,10 @@ import React from "react";
|
|
|
2
2
|
import type { Status, Priority, Complexity } from "@reqord/shared";
|
|
3
3
|
|
|
4
4
|
const STATUS_STYLES: Record<Status, string> = {
|
|
5
|
-
draft: "bg-gray-100 text-gray-
|
|
6
|
-
approved: "bg-
|
|
7
|
-
implemented: "bg-
|
|
8
|
-
deprecated: "bg-red-
|
|
5
|
+
draft: "bg-gray-100 text-gray-600 ring-1 ring-inset ring-gray-300",
|
|
6
|
+
approved: "bg-blue-50 text-blue-700 ring-1 ring-inset ring-blue-200",
|
|
7
|
+
implemented: "bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-200",
|
|
8
|
+
deprecated: "bg-red-50 text-red-700 ring-1 ring-inset ring-red-200",
|
|
9
9
|
};
|
|
10
10
|
|
|
11
11
|
const STATUS_LABELS: Record<Status, string> = {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
|
4
|
+
type ButtonSize = "sm" | "md" | "lg";
|
|
5
|
+
|
|
6
|
+
const variantClasses: Record<ButtonVariant, string> = {
|
|
7
|
+
primary:
|
|
8
|
+
"bg-warm-800 text-white hover:bg-warm-900",
|
|
9
|
+
secondary:
|
|
10
|
+
"border border-warm-300 bg-white text-warm-700 hover:bg-warm-50",
|
|
11
|
+
danger: "bg-accent text-white hover:bg-accent-hover",
|
|
12
|
+
ghost: "text-warm-700 hover:bg-warm-200/60 hover:text-warm-900",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const sizeClasses: Record<ButtonSize, string> = {
|
|
16
|
+
sm: "px-3 py-1.5 text-xs",
|
|
17
|
+
md: "px-4 py-2 text-sm",
|
|
18
|
+
lg: "px-6 py-3 text-base",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export interface ButtonProps
|
|
22
|
+
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
23
|
+
variant?: ButtonVariant;
|
|
24
|
+
size?: ButtonSize;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function Button({
|
|
28
|
+
variant = "primary",
|
|
29
|
+
size = "md",
|
|
30
|
+
className = "",
|
|
31
|
+
children,
|
|
32
|
+
...props
|
|
33
|
+
}: ButtonProps) {
|
|
34
|
+
return (
|
|
35
|
+
<button
|
|
36
|
+
type="button"
|
|
37
|
+
className={`inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none disabled:opacity-50 ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
|
|
38
|
+
{...props}
|
|
39
|
+
>
|
|
40
|
+
{children}
|
|
41
|
+
</button>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
type CardVariant = "default" | "hero";
|
|
4
|
+
|
|
5
|
+
const variantClasses: Record<CardVariant, string> = {
|
|
6
|
+
default: "rounded-xl border border-warm-200 bg-white p-6 shadow-sm",
|
|
7
|
+
hero: "rounded-xl border border-warm-200 bg-warm-100 p-6 shadow-sm",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
11
|
+
variant?: CardVariant;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function Card({
|
|
15
|
+
variant = "default",
|
|
16
|
+
className = "",
|
|
17
|
+
children,
|
|
18
|
+
...props
|
|
19
|
+
}: CardProps) {
|
|
20
|
+
return (
|
|
21
|
+
<div className={`${variantClasses[variant]} ${className}`} {...props}>
|
|
22
|
+
{children}
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -1,45 +1,45 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import React from "react";
|
|
1
3
|
import Link from "next/link";
|
|
4
|
+
import { usePathname } from "next/navigation";
|
|
5
|
+
|
|
6
|
+
const NAV_ITEMS = [
|
|
7
|
+
{ href: "/dashboard", label: "Dashboard" },
|
|
8
|
+
{ href: "/requirements", label: "Requirements" },
|
|
9
|
+
{ href: "/specifications", label: "Specifications" },
|
|
10
|
+
{ href: "/feedback", label: "Feedback" },
|
|
11
|
+
{ href: "/graph", label: "Graph" },
|
|
12
|
+
];
|
|
2
13
|
|
|
3
14
|
export function Nav() {
|
|
15
|
+
const pathname = usePathname();
|
|
4
16
|
return (
|
|
5
|
-
<nav className="border-b border-
|
|
17
|
+
<nav className="border-b border-warm-200 bg-warm-100">
|
|
6
18
|
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
|
7
|
-
<div className="flex h-
|
|
19
|
+
<div className="flex h-16 items-center justify-between">
|
|
8
20
|
<div className="flex items-center gap-8">
|
|
9
|
-
<Link href="/
|
|
10
|
-
Reqord
|
|
21
|
+
<Link href="/dashboard" className="flex items-center">
|
|
22
|
+
<img src="/reqord-logo.svg" alt="Reqord" className="h-10" />
|
|
11
23
|
</Link>
|
|
12
|
-
<div className="flex gap-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
<Link
|
|
32
|
-
href="/feedback"
|
|
33
|
-
className="text-sm font-medium text-gray-600 hover:text-gray-900"
|
|
34
|
-
>
|
|
35
|
-
Feedback
|
|
36
|
-
</Link>
|
|
37
|
-
<Link
|
|
38
|
-
href="/graph"
|
|
39
|
-
className="text-sm font-medium text-gray-600 hover:text-gray-900"
|
|
40
|
-
>
|
|
41
|
-
Graph
|
|
42
|
-
</Link>
|
|
24
|
+
<div className="flex gap-1">
|
|
25
|
+
{NAV_ITEMS.map(({ href, label }) => {
|
|
26
|
+
const isActive =
|
|
27
|
+
pathname === href || pathname.startsWith(href + "/");
|
|
28
|
+
return (
|
|
29
|
+
<Link
|
|
30
|
+
key={href}
|
|
31
|
+
href={href}
|
|
32
|
+
aria-current={isActive ? "page" : undefined}
|
|
33
|
+
className={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
|
34
|
+
isActive
|
|
35
|
+
? "bg-warm-200 text-warm-900 font-semibold"
|
|
36
|
+
: "text-warm-700 hover:bg-warm-200/60 hover:text-warm-900"
|
|
37
|
+
}`}
|
|
38
|
+
>
|
|
39
|
+
{label}
|
|
40
|
+
</Link>
|
|
41
|
+
);
|
|
42
|
+
})}
|
|
43
43
|
</div>
|
|
44
44
|
</div>
|
|
45
45
|
</div>
|
|
@@ -19,6 +19,8 @@ export function Tabs({ tabs, activeTab, onTabChange }: TabsProps) {
|
|
|
19
19
|
return (
|
|
20
20
|
<button
|
|
21
21
|
key={tab.id}
|
|
22
|
+
role="tab"
|
|
23
|
+
aria-selected={isActive}
|
|
22
24
|
onClick={() => onTabChange(tab.id)}
|
|
23
25
|
className={`
|
|
24
26
|
whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium
|
|
@@ -23,11 +23,29 @@ export function buildDrillDownGraphData(
|
|
|
23
23
|
const nodes: Node[] = [];
|
|
24
24
|
const edges: Edge[] = [];
|
|
25
25
|
|
|
26
|
-
//
|
|
26
|
+
// Pre-calculate task counts per spec for dynamic Y positioning
|
|
27
|
+
const specTaskCounts = specifications.map(
|
|
28
|
+
(spec) =>
|
|
29
|
+
tasks.filter(
|
|
30
|
+
(t) => t.linkedTo.specifications.includes(spec.id),
|
|
31
|
+
).length,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
// Calculate cumulative Y positions for specs based on previous spec's task count
|
|
35
|
+
const specYPositions: number[] = [];
|
|
36
|
+
let currentY = 0;
|
|
37
|
+
for (let i = 0; i < specifications.length; i++) {
|
|
38
|
+
specYPositions.push(currentY);
|
|
39
|
+
const issueHeight = specTaskCounts[i] * LAYOUT.ISSUE_VERTICAL_GAP;
|
|
40
|
+
currentY += Math.max(LAYOUT.VERTICAL_GAP, issueHeight);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Center requirement node vertically
|
|
44
|
+
const lastSpecY = specYPositions.length > 0 ? specYPositions[specYPositions.length - 1] : 0;
|
|
27
45
|
nodes.push({
|
|
28
46
|
id: requirement.id,
|
|
29
47
|
type: "requirement",
|
|
30
|
-
position: { x: LAYOUT.REQ_X, y:
|
|
48
|
+
position: { x: LAYOUT.REQ_X, y: lastSpecY / 2 },
|
|
31
49
|
data: {
|
|
32
50
|
label: requirement.title,
|
|
33
51
|
status: requirement.status,
|
|
@@ -40,10 +58,11 @@ export function buildDrillDownGraphData(
|
|
|
40
58
|
const addedIssueIds = new Set<string>();
|
|
41
59
|
specifications.forEach((spec, i) => {
|
|
42
60
|
const specNodeId = spec.id;
|
|
61
|
+
const specY = specYPositions[i];
|
|
43
62
|
nodes.push({
|
|
44
63
|
id: specNodeId,
|
|
45
64
|
type: "specification",
|
|
46
|
-
position: { x: LAYOUT.SPEC_X, y:
|
|
65
|
+
position: { x: LAYOUT.SPEC_X, y: specY },
|
|
47
66
|
data: {
|
|
48
67
|
label: spec.id,
|
|
49
68
|
status: spec.status,
|
|
@@ -72,7 +91,7 @@ export function buildDrillDownGraphData(
|
|
|
72
91
|
type: "issue",
|
|
73
92
|
position: {
|
|
74
93
|
x: LAYOUT.ISSUE_X,
|
|
75
|
-
y:
|
|
94
|
+
y: specY + issueOffset * LAYOUT.ISSUE_VERTICAL_GAP,
|
|
76
95
|
},
|
|
77
96
|
data: {
|
|
78
97
|
label: task.title,
|