@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
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { describe, it, expect, afterEach, vi } from "vitest";
|
|
4
|
+
import { render, screen, cleanup } from "@testing-library/react";
|
|
5
|
+
import "@testing-library/jest-dom/vitest";
|
|
6
|
+
import { Nav } from "../../../components/ui/nav";
|
|
7
|
+
|
|
8
|
+
vi.mock("next/navigation", () => ({
|
|
9
|
+
usePathname: () => "/requirements",
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock("next/link", () => ({
|
|
13
|
+
default: ({
|
|
14
|
+
href,
|
|
15
|
+
children,
|
|
16
|
+
className,
|
|
17
|
+
}: {
|
|
18
|
+
href: string;
|
|
19
|
+
children: React.ReactNode;
|
|
20
|
+
className?: string;
|
|
21
|
+
}) => (
|
|
22
|
+
<a href={href} className={className}>
|
|
23
|
+
{children}
|
|
24
|
+
</a>
|
|
25
|
+
),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
describe("Nav", () => {
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
cleanup();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("renders all navigation items", () => {
|
|
34
|
+
render(<Nav />);
|
|
35
|
+
|
|
36
|
+
expect(screen.getByText("Dashboard")).toBeInTheDocument();
|
|
37
|
+
expect(screen.getByText("Requirements")).toBeInTheDocument();
|
|
38
|
+
expect(screen.getByText("Specifications")).toBeInTheDocument();
|
|
39
|
+
expect(screen.getByText("Feedback")).toBeInTheDocument();
|
|
40
|
+
expect(screen.getByText("Graph")).toBeInTheDocument();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("renders the Reqord brand link", () => {
|
|
44
|
+
render(<Nav />);
|
|
45
|
+
|
|
46
|
+
const brandLogo = screen.getByAltText("Reqord");
|
|
47
|
+
expect(brandLogo).toBeInTheDocument();
|
|
48
|
+
expect(brandLogo.closest("a")).toHaveAttribute("href", "/dashboard");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("applies active styles to the current path link", () => {
|
|
52
|
+
render(<Nav />);
|
|
53
|
+
|
|
54
|
+
const requirementsLink = screen.getByText("Requirements").closest("a");
|
|
55
|
+
expect(requirementsLink).toHaveClass("bg-warm-200");
|
|
56
|
+
expect(requirementsLink).toHaveClass("text-warm-900");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("applies inactive styles to non-active links", () => {
|
|
60
|
+
render(<Nav />);
|
|
61
|
+
|
|
62
|
+
const dashboardLink = screen.getByText("Dashboard").closest("a");
|
|
63
|
+
expect(dashboardLink).toHaveClass("text-warm-700");
|
|
64
|
+
expect(dashboardLink).not.toHaveClass("bg-warm-200");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("each nav item has correct href", () => {
|
|
68
|
+
render(<Nav />);
|
|
69
|
+
|
|
70
|
+
expect(screen.getByText("Dashboard").closest("a")).toHaveAttribute(
|
|
71
|
+
"href",
|
|
72
|
+
"/dashboard"
|
|
73
|
+
);
|
|
74
|
+
expect(screen.getByText("Requirements").closest("a")).toHaveAttribute(
|
|
75
|
+
"href",
|
|
76
|
+
"/requirements"
|
|
77
|
+
);
|
|
78
|
+
expect(screen.getByText("Specifications").closest("a")).toHaveAttribute(
|
|
79
|
+
"href",
|
|
80
|
+
"/specifications"
|
|
81
|
+
);
|
|
82
|
+
expect(screen.getByText("Feedback").closest("a")).toHaveAttribute(
|
|
83
|
+
"href",
|
|
84
|
+
"/feedback"
|
|
85
|
+
);
|
|
86
|
+
expect(screen.getByText("Graph").closest("a")).toHaveAttribute(
|
|
87
|
+
"href",
|
|
88
|
+
"/graph"
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
4
|
+
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
|
5
|
+
import "@testing-library/jest-dom/vitest";
|
|
6
|
+
import { Tabs } from "../../../components/ui/tabs";
|
|
7
|
+
|
|
8
|
+
const tabs = [
|
|
9
|
+
{ id: "design", label: "Design" },
|
|
10
|
+
{ id: "research", label: "Research" },
|
|
11
|
+
{ id: "coverage", label: "Coverage" },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
describe("Tabs - アクセシビリティ", () => {
|
|
15
|
+
afterEach(() => cleanup());
|
|
16
|
+
|
|
17
|
+
it("各タブボタンにrole='tab'が設定されている", () => {
|
|
18
|
+
render(<Tabs tabs={tabs} activeTab="design" onTabChange={vi.fn()} />);
|
|
19
|
+
|
|
20
|
+
const tabButtons = screen.getAllByRole("tab");
|
|
21
|
+
expect(tabButtons).toHaveLength(3);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("アクティブなタブのaria-selected='true'が設定される", () => {
|
|
25
|
+
render(<Tabs tabs={tabs} activeTab="design" onTabChange={vi.fn()} />);
|
|
26
|
+
|
|
27
|
+
const designTab = screen.getByRole("tab", { name: "Design" });
|
|
28
|
+
expect(designTab).toHaveAttribute("aria-selected", "true");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("非アクティブなタブのaria-selected='false'が設定される", () => {
|
|
32
|
+
render(<Tabs tabs={tabs} activeTab="design" onTabChange={vi.fn()} />);
|
|
33
|
+
|
|
34
|
+
const researchTab = screen.getByRole("tab", { name: "Research" });
|
|
35
|
+
expect(researchTab).toHaveAttribute("aria-selected", "false");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("タブクリックでonTabChangeが呼ばれる", () => {
|
|
39
|
+
const onTabChange = vi.fn();
|
|
40
|
+
render(<Tabs tabs={tabs} activeTab="design" onTabChange={onTabChange} />);
|
|
41
|
+
|
|
42
|
+
fireEvent.click(screen.getByRole("tab", { name: "Research" }));
|
|
43
|
+
expect(onTabChange).toHaveBeenCalledWith("research");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("全タブのラベルが表示される", () => {
|
|
47
|
+
render(<Tabs tabs={tabs} activeTab="design" onTabChange={vi.fn()} />);
|
|
48
|
+
|
|
49
|
+
expect(screen.getByText("Design")).toBeInTheDocument();
|
|
50
|
+
expect(screen.getByText("Research")).toBeInTheDocument();
|
|
51
|
+
expect(screen.getByText("Coverage")).toBeInTheDocument();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -107,7 +107,7 @@ describe("buildDrillDownGraphData", () => {
|
|
|
107
107
|
expect(result.edges).toHaveLength(2);
|
|
108
108
|
});
|
|
109
109
|
|
|
110
|
-
it("positions spec nodes at x=400 with vertical gap of 120", () => {
|
|
110
|
+
it("positions spec nodes at x=400 with vertical gap of 120 when no tasks", () => {
|
|
111
111
|
const req = makeReq("req-000001");
|
|
112
112
|
const spec1 = makeSpec("spec-000001", "req-000001");
|
|
113
113
|
const spec2 = makeSpec("spec-000002", "req-000001");
|
|
@@ -202,7 +202,7 @@ describe("buildDrillDownGraphData", () => {
|
|
|
202
202
|
});
|
|
203
203
|
});
|
|
204
204
|
|
|
205
|
-
it("positions issues
|
|
205
|
+
it("positions issues without overlap when spec has many tasks", () => {
|
|
206
206
|
const req = makeReq("req-000001");
|
|
207
207
|
const spec1 = makeSpec("spec-000001", "req-000001");
|
|
208
208
|
const spec2 = makeSpec("spec-000002", "req-000001");
|
|
@@ -214,9 +214,51 @@ describe("buildDrillDownGraphData", () => {
|
|
|
214
214
|
|
|
215
215
|
const issueNodes = result.nodes.filter((n) => n.type === "issue");
|
|
216
216
|
expect(issueNodes).toHaveLength(3);
|
|
217
|
+
// spec1 issues start at y=0
|
|
217
218
|
expect(issueNodes[0].position).toEqual({ x: 800, y: 0 });
|
|
218
219
|
expect(issueNodes[1].position).toEqual({ x: 800, y: 80 });
|
|
219
|
-
|
|
220
|
+
// spec2 is pushed down so its issues don't overlap with spec1's
|
|
221
|
+
// spec1 had 2 tasks → occupies 2*80=160px, so spec2 starts at max(120, 160)=160
|
|
222
|
+
expect(issueNodes[2].position).toEqual({ x: 800, y: 160 });
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("pushes spec nodes down when previous spec has many tasks", () => {
|
|
226
|
+
const req = makeReq("req-000001");
|
|
227
|
+
const spec1 = makeSpec("spec-000001", "req-000001");
|
|
228
|
+
const spec2 = makeSpec("spec-000002", "req-000001");
|
|
229
|
+
const tasks = [
|
|
230
|
+
makeTask(1, ["spec-000001"]),
|
|
231
|
+
makeTask(2, ["spec-000001"]),
|
|
232
|
+
makeTask(3, ["spec-000001"]),
|
|
233
|
+
makeTask(4, ["spec-000001"]),
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
const result = buildDrillDownGraphData(req, [spec1, spec2], tasks);
|
|
237
|
+
|
|
238
|
+
const specNodes = result.nodes.filter((n) => n.type === "specification");
|
|
239
|
+
// spec1 has 4 tasks → occupies 4*80=320px, which exceeds default gap of 120
|
|
240
|
+
// so spec2 is pushed to y=320
|
|
241
|
+
expect(specNodes[0].position).toEqual({ x: 400, y: 0 });
|
|
242
|
+
expect(specNodes[1].position).toEqual({ x: 400, y: 320 });
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("centers requirement node vertically relative to all specs", () => {
|
|
246
|
+
const req = makeReq("req-000001");
|
|
247
|
+
const spec1 = makeSpec("spec-000001", "req-000001");
|
|
248
|
+
const spec2 = makeSpec("spec-000002", "req-000001");
|
|
249
|
+
const tasks = [
|
|
250
|
+
makeTask(1, ["spec-000001"]),
|
|
251
|
+
makeTask(2, ["spec-000001"]),
|
|
252
|
+
makeTask(3, ["spec-000001"]),
|
|
253
|
+
makeTask(4, ["spec-000001"]),
|
|
254
|
+
];
|
|
255
|
+
|
|
256
|
+
const result = buildDrillDownGraphData(req, [spec1, spec2], tasks);
|
|
257
|
+
|
|
258
|
+
const reqNode = result.nodes.find((n) => n.type === "requirement");
|
|
259
|
+
const specNodes = result.nodes.filter((n) => n.type === "specification");
|
|
260
|
+
const lastSpecY = specNodes[specNodes.length - 1].position.y; // 320
|
|
261
|
+
expect(reqNode?.position.y).toBe(lastSpecY / 2); // 160
|
|
220
262
|
});
|
|
221
263
|
|
|
222
264
|
it("does not create issue nodes when no tasks are linked to spec", () => {
|
|
@@ -11,32 +11,40 @@ async function DashboardContent() {
|
|
|
11
11
|
const data = await getDashboardData();
|
|
12
12
|
|
|
13
13
|
return (
|
|
14
|
-
<div className="
|
|
14
|
+
<div className="space-y-6 py-2">
|
|
15
15
|
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
|
|
16
16
|
|
|
17
|
-
{/* Project Health */}
|
|
18
|
-
<
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
{/*
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
{/* Warnings */}
|
|
34
|
-
{data.warnings.length > 0 && <WarningAlerts warnings={data.warnings} />}
|
|
17
|
+
{/* Overview zone: Project Health + Progress */}
|
|
18
|
+
<div className="space-y-4">
|
|
19
|
+
<ProjectHealth score={data.healthScore} />
|
|
20
|
+
<ProgressSection
|
|
21
|
+
requirements={data.requirements}
|
|
22
|
+
specifications={data.specifications}
|
|
23
|
+
issues={data.issues}
|
|
24
|
+
/>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
{/* Action zone: Warnings */}
|
|
28
|
+
{data.warnings.length > 0 && (
|
|
29
|
+
<div className="mt-10">
|
|
30
|
+
<WarningAlerts warnings={data.warnings} />
|
|
31
|
+
</div>
|
|
32
|
+
)}
|
|
35
33
|
|
|
36
|
-
{/* Critical Path */}
|
|
34
|
+
{/* Action zone: Critical Path */}
|
|
37
35
|
{data.criticalPath !== null && (
|
|
38
|
-
<
|
|
36
|
+
<div className="mt-10">
|
|
37
|
+
<CriticalPathDisplay items={data.criticalPath} />
|
|
38
|
+
</div>
|
|
39
39
|
)}
|
|
40
|
+
|
|
41
|
+
{/* Detail zone: Status Cards */}
|
|
42
|
+
<div className="mt-10">
|
|
43
|
+
<StatusCards
|
|
44
|
+
requirements={data.requirements}
|
|
45
|
+
specifications={data.specifications}
|
|
46
|
+
/>
|
|
47
|
+
</div>
|
|
40
48
|
</div>
|
|
41
49
|
);
|
|
42
50
|
}
|
package/src/app/globals.css
CHANGED
|
@@ -1,2 +1,48 @@
|
|
|
1
1
|
@import "tailwindcss";
|
|
2
2
|
@plugin "@tailwindcss/typography";
|
|
3
|
+
|
|
4
|
+
@theme {
|
|
5
|
+
/* ロゴ由来のアクセント — ポイント使い専用 */
|
|
6
|
+
--color-accent: #ff3b2f;
|
|
7
|
+
--color-accent-hover: #e5342a;
|
|
8
|
+
|
|
9
|
+
/* 暖色ニュートラル — ロゴの #f5f2ed/#1a1a1a ベース */
|
|
10
|
+
--color-warm-50: #FAF8F5;
|
|
11
|
+
--color-warm-100: #F5F2ED;
|
|
12
|
+
--color-warm-200: #E8E4DD;
|
|
13
|
+
--color-warm-300: #D4CFC7;
|
|
14
|
+
--color-warm-700: #4A3F35;
|
|
15
|
+
--color-warm-800: #33291F;
|
|
16
|
+
--color-warm-900: #1A1A1A;
|
|
17
|
+
|
|
18
|
+
/* brand = warm(UIの主色調) */
|
|
19
|
+
--color-brand-50: #FAF8F5;
|
|
20
|
+
--color-brand-100: #F5F2ED;
|
|
21
|
+
--color-brand-200: #E8E4DD;
|
|
22
|
+
--color-brand-500: #8C7E6F;
|
|
23
|
+
--color-brand-600: #6B5D4F;
|
|
24
|
+
--color-brand-700: #4A3F35;
|
|
25
|
+
--color-brand-800: #33291F;
|
|
26
|
+
--color-brand-900: #1A1A1A;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* ページ遷移アニメーション */
|
|
30
|
+
main > * {
|
|
31
|
+
animation: fadeIn 0.2s ease-out;
|
|
32
|
+
}
|
|
33
|
+
@keyframes fadeIn {
|
|
34
|
+
from { opacity: 0; transform: translateY(8px); }
|
|
35
|
+
to { opacity: 1; transform: translateY(0); }
|
|
36
|
+
}
|
|
37
|
+
@media (prefers-reduced-motion: reduce) {
|
|
38
|
+
main > * {
|
|
39
|
+
animation: none;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* グローバルフォーカスインジケータ */
|
|
44
|
+
:focus-visible {
|
|
45
|
+
outline: 2px solid var(--color-brand-500);
|
|
46
|
+
outline-offset: 2px;
|
|
47
|
+
border-radius: 2px;
|
|
48
|
+
}
|
package/src/app/layout.tsx
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { Metadata } from "next";
|
|
2
|
+
import { Inter } from "next/font/google";
|
|
2
3
|
import { Nav } from "@/components/ui/nav";
|
|
3
4
|
import "./globals.css";
|
|
4
5
|
|
|
6
|
+
const inter = Inter({ subsets: ["latin"], display: "swap" });
|
|
7
|
+
|
|
5
8
|
export const metadata: Metadata = {
|
|
6
9
|
title: "Reqord",
|
|
7
10
|
description: "Requirements management UI",
|
|
@@ -14,7 +17,7 @@ export default function RootLayout({
|
|
|
14
17
|
}) {
|
|
15
18
|
return (
|
|
16
19
|
<html lang="ja">
|
|
17
|
-
<body className=
|
|
20
|
+
<body className={`${inter.className} bg-warm-50 text-warm-900 antialiased`}>
|
|
18
21
|
<Nav />
|
|
19
22
|
<main className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
|
20
23
|
{children}
|
|
@@ -2,11 +2,36 @@ export default function Loading() {
|
|
|
2
2
|
return (
|
|
3
3
|
<div className="animate-pulse space-y-4">
|
|
4
4
|
<div className="h-8 w-48 rounded bg-gray-200" />
|
|
5
|
-
|
|
6
|
-
<div className="
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
{/* Filter bar skeleton */}
|
|
6
|
+
<div className="flex gap-3">
|
|
7
|
+
<div className="h-10 w-64 rounded-md bg-gray-200" />
|
|
8
|
+
<div className="h-10 w-32 rounded-md bg-gray-200" />
|
|
9
|
+
<div className="h-10 w-32 rounded-md bg-gray-200" />
|
|
10
|
+
</div>
|
|
11
|
+
{/* Table skeleton */}
|
|
12
|
+
<div className="overflow-hidden rounded-lg border border-gray-200">
|
|
13
|
+
{/* Table header skeleton */}
|
|
14
|
+
<div className="flex gap-4 border-b-2 border-gray-200 bg-gray-50 px-4 py-3">
|
|
15
|
+
<div className="h-4 w-16 rounded bg-gray-300" />
|
|
16
|
+
<div className="h-4 w-48 rounded bg-gray-300" />
|
|
17
|
+
<div className="h-4 w-20 rounded bg-gray-300" />
|
|
18
|
+
<div className="h-4 w-20 rounded bg-gray-300" />
|
|
19
|
+
<div className="h-4 w-20 rounded bg-gray-300" />
|
|
20
|
+
<div className="h-4 w-24 rounded bg-gray-300" />
|
|
21
|
+
</div>
|
|
22
|
+
{/* Table rows skeleton */}
|
|
23
|
+
<div className="divide-y divide-gray-200">
|
|
24
|
+
{Array.from({ length: 8 }, (_, i) => (
|
|
25
|
+
<div key={i} className="flex gap-4 px-4 py-3.5">
|
|
26
|
+
<div className="h-4 w-24 rounded bg-gray-200" />
|
|
27
|
+
<div className="h-4 w-64 rounded bg-gray-200" />
|
|
28
|
+
<div className="h-5 w-20 rounded-full bg-gray-200" />
|
|
29
|
+
<div className="h-5 w-16 rounded-full bg-gray-200" />
|
|
30
|
+
<div className="h-5 w-8 rounded-full bg-gray-200" />
|
|
31
|
+
<div className="h-4 w-20 rounded bg-gray-200" />
|
|
32
|
+
</div>
|
|
33
|
+
))}
|
|
34
|
+
</div>
|
|
10
35
|
</div>
|
|
11
36
|
</div>
|
|
12
37
|
);
|
|
@@ -2,11 +2,35 @@ export default function Loading() {
|
|
|
2
2
|
return (
|
|
3
3
|
<div className="animate-pulse space-y-4">
|
|
4
4
|
<div className="h-8 w-48 rounded bg-gray-200" />
|
|
5
|
-
|
|
6
|
-
<div className="
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
{/* Filter bar skeleton */}
|
|
6
|
+
<div className="flex gap-3">
|
|
7
|
+
<div className="h-10 w-64 rounded-md bg-gray-200" />
|
|
8
|
+
<div className="h-10 w-32 rounded-md bg-gray-200" />
|
|
9
|
+
</div>
|
|
10
|
+
{/* Table skeleton */}
|
|
11
|
+
<div className="overflow-hidden rounded-lg border border-gray-200">
|
|
12
|
+
{/* Table header skeleton */}
|
|
13
|
+
<div className="flex gap-4 border-b-2 border-gray-200 bg-gray-50 px-4 py-3">
|
|
14
|
+
<div className="h-4 w-16 rounded bg-gray-300" />
|
|
15
|
+
<div className="h-4 w-48 rounded bg-gray-300" />
|
|
16
|
+
<div className="h-4 w-32 rounded bg-gray-300" />
|
|
17
|
+
<div className="h-4 w-20 rounded bg-gray-300" />
|
|
18
|
+
<div className="h-4 w-16 rounded bg-gray-300" />
|
|
19
|
+
<div className="h-4 w-24 rounded bg-gray-300" />
|
|
20
|
+
</div>
|
|
21
|
+
{/* Table rows skeleton */}
|
|
22
|
+
<div className="divide-y divide-gray-200">
|
|
23
|
+
{Array.from({ length: 8 }, (_, i) => (
|
|
24
|
+
<div key={i} className="flex gap-4 px-4 py-3.5">
|
|
25
|
+
<div className="h-4 w-24 rounded bg-gray-200" />
|
|
26
|
+
<div className="h-4 w-64 rounded bg-gray-200" />
|
|
27
|
+
<div className="h-4 w-32 rounded bg-gray-200" />
|
|
28
|
+
<div className="h-5 w-20 rounded-full bg-gray-200" />
|
|
29
|
+
<div className="h-4 w-12 rounded bg-gray-200" />
|
|
30
|
+
<div className="h-4 w-20 rounded bg-gray-200" />
|
|
31
|
+
</div>
|
|
32
|
+
))}
|
|
33
|
+
</div>
|
|
10
34
|
</div>
|
|
11
35
|
</div>
|
|
12
36
|
);
|
|
@@ -9,6 +9,13 @@ type CriticalPathDisplayProps = {
|
|
|
9
9
|
|
|
10
10
|
const INITIAL_DISPLAY_COUNT = 10;
|
|
11
11
|
|
|
12
|
+
const PRIORITY_BADGE_CLASSES: Record<string, string> = {
|
|
13
|
+
P0: "bg-red-100 text-red-800",
|
|
14
|
+
P1: "bg-red-100 text-red-800",
|
|
15
|
+
P2: "bg-orange-100 text-orange-800",
|
|
16
|
+
P3: "bg-gray-100 text-gray-800",
|
|
17
|
+
};
|
|
18
|
+
|
|
12
19
|
export function CriticalPathDisplay({ items }: CriticalPathDisplayProps) {
|
|
13
20
|
const openItems = items.filter((item) => item.status !== "closed");
|
|
14
21
|
const closedItems = items.filter((item) => item.status === "closed");
|
|
@@ -23,10 +30,12 @@ export function CriticalPathDisplay({ items }: CriticalPathDisplayProps) {
|
|
|
23
30
|
</span>
|
|
24
31
|
</h2>
|
|
25
32
|
{/* Show only open items when they exist; closed items are accessible via the expand button below */}
|
|
26
|
-
<div className="space-y-
|
|
33
|
+
<div className="space-y-2">
|
|
27
34
|
{(openItems.length > 0
|
|
28
35
|
? openItems
|
|
29
|
-
:
|
|
36
|
+
: showClosed
|
|
37
|
+
? closedItems
|
|
38
|
+
: closedItems.slice(0, INITIAL_DISPLAY_COUNT)
|
|
30
39
|
).map((item) => {
|
|
31
40
|
const isCompleted = item.status === "closed";
|
|
32
41
|
const isPending =
|
|
@@ -39,24 +48,30 @@ export function CriticalPathDisplay({ items }: CriticalPathDisplayProps) {
|
|
|
39
48
|
textClass = "font-bold";
|
|
40
49
|
}
|
|
41
50
|
|
|
51
|
+
const badgeClass =
|
|
52
|
+
PRIORITY_BADGE_CLASSES[item.priority] ?? "bg-gray-100 text-gray-800";
|
|
53
|
+
|
|
42
54
|
return (
|
|
43
55
|
<div
|
|
44
56
|
key={item.issueNumber}
|
|
45
57
|
data-testid="critical-path-item"
|
|
46
|
-
className={`flex items-
|
|
58
|
+
className={`flex items-center gap-3 rounded-md border border-gray-200 px-3 py-2 ${textClass}`}
|
|
47
59
|
>
|
|
48
|
-
<
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
+
<a
|
|
61
|
+
href={item.url}
|
|
62
|
+
target="_blank"
|
|
63
|
+
rel="noopener noreferrer"
|
|
64
|
+
className="text-sm font-medium text-blue-600 hover:underline"
|
|
65
|
+
>
|
|
66
|
+
#{item.issueNumber}
|
|
67
|
+
</a>
|
|
68
|
+
<span
|
|
69
|
+
className={`rounded-full px-2 py-0.5 text-xs font-medium ${badgeClass}`}
|
|
70
|
+
>
|
|
71
|
+
{item.priority}
|
|
72
|
+
</span>
|
|
73
|
+
<span className="flex-1 text-sm text-gray-700">{item.title}</span>
|
|
74
|
+
<span className="text-xs text-gray-400">{item.status}</span>
|
|
60
75
|
</div>
|
|
61
76
|
);
|
|
62
77
|
})}
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
1
|
import React from "react";
|
|
4
2
|
|
|
5
3
|
type ProgressBarProps = {
|
|
@@ -33,10 +31,10 @@ export function ProgressBar({
|
|
|
33
31
|
{current}/{total} ({percentage}%)
|
|
34
32
|
</span>
|
|
35
33
|
</div>
|
|
36
|
-
<div className="h-
|
|
34
|
+
<div className="h-3 overflow-hidden rounded-full bg-gray-100">
|
|
37
35
|
<div
|
|
38
36
|
data-testid="progress-bar-fill"
|
|
39
|
-
className={`h-full ${bgClass} transition-all duration-
|
|
37
|
+
className={`h-full rounded-full ${bgClass} transition-all duration-500 ease-out`}
|
|
40
38
|
style={{ width: `${percentage}%` }}
|
|
41
39
|
/>
|
|
42
40
|
</div>
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
1
|
import React from "react";
|
|
4
2
|
|
|
5
3
|
type ProjectHealthProps = {
|
|
@@ -9,26 +7,27 @@ type ProjectHealthProps = {
|
|
|
9
7
|
export function ProjectHealth({ score }: ProjectHealthProps) {
|
|
10
8
|
const roundedScore = Math.round(score);
|
|
11
9
|
|
|
12
|
-
let colorClass = "text-red-
|
|
10
|
+
let colorClass = "text-red-600";
|
|
13
11
|
if (roundedScore >= 80) {
|
|
14
|
-
colorClass = "text-
|
|
12
|
+
colorClass = "text-emerald-600";
|
|
15
13
|
} else if (roundedScore >= 50) {
|
|
16
|
-
colorClass = "text-yellow-
|
|
14
|
+
colorClass = "text-yellow-600";
|
|
17
15
|
}
|
|
18
16
|
|
|
19
17
|
return (
|
|
20
|
-
<div className="rounded-
|
|
21
|
-
<
|
|
18
|
+
<div className="relative overflow-hidden rounded-2xl border border-warm-200 bg-warm-100 p-8 shadow-sm">
|
|
19
|
+
<div className="absolute left-0 top-0 h-full w-1.5 bg-accent" />
|
|
20
|
+
<h2 className="text-sm font-medium uppercase tracking-wider text-warm-700">
|
|
22
21
|
Project Health
|
|
23
22
|
</h2>
|
|
24
|
-
<div className="flex items-baseline justify-center">
|
|
23
|
+
<div className="mt-4 flex items-baseline justify-center">
|
|
25
24
|
<span
|
|
26
25
|
data-testid="health-score"
|
|
27
|
-
className={`text-
|
|
26
|
+
className={`text-7xl font-extrabold tabular-nums ${colorClass}`}
|
|
28
27
|
>
|
|
29
28
|
{roundedScore}
|
|
30
29
|
</span>
|
|
31
|
-
<span className="ml-2 text-2xl text-
|
|
30
|
+
<span className="ml-2 text-2xl text-warm-500">/ 100</span>
|
|
32
31
|
</div>
|
|
33
32
|
</div>
|
|
34
33
|
);
|
|
@@ -7,20 +7,31 @@ type StatusCardProps = {
|
|
|
7
7
|
breakdown: StatusBreakdown;
|
|
8
8
|
};
|
|
9
9
|
|
|
10
|
+
const STATUS_BADGE_CLASSES: Record<string, string> = {
|
|
11
|
+
draft: "bg-gray-100 text-gray-600",
|
|
12
|
+
approved: "bg-blue-50 text-blue-700",
|
|
13
|
+
implemented: "bg-emerald-50 text-emerald-700",
|
|
14
|
+
deprecated: "bg-red-50 text-red-700",
|
|
15
|
+
};
|
|
16
|
+
|
|
10
17
|
export function StatusCard({ title, total, breakdown }: StatusCardProps) {
|
|
11
18
|
return (
|
|
12
|
-
<div className="rounded-
|
|
19
|
+
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm hover:shadow-md transition-shadow">
|
|
13
20
|
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
|
14
21
|
<p className="mt-2 text-3xl font-bold text-gray-900">{total}</p>
|
|
15
22
|
<div className="mt-4 flex flex-wrap gap-2">
|
|
16
|
-
{Object.entries(breakdown).map(([status, count]) =>
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
{Object.entries(breakdown).map(([status, count]) => {
|
|
24
|
+
const badgeClass =
|
|
25
|
+
STATUS_BADGE_CLASSES[status] ?? "bg-gray-100 text-gray-600";
|
|
26
|
+
return (
|
|
27
|
+
<span
|
|
28
|
+
key={status}
|
|
29
|
+
className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-medium ${badgeClass}`}
|
|
30
|
+
>
|
|
31
|
+
{status}: {count}
|
|
32
|
+
</span>
|
|
33
|
+
);
|
|
34
|
+
})}
|
|
24
35
|
</div>
|
|
25
36
|
</div>
|
|
26
37
|
);
|