Skip to content

Commit abb1bfa

Browse files
authored
Merge pull request #29 from database-playground/pan93412/dbp-127-顯示使用者的做題和積分總數
feat(users): implement question statistics and points history
2 parents 0883829 + a20f167 commit abb1bfa

File tree

14 files changed

+339
-32
lines changed

14 files changed

+339
-32
lines changed

app/(admin)/(activity-management)/events/_components/data-table.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,15 @@ export function EventsDataTable({ query }: { query?: string }) {
1818
const variables = {
1919
first: PAGE_SIZE,
2020
after,
21-
where: query ? { typeContains: query } : undefined,
21+
where: query
22+
? {
23+
or: [
24+
{ typeContains: query },
25+
{ hasUserWith: [{ nameContains: query }] },
26+
{ hasUserWith: [{ emailContains: query }] },
27+
],
28+
}
29+
: undefined,
2230
} satisfies VariablesOf<typeof EVENTS_TABLE_QUERY>;
2331

2432
const { data } = useSuspenseQuery(EVENTS_TABLE_QUERY, {

app/(admin)/(activity-management)/events/_components/filterable-data-table.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export default function FilterableDataTable() {
1515
<div className="flex flex-col">
1616
<div className="mb-4 flex items-center gap-4">
1717
<Input
18-
placeholder="搜尋事件類型"
18+
placeholder="搜尋事件類型或使用者名稱、e-mail"
1919
value={query}
2020
onChange={(e) => setQuery(e.target.value)}
2121
/>

app/(admin)/(activity-management)/points/_components/data-table.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,15 @@ export function PointsDataTable({ query }: { query?: string }) {
6262
const variables = {
6363
first: PAGE_SIZE,
6464
after,
65-
where: query ? { descriptionContains: query } : undefined,
65+
where: query
66+
? {
67+
or: [
68+
{ descriptionContains: query },
69+
{ hasUserWith: [{ nameContains: query }] },
70+
{ hasUserWith: [{ emailContains: query }] },
71+
],
72+
}
73+
: undefined,
6674
} satisfies VariablesOf<typeof POINTS_TABLE_QUERY>;
6775

6876
const { data } = useSuspenseQuery(POINTS_TABLE_QUERY, {

app/(admin)/(activity-management)/points/_components/filterable-data-table.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export default function FilterableDataTable() {
1515
<div className="flex flex-col">
1616
<div className="mb-4 flex items-center gap-4">
1717
<Input
18-
placeholder="搜尋描述"
18+
placeholder="搜尋描述、使用者名稱或 e-mail"
1919
value={query}
2020
onChange={(e) => setQuery(e.target.value)}
2121
/>

app/(admin)/(activity-management)/submissions/_components/data-table.tsx

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,38 @@
22

33
import { CursorDataTable } from "@/components/data-table/cursor";
44
import type { Direction } from "@/components/data-table/pagination";
5+
import type { SubmissionStatus } from "@/gql/graphql";
56
import { useSuspenseQuery } from "@apollo/client/react";
7+
import type { VariablesOf } from "@graphql-typed-document-node/core";
68
import { useState } from "react";
79
import { columns, type Submission } from "./data-table-columns";
810
import { SUBMISSIONS_TABLE_QUERY } from "./query";
911

10-
export function SubmissionsDataTable() {
12+
export type SubmissionStatusFilter = SubmissionStatus | "all";
13+
14+
export function SubmissionsDataTable({
15+
query,
16+
status,
17+
}: {
18+
query?: string;
19+
status?: SubmissionStatusFilter;
20+
}) {
1121
const PAGE_SIZE = 20;
1222
const [cursors, setCursors] = useState<(string | null)[]>([null]);
1323
const [currentIndex, setCurrentIndex] = useState(0);
1424

1525
const after = cursors[currentIndex];
16-
const variables = { first: PAGE_SIZE, after };
26+
const variables = {
27+
first: PAGE_SIZE,
28+
after,
29+
where: {
30+
or: [
31+
{ hasUserWith: [{ nameContains: query }] },
32+
{ hasUserWith: [{ emailContains: query }] },
33+
],
34+
status: status === "all" ? undefined : status,
35+
},
36+
} satisfies VariablesOf<typeof SUBMISSIONS_TABLE_QUERY>;
1737

1838
const { data } = useSuspenseQuery(SUBMISSIONS_TABLE_QUERY, {
1939
variables,
@@ -45,7 +65,7 @@ export function SubmissionsDataTable() {
4565
if (!pageInfo) return;
4666
if (direction === "forward" && pageInfo.hasNextPage) {
4767
const nextCursor = pageInfo.endCursor ?? null;
48-
setCursors(prev => {
68+
setCursors((prev) => {
4969
const newCursors = prev.slice(0, currentIndex + 1);
5070
newCursors.push(nextCursor);
5171
return newCursors;
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"use client";
2+
3+
import { Input } from "@/components/ui/input";
4+
5+
import { DataTableSkeleton } from "@/components/data-table/skeleton";
6+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
7+
import { SubmissionStatus } from "@/gql/graphql";
8+
import { useDebouncedValue } from "foxact/use-debounced-value";
9+
import { Suspense, useState } from "react";
10+
import { SubmissionsDataTable, type SubmissionStatusFilter } from "./data-table";
11+
12+
export default function FilterableDataTable() {
13+
const [query, setQuery] = useState("");
14+
const [status, setStatus] = useState<SubmissionStatusFilter>("all");
15+
const debouncedQuery = useDebouncedValue(query, 200);
16+
17+
return (
18+
<div className="flex flex-col">
19+
<div className="mb-4 flex items-center gap-4">
20+
<Input
21+
placeholder="搜尋使用者名稱或 e-mail"
22+
value={query}
23+
onChange={(e) => setQuery(e.target.value)}
24+
/>
25+
<Select value={status} onValueChange={(value) => setStatus(value as SubmissionStatusFilter)}>
26+
<SelectTrigger>
27+
<SelectValue placeholder="選擇解題狀態" />
28+
</SelectTrigger>
29+
<SelectContent>
30+
<SelectItem value="all">全部</SelectItem>
31+
<SelectItem value={SubmissionStatus.Failed}>錯誤</SelectItem>
32+
<SelectItem value={SubmissionStatus.Success}>成功</SelectItem>
33+
<SelectItem value={SubmissionStatus.Pending}>執行中</SelectItem>
34+
</SelectContent>
35+
</Select>
36+
</div>
37+
38+
<Suspense fallback={<DataTableSkeleton />}>
39+
<SubmissionsDataTable query={debouncedQuery} status={status} />
40+
</Suspense>
41+
</div>
42+
);
43+
}

app/(admin)/(activity-management)/submissions/_components/query.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ export const SUBMISSIONS_TABLE_QUERY = graphql(`
66
$after: Cursor
77
$last: Int
88
$before: Cursor
9+
$where: SubmissionWhereInput
910
) {
10-
submissions(first: $first, after: $after, last: $last, before: $before, orderBy: { field: SUBMITTED_AT, direction: DESC }) {
11+
submissions(first: $first, after: $after, last: $last, before: $before, where: $where, orderBy: { field: SUBMITTED_AT, direction: DESC }) {
1112
edges {
1213
node {
1314
id
@@ -16,6 +17,7 @@ export const SUBMISSIONS_TABLE_QUERY = graphql(`
1617
user {
1718
id
1819
name
20+
email
1921
}
2022
question {
2123
id

app/(admin)/(activity-management)/submissions/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { DataTableSkeleton } from "@/components/data-table/skeleton";
22
import { SiteHeader } from "@/components/site-header";
33
import type { Metadata } from "next";
44
import { Suspense } from "react";
5-
import { SubmissionsDataTable } from "./_components/data-table";
5+
import FilterableDataTable from "./_components/filterable-data-table";
66

77
export const metadata: Metadata = {
88
title: "提交記錄",
@@ -26,7 +26,7 @@ export default function Page() {
2626
</div>
2727
<div>
2828
<Suspense fallback={<DataTableSkeleton />}>
29-
<SubmissionsDataTable />
29+
<FilterableDataTable />
3030
</Suspense>
3131
</div>
3232
</main>
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"use client";
2+
3+
import { CardLayout } from "@/components/card-layout";
4+
import { type FragmentType, graphql, useFragment } from "@/gql";
5+
import { Trophy } from "lucide-react";
6+
7+
const USER_POINTS_CARD_FRAGMENT = graphql(`
8+
fragment UserPointsCard on User {
9+
totalPoints
10+
11+
points(first: 5, orderBy: { field: GRANTED_AT, direction: DESC }) {
12+
edges {
13+
node {
14+
id
15+
...UserPointHistoryLine
16+
}
17+
}
18+
}
19+
}
20+
`);
21+
22+
const USER_POINT_HISTORY_LINE_FRAGMENT = graphql(`
23+
fragment UserPointHistoryLine on Point {
24+
points
25+
description
26+
grantedAt
27+
}
28+
`);
29+
30+
export function PointsCard({
31+
fragment,
32+
}: {
33+
fragment: FragmentType<typeof USER_POINTS_CARD_FRAGMENT>;
34+
}) {
35+
const { totalPoints, points } = useFragment(
36+
USER_POINTS_CARD_FRAGMENT,
37+
fragment,
38+
);
39+
40+
return (
41+
<CardLayout title="總積分" description="這個使用者的總積分與最近積分紀錄。">
42+
<div className="space-y-4">
43+
<div className="flex items-center gap-3">
44+
<Trophy className="h-8 w-8 text-yellow-500" />
45+
<div>
46+
<p className="text-3xl font-bold">{totalPoints}</p>
47+
<p className="text-sm text-muted-foreground">積分</p>
48+
</div>
49+
</div>
50+
51+
{points?.edges && points.edges.length > 0 && (
52+
<div className="border-t pt-4">
53+
<p className="mb-2 text-sm font-medium">最近積分紀錄</p>
54+
<div className="space-y-2">
55+
{points.edges
56+
.map((edge) => {
57+
if (!edge?.node) return null;
58+
return <PointHistoryLine key={edge.node.id} fragment={edge.node} />;
59+
})}
60+
</div>
61+
</div>
62+
)}
63+
</div>
64+
</CardLayout>
65+
);
66+
}
67+
68+
function PointHistoryLine({
69+
fragment,
70+
}: {
71+
fragment: FragmentType<typeof USER_POINT_HISTORY_LINE_FRAGMENT>;
72+
}) {
73+
const { points, description, grantedAt } = useFragment(
74+
USER_POINT_HISTORY_LINE_FRAGMENT,
75+
fragment,
76+
);
77+
78+
return (
79+
<div className={`flex items-start justify-between gap-2 text-sm`}>
80+
<div className="flex-1">
81+
<p className="font-medium">{description || "積分取得"}</p>
82+
<p className="text-xs text-muted-foreground">
83+
{new Date(grantedAt).toLocaleString("zh-TW", {
84+
timeZone: "Asia/Taipei",
85+
})}
86+
</p>
87+
</div>
88+
<Point point={points} />
89+
</div>
90+
);
91+
}
92+
93+
function Point({ point }: { point: number }) {
94+
const pointAbs = Math.abs(point);
95+
96+
if (point > 0) {
97+
return <span className="font-bold text-green-600">+{pointAbs}</span>;
98+
}
99+
100+
if (point < 0) {
101+
return <span className="font-bold text-red-600">-{pointAbs}</span>;
102+
}
103+
104+
return <span className="text-muted-foreground">{pointAbs}</span>;
105+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"use client";
2+
3+
import { CardLayout } from "@/components/card-layout";
4+
import { type FragmentType, graphql, useFragment } from "@/gql";
5+
import { DIFFICULTY_TRANSLATION } from "@/lib/translation";
6+
import { BookOpen, CheckCircle2, FileQuestion } from "lucide-react";
7+
8+
const USER_QUESTIONS_CARD_FRAGMENT = graphql(`
9+
fragment UserQuestionsCard on User {
10+
submissionStatistics {
11+
totalQuestions
12+
solvedQuestions
13+
attemptedQuestions
14+
15+
solvedQuestionByDifficulty {
16+
difficulty
17+
solvedQuestions
18+
}
19+
}
20+
}
21+
`);
22+
23+
export function QuestionsCard({ fragment }: { fragment: FragmentType<typeof USER_QUESTIONS_CARD_FRAGMENT> }) {
24+
const { submissionStatistics } = useFragment(USER_QUESTIONS_CARD_FRAGMENT, fragment);
25+
26+
if (!submissionStatistics) {
27+
return (
28+
<CardLayout title="做題統計" description="這個使用者的做題統計資訊。">
29+
<p className="text-sm text-muted-foreground">暫無資料</p>
30+
</CardLayout>
31+
);
32+
}
33+
34+
const { totalQuestions, solvedQuestions, attemptedQuestions, solvedQuestionByDifficulty } = submissionStatistics;
35+
36+
return (
37+
<CardLayout title="做題統計" description="這個使用者的做題統計資訊。">
38+
<div className="space-y-4">
39+
<div className="grid grid-cols-3 gap-4">
40+
<div className="flex flex-col gap-1">
41+
<div
42+
className={`flex items-center gap-2 text-sm text-muted-foreground`}
43+
>
44+
<FileQuestion className="h-4 w-4" />
45+
<span>總題數</span>
46+
</div>
47+
<p className="text-2xl font-bold">{totalQuestions}</p>
48+
</div>
49+
<div className="flex flex-col gap-1">
50+
<div
51+
className={`flex items-center gap-2 text-sm text-muted-foreground`}
52+
>
53+
<BookOpen className="h-4 w-4" />
54+
<span>嘗試題數</span>
55+
</div>
56+
<p className="text-2xl font-bold">{attemptedQuestions}</p>
57+
</div>
58+
<div className="flex flex-col gap-1">
59+
<div
60+
className={`flex items-center gap-2 text-sm text-muted-foreground`}
61+
>
62+
<CheckCircle2 className="h-4 w-4" />
63+
<span>完成題數</span>
64+
</div>
65+
<p className="text-2xl font-bold">{solvedQuestions}</p>
66+
</div>
67+
</div>
68+
69+
{solvedQuestionByDifficulty && solvedQuestionByDifficulty.length > 0 && (
70+
<div className="border-t pt-4">
71+
<p className="mb-2 text-sm font-medium">各難度完成題數</p>
72+
<div className="space-y-2">
73+
{solvedQuestionByDifficulty.map(({ difficulty, solvedQuestions }) => (
74+
<div
75+
key={difficulty}
76+
className={`flex items-center justify-between`}
77+
>
78+
<span className="text-sm">{DIFFICULTY_TRANSLATION[difficulty]}</span>
79+
<span className="font-medium">{solvedQuestions}</span>
80+
</div>
81+
))}
82+
</div>
83+
</div>
84+
)}
85+
</div>
86+
</CardLayout>
87+
);
88+
}

0 commit comments

Comments
 (0)