@reqord/web 0.2.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
|
@@ -5,24 +5,76 @@ type WarningAlertProps = {
|
|
|
5
5
|
warning: Warning;
|
|
6
6
|
};
|
|
7
7
|
|
|
8
|
+
function ErrorIcon() {
|
|
9
|
+
return (
|
|
10
|
+
<svg
|
|
11
|
+
className="h-5 w-5 text-red-500"
|
|
12
|
+
viewBox="0 0 20 20"
|
|
13
|
+
fill="currentColor"
|
|
14
|
+
aria-hidden="true"
|
|
15
|
+
>
|
|
16
|
+
<path
|
|
17
|
+
fillRule="evenodd"
|
|
18
|
+
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z"
|
|
19
|
+
clipRule="evenodd"
|
|
20
|
+
/>
|
|
21
|
+
</svg>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function WarningIcon() {
|
|
26
|
+
return (
|
|
27
|
+
<svg
|
|
28
|
+
className="h-5 w-5 text-yellow-500"
|
|
29
|
+
viewBox="0 0 20 20"
|
|
30
|
+
fill="currentColor"
|
|
31
|
+
aria-hidden="true"
|
|
32
|
+
>
|
|
33
|
+
<path
|
|
34
|
+
fillRule="evenodd"
|
|
35
|
+
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
|
|
36
|
+
clipRule="evenodd"
|
|
37
|
+
/>
|
|
38
|
+
</svg>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function InfoIcon() {
|
|
43
|
+
return (
|
|
44
|
+
<svg
|
|
45
|
+
className="h-5 w-5 text-blue-500"
|
|
46
|
+
viewBox="0 0 20 20"
|
|
47
|
+
fill="currentColor"
|
|
48
|
+
aria-hidden="true"
|
|
49
|
+
>
|
|
50
|
+
<path
|
|
51
|
+
fillRule="evenodd"
|
|
52
|
+
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z"
|
|
53
|
+
clipRule="evenodd"
|
|
54
|
+
/>
|
|
55
|
+
</svg>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
8
59
|
export function WarningAlert({ warning }: WarningAlertProps) {
|
|
9
|
-
let borderColor = "border-blue-500";
|
|
10
60
|
let bgColor = "bg-blue-50";
|
|
61
|
+
let icon = <InfoIcon />;
|
|
11
62
|
|
|
12
63
|
if (warning.severity === "error") {
|
|
13
|
-
borderColor = "border-red-500";
|
|
14
64
|
bgColor = "bg-red-50";
|
|
65
|
+
icon = <ErrorIcon />;
|
|
15
66
|
} else if (warning.severity === "warning") {
|
|
16
|
-
borderColor = "border-yellow-500";
|
|
17
67
|
bgColor = "bg-yellow-50";
|
|
68
|
+
icon = <WarningIcon />;
|
|
18
69
|
}
|
|
19
70
|
|
|
20
71
|
return (
|
|
21
72
|
<div
|
|
22
73
|
data-testid="warning-alert"
|
|
23
|
-
className={`rounded-
|
|
74
|
+
className={`rounded-lg p-4 ${bgColor}`}
|
|
24
75
|
>
|
|
25
|
-
<div className="flex items-start">
|
|
76
|
+
<div className="flex items-start gap-3">
|
|
77
|
+
<div className="flex-shrink-0 pt-0.5">{icon}</div>
|
|
26
78
|
<div className="flex-1">
|
|
27
79
|
<p className="text-sm font-medium text-gray-900">{warning.message}</p>
|
|
28
80
|
<p className="mt-1 text-xs text-gray-600">{warning.relatedId}</p>
|
|
@@ -8,7 +8,15 @@ export interface FeedbackFilterState {
|
|
|
8
8
|
status?: string;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
const TYPE_OPTIONS
|
|
11
|
+
const TYPE_OPTIONS: { value: string; label: string }[] = [
|
|
12
|
+
{ value: "all", label: "all" },
|
|
13
|
+
{ value: "bug", label: "bug" },
|
|
14
|
+
{ value: "improvement", label: "improvement" },
|
|
15
|
+
{ value: "requirement-gap", label: "Req Gap" },
|
|
16
|
+
{ value: "spec-mismatch", label: "Spec Mismatch" },
|
|
17
|
+
{ value: "security", label: "security" },
|
|
18
|
+
];
|
|
19
|
+
|
|
12
20
|
const SEVERITY_OPTIONS = ["all", "critical", "high", "medium", "low"];
|
|
13
21
|
const STATUS_OPTIONS = ["all", "open", "closed"];
|
|
14
22
|
|
|
@@ -19,7 +27,7 @@ function SegmentedButtons({
|
|
|
19
27
|
onChange,
|
|
20
28
|
}: {
|
|
21
29
|
label: string;
|
|
22
|
-
options: string[];
|
|
30
|
+
options: { value: string; label: string }[];
|
|
23
31
|
value: string;
|
|
24
32
|
onChange: (v: string) => void;
|
|
25
33
|
}) {
|
|
@@ -29,18 +37,18 @@ function SegmentedButtons({
|
|
|
29
37
|
<div className="flex gap-1">
|
|
30
38
|
{options.map((opt) => (
|
|
31
39
|
<button
|
|
32
|
-
key={opt}
|
|
33
|
-
onClick={() => onChange(opt)}
|
|
34
|
-
aria-label={`${label} ${opt}`}
|
|
35
|
-
aria-pressed={value === opt}
|
|
36
|
-
className={`rounded-md px-
|
|
37
|
-
value === opt
|
|
40
|
+
key={opt.value}
|
|
41
|
+
onClick={() => onChange(opt.value)}
|
|
42
|
+
aria-label={`${label} ${opt.label}`}
|
|
43
|
+
aria-pressed={value === opt.value}
|
|
44
|
+
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
|
|
45
|
+
value === opt.value
|
|
38
46
|
? "bg-blue-600 text-white"
|
|
39
47
|
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
|
40
48
|
}`}
|
|
41
|
-
data-testid={`filter-${label.toLowerCase()}-${opt}`}
|
|
49
|
+
data-testid={`filter-${label.toLowerCase()}-${opt.value}`}
|
|
42
50
|
>
|
|
43
|
-
{opt}
|
|
51
|
+
{opt.label}
|
|
44
52
|
</button>
|
|
45
53
|
))}
|
|
46
54
|
</div>
|
|
@@ -48,6 +56,27 @@ function SegmentedButtons({
|
|
|
48
56
|
);
|
|
49
57
|
}
|
|
50
58
|
|
|
59
|
+
function SimpleSegmentedButtons({
|
|
60
|
+
label,
|
|
61
|
+
options,
|
|
62
|
+
value,
|
|
63
|
+
onChange,
|
|
64
|
+
}: {
|
|
65
|
+
label: string;
|
|
66
|
+
options: string[];
|
|
67
|
+
value: string;
|
|
68
|
+
onChange: (v: string) => void;
|
|
69
|
+
}) {
|
|
70
|
+
return (
|
|
71
|
+
<SegmentedButtons
|
|
72
|
+
label={label}
|
|
73
|
+
options={options.map((o) => ({ value: o, label: o }))}
|
|
74
|
+
value={value}
|
|
75
|
+
onChange={onChange}
|
|
76
|
+
/>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
51
80
|
export function FeedbackFilters({
|
|
52
81
|
activeFilters,
|
|
53
82
|
onFilterChange,
|
|
@@ -65,7 +94,7 @@ export function FeedbackFilters({
|
|
|
65
94
|
onFilterChange({ ...activeFilters, type: v === "all" ? undefined : v })
|
|
66
95
|
}
|
|
67
96
|
/>
|
|
68
|
-
<
|
|
97
|
+
<SimpleSegmentedButtons
|
|
69
98
|
label="Severity"
|
|
70
99
|
options={SEVERITY_OPTIONS}
|
|
71
100
|
value={activeFilters.severity ?? "all"}
|
|
@@ -73,7 +102,7 @@ export function FeedbackFilters({
|
|
|
73
102
|
onFilterChange({ ...activeFilters, severity: v === "all" ? undefined : v })
|
|
74
103
|
}
|
|
75
104
|
/>
|
|
76
|
-
<
|
|
105
|
+
<SimpleSegmentedButtons
|
|
77
106
|
label="Status"
|
|
78
107
|
options={STATUS_OPTIONS}
|
|
79
108
|
value={activeFilters.status ?? "all"}
|
|
@@ -23,7 +23,7 @@ export function DrillDownBreadcrumb({ requirementTitle, onBack }: DrillDownBread
|
|
|
23
23
|
<div className="mb-4 flex items-center gap-2 text-sm text-gray-600">
|
|
24
24
|
<button
|
|
25
25
|
onClick={onBack}
|
|
26
|
-
className="flex items-center gap-1 rounded px-3 py-1 hover:bg-
|
|
26
|
+
className="flex items-center gap-1 rounded px-3 py-1 hover:bg-warm-200/60 hover:text-warm-900"
|
|
27
27
|
>
|
|
28
28
|
← Back to overview
|
|
29
29
|
</button>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { useMemo } from "react";
|
|
4
4
|
import { ReactFlow, Background, Controls } from "@xyflow/react";
|
|
5
|
+
import "@xyflow/react/dist/style.css";
|
|
5
6
|
import { RequirementNode } from "./requirement-node";
|
|
6
7
|
import { SpecificationNode } from "./specification-node";
|
|
7
8
|
import { IssueNode } from "./issue-node";
|
|
@@ -33,7 +34,8 @@ export function DrillDownGraph({ requirement, specifications, tasks }: DrillDown
|
|
|
33
34
|
edges={edges}
|
|
34
35
|
nodeTypes={nodeTypes}
|
|
35
36
|
fitView
|
|
36
|
-
|
|
37
|
+
fitViewOptions={{ padding: 0.1, maxZoom: 1 }}
|
|
38
|
+
minZoom={0.2}
|
|
37
39
|
maxZoom={2}
|
|
38
40
|
proOptions={{ hideAttribution: true }}
|
|
39
41
|
>
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
export const EDGE_STYLES = {
|
|
2
2
|
dependency: {
|
|
3
|
-
stroke: "#
|
|
3
|
+
stroke: "#94a3b8",
|
|
4
4
|
strokeWidth: 2,
|
|
5
5
|
},
|
|
6
6
|
implements: {
|
|
7
|
-
stroke: "#
|
|
7
|
+
stroke: "#dc2626",
|
|
8
8
|
strokeWidth: 2,
|
|
9
9
|
strokeDasharray: "5,5",
|
|
10
10
|
},
|
|
11
11
|
tracks: {
|
|
12
|
-
stroke: "#
|
|
12
|
+
stroke: "#a855f7",
|
|
13
13
|
strokeWidth: 1.5,
|
|
14
14
|
strokeDasharray: "2,2",
|
|
15
15
|
},
|
|
@@ -4,9 +4,9 @@ import React, { memo } from "react";
|
|
|
4
4
|
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
|
5
5
|
|
|
6
6
|
const STATUS_COLORS: Record<string, string> = {
|
|
7
|
-
open: "bg-
|
|
8
|
-
in_progress: "bg-blue-
|
|
9
|
-
closed: "bg-
|
|
7
|
+
open: "border border-amber-300 bg-amber-50",
|
|
8
|
+
in_progress: "border border-blue-300 bg-blue-50",
|
|
9
|
+
closed: "border border-emerald-300 bg-emerald-50",
|
|
10
10
|
};
|
|
11
11
|
|
|
12
12
|
type IssueNodeData = {
|
|
@@ -18,7 +18,7 @@ type IssueNodeData = {
|
|
|
18
18
|
|
|
19
19
|
function IssueNodeComponent({ data }: NodeProps) {
|
|
20
20
|
const nodeData = data as IssueNodeData;
|
|
21
|
-
const
|
|
21
|
+
const colorClass = STATUS_COLORS[nodeData.status] ?? "border border-gray-300 bg-gray-50";
|
|
22
22
|
|
|
23
23
|
const handleClick = () => {
|
|
24
24
|
if (nodeData.issueUrl) {
|
|
@@ -28,14 +28,14 @@ function IssueNodeComponent({ data }: NodeProps) {
|
|
|
28
28
|
|
|
29
29
|
return (
|
|
30
30
|
<div
|
|
31
|
-
className={`rounded-lg
|
|
31
|
+
className={`rounded-lg shadow-sm px-3 py-2 cursor-pointer ${colorClass}`}
|
|
32
32
|
style={{ width: 180 }}
|
|
33
33
|
onClick={handleClick}
|
|
34
34
|
>
|
|
35
|
-
<Handle type="target" position={Position.Left} className="!bg-gray-
|
|
35
|
+
<Handle type="target" position={Position.Left} className="!bg-gray-300" />
|
|
36
36
|
<p className="truncate text-sm font-medium text-gray-900">{nodeData.label}</p>
|
|
37
37
|
<div className="mt-1">
|
|
38
|
-
<span className="inline-block rounded-full bg-white px-2 py-0.5 text-xs text-gray-700">
|
|
38
|
+
<span className="inline-block rounded-full bg-white/80 backdrop-blur-sm px-2 py-0.5 text-xs text-gray-700">
|
|
39
39
|
{nodeData.status}
|
|
40
40
|
</span>
|
|
41
41
|
</div>
|
|
@@ -30,8 +30,8 @@ const EDGE_STYLES = {
|
|
|
30
30
|
animated: false,
|
|
31
31
|
},
|
|
32
32
|
implements: {
|
|
33
|
-
style: { stroke: "#
|
|
34
|
-
markerEnd: { type: "arrowclosed" as const, color: "#
|
|
33
|
+
style: { stroke: "#dc2626", strokeWidth: 2, strokeDasharray: "5,5" },
|
|
34
|
+
markerEnd: { type: "arrowclosed" as const, color: "#dc2626" },
|
|
35
35
|
animated: false,
|
|
36
36
|
},
|
|
37
37
|
tracks: {
|
|
@@ -6,8 +6,8 @@ import { useRouter } from "next/navigation";
|
|
|
6
6
|
|
|
7
7
|
const STATUS_COLORS: Record<string, string> = {
|
|
8
8
|
draft: "border-gray-300 bg-gray-50",
|
|
9
|
-
approved: "border-
|
|
10
|
-
implemented: "border-
|
|
9
|
+
approved: "border-blue-300 bg-blue-50",
|
|
10
|
+
implemented: "border-emerald-300 bg-emerald-50",
|
|
11
11
|
deprecated: "border-red-300 bg-red-50",
|
|
12
12
|
};
|
|
13
13
|
|
|
@@ -43,11 +43,11 @@ function RequirementNodeComponent({ data, id }: NodeProps) {
|
|
|
43
43
|
|
|
44
44
|
return (
|
|
45
45
|
<div
|
|
46
|
-
className={`cursor-pointer rounded-lg border-2 px-3 py-2 shadow-
|
|
46
|
+
className={`cursor-pointer rounded-lg border-2 px-3 py-2 shadow-md ${borderClass}`}
|
|
47
47
|
style={{ width: 220 }}
|
|
48
48
|
onClick={handleBodyClick}
|
|
49
49
|
>
|
|
50
|
-
<Handle type="target" position={Position.Left} className="!bg-gray-
|
|
50
|
+
<Handle type="target" position={Position.Left} className="!bg-gray-300" />
|
|
51
51
|
<p className="truncate text-xs font-mono text-gray-500">{id}</p>
|
|
52
52
|
<p className="mt-0.5 truncate text-sm font-medium text-gray-900">
|
|
53
53
|
{nodeData.label}
|
|
@@ -61,7 +61,7 @@ function RequirementNodeComponent({ data, id }: NodeProps) {
|
|
|
61
61
|
📄 {nodeData.specCount} spec{nodeData.specCount > 1 ? "s" : ""}
|
|
62
62
|
</button>
|
|
63
63
|
) : null}
|
|
64
|
-
<Handle type="source" position={Position.Right} className="!bg-gray-
|
|
64
|
+
<Handle type="source" position={Position.Right} className="!bg-gray-300" />
|
|
65
65
|
</div>
|
|
66
66
|
);
|
|
67
67
|
}
|
|
@@ -4,10 +4,10 @@ import React, { memo } from "react";
|
|
|
4
4
|
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
|
5
5
|
|
|
6
6
|
const STATUS_COLORS: Record<string, string> = {
|
|
7
|
-
draft: "bg-
|
|
8
|
-
approved: "bg-
|
|
9
|
-
implemented: "bg-emerald-
|
|
10
|
-
deprecated: "bg-red-
|
|
7
|
+
draft: "border border-gray-300 bg-gray-100",
|
|
8
|
+
approved: "border border-blue-300 bg-blue-100",
|
|
9
|
+
implemented: "border border-emerald-300 bg-emerald-100",
|
|
10
|
+
deprecated: "border border-red-300 bg-red-100",
|
|
11
11
|
};
|
|
12
12
|
|
|
13
13
|
type SpecificationNodeData = {
|
|
@@ -17,21 +17,24 @@ type SpecificationNodeData = {
|
|
|
17
17
|
|
|
18
18
|
function SpecificationNodeComponent({ data, id }: NodeProps) {
|
|
19
19
|
const nodeData = data as SpecificationNodeData;
|
|
20
|
-
const
|
|
20
|
+
const colorClass = STATUS_COLORS[nodeData.status] ?? "border border-gray-300 bg-gray-100";
|
|
21
21
|
|
|
22
22
|
return (
|
|
23
23
|
<div
|
|
24
|
-
className={`rounded-lg
|
|
24
|
+
className={`rounded-lg shadow-sm px-3 py-2 ${colorClass}`}
|
|
25
25
|
style={{ width: 200 }}
|
|
26
26
|
>
|
|
27
|
-
<Handle type="target" position={Position.Left} className="!bg-gray-
|
|
27
|
+
<Handle type="target" position={Position.Left} className="!bg-gray-300" />
|
|
28
28
|
<p className="truncate text-xs font-mono text-gray-900">{id}</p>
|
|
29
|
+
{nodeData.label && nodeData.label !== id && (
|
|
30
|
+
<p className="mt-0.5 truncate text-sm font-medium text-gray-800">{nodeData.label}</p>
|
|
31
|
+
)}
|
|
29
32
|
<div className="mt-1">
|
|
30
|
-
<span className="inline-block rounded-full bg-white px-2 py-0.5 text-xs text-gray-700">
|
|
33
|
+
<span className="inline-block rounded-full bg-white/80 backdrop-blur-sm px-2 py-0.5 text-xs text-gray-700">
|
|
31
34
|
{nodeData.status}
|
|
32
35
|
</span>
|
|
33
36
|
</div>
|
|
34
|
-
<Handle type="source" position={Position.Right} className="!bg-gray-
|
|
37
|
+
<Handle type="source" position={Position.Right} className="!bg-gray-300" />
|
|
35
38
|
</div>
|
|
36
39
|
);
|
|
37
40
|
}
|
|
@@ -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 { Requirement, Status, Priority } from "@reqord/shared";
|
|
6
6
|
import { StatusBadge, PriorityBadge, ComplexityBadge } from "@/components/ui/badge";
|
|
@@ -87,18 +87,38 @@ export function RequirementTable({ requirements }: { requirements: Requirement[]
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
function renderSortHeader(column: SortKey, label: string) {
|
|
90
|
-
const
|
|
90
|
+
const isActive = sortKey === column;
|
|
91
91
|
return (
|
|
92
92
|
<th
|
|
93
93
|
key={column}
|
|
94
|
-
|
|
95
|
-
|
|
94
|
+
scope="col"
|
|
95
|
+
aria-sort={isActive ? (sortDir === "asc" ? "ascending" : "descending") : undefined}
|
|
96
|
+
className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
|
96
97
|
>
|
|
97
|
-
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
onClick={() => handleSort(column)}
|
|
101
|
+
className={`inline-flex items-center gap-1 transition-colors ${
|
|
102
|
+
isActive ? "text-warm-900 font-semibold" : "hover:text-gray-900"
|
|
103
|
+
}`}
|
|
104
|
+
>
|
|
105
|
+
{label}
|
|
106
|
+
<span aria-hidden="true" className={`text-[10px] ${isActive ? "opacity-100" : "opacity-30"}`}>
|
|
107
|
+
{isActive && sortDir === "desc" ? "▼" : "▲"}
|
|
108
|
+
</span>
|
|
109
|
+
</button>
|
|
98
110
|
</th>
|
|
99
111
|
);
|
|
100
112
|
}
|
|
101
113
|
|
|
114
|
+
const hasActiveFilters = search || statusFilter !== "all" || priorityFilter !== "all";
|
|
115
|
+
|
|
116
|
+
function clearAllFilters() {
|
|
117
|
+
setSearch("");
|
|
118
|
+
setStatusFilter("all");
|
|
119
|
+
setPriorityFilter("all");
|
|
120
|
+
}
|
|
121
|
+
|
|
102
122
|
return (
|
|
103
123
|
<div className="space-y-4">
|
|
104
124
|
<div className="flex flex-wrap items-center gap-3">
|
|
@@ -107,12 +127,14 @@ export function RequirementTable({ requirements }: { requirements: Requirement[]
|
|
|
107
127
|
placeholder="Search by ID or title..."
|
|
108
128
|
value={search}
|
|
109
129
|
onChange={(e) => setSearch(e.target.value)}
|
|
130
|
+
aria-label="Search requirements by ID or title"
|
|
110
131
|
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"
|
|
111
132
|
/>
|
|
112
133
|
<select
|
|
113
134
|
value={statusFilter}
|
|
114
135
|
onChange={(e) => setStatusFilter(e.target.value as Status | "all")}
|
|
115
|
-
|
|
136
|
+
aria-label="Filter by status"
|
|
137
|
+
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"
|
|
116
138
|
>
|
|
117
139
|
{STATUS_OPTIONS.map((o) => (
|
|
118
140
|
<option key={o.value} value={o.value}>
|
|
@@ -123,7 +145,8 @@ export function RequirementTable({ requirements }: { requirements: Requirement[]
|
|
|
123
145
|
<select
|
|
124
146
|
value={priorityFilter}
|
|
125
147
|
onChange={(e) => setPriorityFilter(e.target.value as Priority | "all")}
|
|
126
|
-
|
|
148
|
+
aria-label="Filter by priority"
|
|
149
|
+
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"
|
|
127
150
|
>
|
|
128
151
|
{PRIORITY_OPTIONS.map((o) => (
|
|
129
152
|
<option key={o.value} value={o.value}>
|
|
@@ -138,7 +161,7 @@ export function RequirementTable({ requirements }: { requirements: Requirement[]
|
|
|
138
161
|
|
|
139
162
|
<div className="overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm">
|
|
140
163
|
<table className="min-w-full divide-y divide-gray-200">
|
|
141
|
-
<thead className="bg-gray-50">
|
|
164
|
+
<thead className="bg-gray-50 border-b-2 border-gray-200">
|
|
142
165
|
<tr>
|
|
143
166
|
{renderSortHeader("id", "ID")}
|
|
144
167
|
{renderSortHeader("title", "Title")}
|
|
@@ -153,14 +176,35 @@ export function RequirementTable({ requirements }: { requirements: Requirement[]
|
|
|
153
176
|
<tbody className="divide-y divide-gray-200">
|
|
154
177
|
{filtered.length === 0 ? (
|
|
155
178
|
<tr>
|
|
156
|
-
<td colSpan={6} className="px-4 py-
|
|
157
|
-
|
|
179
|
+
<td colSpan={6} className="px-4 py-12 text-center">
|
|
180
|
+
<div className="text-gray-400">
|
|
181
|
+
<svg className="mx-auto h-12 w-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
182
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
|
183
|
+
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
184
|
+
</svg>
|
|
185
|
+
<p className="mt-2 text-sm font-medium text-gray-900">No requirements found</p>
|
|
186
|
+
<p className="mt-1 text-sm text-gray-500">Try adjusting your search or filter criteria</p>
|
|
187
|
+
{hasActiveFilters && (
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
onClick={clearAllFilters}
|
|
191
|
+
className="mt-3 text-sm font-medium text-blue-600 hover:text-blue-800"
|
|
192
|
+
>
|
|
193
|
+
Clear all filters
|
|
194
|
+
</button>
|
|
195
|
+
)}
|
|
196
|
+
</div>
|
|
158
197
|
</td>
|
|
159
198
|
</tr>
|
|
160
199
|
) : (
|
|
161
|
-
filtered.map((req) => (
|
|
162
|
-
<tr
|
|
163
|
-
|
|
200
|
+
filtered.map((req, index) => (
|
|
201
|
+
<tr
|
|
202
|
+
key={req.id}
|
|
203
|
+
className={`hover:bg-blue-50/50 transition-colors duration-150 ${
|
|
204
|
+
index % 2 === 1 ? "bg-gray-50/50" : ""
|
|
205
|
+
}`}
|
|
206
|
+
>
|
|
207
|
+
<td className="whitespace-nowrap px-4 py-3.5 text-sm font-mono">
|
|
164
208
|
<Link
|
|
165
209
|
href={`/requirements/${req.id}`}
|
|
166
210
|
className="text-blue-600 hover:text-blue-800 hover:underline"
|
|
@@ -168,7 +212,7 @@ export function RequirementTable({ requirements }: { requirements: Requirement[]
|
|
|
168
212
|
{req.id}
|
|
169
213
|
</Link>
|
|
170
214
|
</td>
|
|
171
|
-
<td className="px-4 py-3 text-sm">
|
|
215
|
+
<td className="px-4 py-3.5 text-sm">
|
|
172
216
|
<Link
|
|
173
217
|
href={`/requirements/${req.id}`}
|
|
174
218
|
className="text-gray-900 hover:text-blue-600"
|
|
@@ -176,20 +220,20 @@ export function RequirementTable({ requirements }: { requirements: Requirement[]
|
|
|
176
220
|
{req.title}
|
|
177
221
|
</Link>
|
|
178
222
|
</td>
|
|
179
|
-
<td className="whitespace-nowrap px-4 py-3">
|
|
223
|
+
<td className="whitespace-nowrap px-4 py-3.5">
|
|
180
224
|
<StatusBadge status={req.status} />
|
|
181
225
|
</td>
|
|
182
|
-
<td className="whitespace-nowrap px-4 py-3">
|
|
226
|
+
<td className="whitespace-nowrap px-4 py-3.5">
|
|
183
227
|
<PriorityBadge priority={req.priority} />
|
|
184
228
|
</td>
|
|
185
|
-
<td className="whitespace-nowrap px-4 py-3">
|
|
229
|
+
<td className="whitespace-nowrap px-4 py-3.5">
|
|
186
230
|
{req.estimatedComplexity ? (
|
|
187
231
|
<ComplexityBadge complexity={req.estimatedComplexity} />
|
|
188
232
|
) : (
|
|
189
233
|
<span className="text-xs text-gray-400">-</span>
|
|
190
234
|
)}
|
|
191
235
|
</td>
|
|
192
|
-
<td className="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
|
|
236
|
+
<td className="whitespace-nowrap px-4 py-3.5 text-sm text-gray-500">
|
|
193
237
|
{new Date(req.updatedAt).toLocaleDateString("ja-JP")}
|
|
194
238
|
</td>
|
|
195
239
|
</tr>
|