Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,15 @@ export function EventsDataTable({ query }: { query?: string }) {
const variables = {
first: PAGE_SIZE,
after,
where: query ? { typeContains: query } : undefined,
where: query
? {
or: [
{ typeContains: query },
{ hasUserWith: [{ nameContains: query }] },
{ hasUserWith: [{ emailContains: query }] },
],
}
: undefined,
} satisfies VariablesOf<typeof EVENTS_TABLE_QUERY>;

const { data } = useSuspenseQuery(EVENTS_TABLE_QUERY, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export default function FilterableDataTable() {
<div className="flex flex-col">
<div className="mb-4 flex items-center gap-4">
<Input
placeholder="搜尋事件類型"
placeholder="搜尋事件類型或使用者名稱、e-mail"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,15 @@ export function PointsDataTable({ query }: { query?: string }) {
const variables = {
first: PAGE_SIZE,
after,
where: query ? { descriptionContains: query } : undefined,
where: query
? {
or: [
{ descriptionContains: query },
{ hasUserWith: [{ nameContains: query }] },
{ hasUserWith: [{ emailContains: query }] },
],
}
: undefined,
} satisfies VariablesOf<typeof POINTS_TABLE_QUERY>;

const { data } = useSuspenseQuery(POINTS_TABLE_QUERY, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export default function FilterableDataTable() {
<div className="flex flex-col">
<div className="mb-4 flex items-center gap-4">
<Input
placeholder="搜尋描述"
placeholder="搜尋描述、使用者名稱或 e-mail"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,38 @@

import { CursorDataTable } from "@/components/data-table/cursor";
import type { Direction } from "@/components/data-table/pagination";
import type { SubmissionStatus } from "@/gql/graphql";
import { useSuspenseQuery } from "@apollo/client/react";
import type { VariablesOf } from "@graphql-typed-document-node/core";
import { useState } from "react";
import { columns, type Submission } from "./data-table-columns";
import { SUBMISSIONS_TABLE_QUERY } from "./query";

export function SubmissionsDataTable() {
export type SubmissionStatusFilter = SubmissionStatus | "all";

export function SubmissionsDataTable({
query,
status,
}: {
query?: string;
status?: SubmissionStatusFilter;
}) {
const PAGE_SIZE = 20;
const [cursors, setCursors] = useState<(string | null)[]>([null]);
const [currentIndex, setCurrentIndex] = useState(0);

const after = cursors[currentIndex];
const variables = { first: PAGE_SIZE, after };
const variables = {
first: PAGE_SIZE,
after,
where: {
or: [
{ hasUserWith: [{ nameContains: query }] },
{ hasUserWith: [{ emailContains: query }] },
],
status: status === "all" ? undefined : status,
},
} satisfies VariablesOf<typeof SUBMISSIONS_TABLE_QUERY>;

const { data } = useSuspenseQuery(SUBMISSIONS_TABLE_QUERY, {
variables,
Expand Down Expand Up @@ -45,7 +65,7 @@ export function SubmissionsDataTable() {
if (!pageInfo) return;
if (direction === "forward" && pageInfo.hasNextPage) {
const nextCursor = pageInfo.endCursor ?? null;
setCursors(prev => {
setCursors((prev) => {
const newCursors = prev.slice(0, currentIndex + 1);
newCursors.push(nextCursor);
return newCursors;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"use client";

import { Input } from "@/components/ui/input";

import { DataTableSkeleton } from "@/components/data-table/skeleton";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { SubmissionStatus } from "@/gql/graphql";
import { useDebouncedValue } from "foxact/use-debounced-value";
import { Suspense, useState } from "react";
import { SubmissionsDataTable, type SubmissionStatusFilter } from "./data-table";

export default function FilterableDataTable() {
const [query, setQuery] = useState("");
const [status, setStatus] = useState<SubmissionStatusFilter>("all");
const debouncedQuery = useDebouncedValue(query, 200);

return (
<div className="flex flex-col">
<div className="mb-4 flex items-center gap-4">
<Input
placeholder="搜尋使用者名稱或 e-mail"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<Select value={status} onValueChange={(value) => setStatus(value as SubmissionStatusFilter)}>
<SelectTrigger>
<SelectValue placeholder="選擇解題狀態" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部</SelectItem>
<SelectItem value={SubmissionStatus.Failed}>錯誤</SelectItem>
<SelectItem value={SubmissionStatus.Success}>成功</SelectItem>
<SelectItem value={SubmissionStatus.Pending}>執行中</SelectItem>
</SelectContent>
</Select>
</div>

<Suspense fallback={<DataTableSkeleton />}>
<SubmissionsDataTable query={debouncedQuery} status={status} />
</Suspense>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ export const SUBMISSIONS_TABLE_QUERY = graphql(`
$after: Cursor
$last: Int
$before: Cursor
$where: SubmissionWhereInput
) {
submissions(first: $first, after: $after, last: $last, before: $before, orderBy: { field: SUBMITTED_AT, direction: DESC }) {
submissions(first: $first, after: $after, last: $last, before: $before, where: $where, orderBy: { field: SUBMITTED_AT, direction: DESC }) {
edges {
node {
id
Expand All @@ -16,6 +17,7 @@ export const SUBMISSIONS_TABLE_QUERY = graphql(`
user {
id
name
email
}
question {
id
Expand Down
4 changes: 2 additions & 2 deletions app/(admin)/(activity-management)/submissions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { DataTableSkeleton } from "@/components/data-table/skeleton";
import { SiteHeader } from "@/components/site-header";
import type { Metadata } from "next";
import { Suspense } from "react";
import { SubmissionsDataTable } from "./_components/data-table";
import FilterableDataTable from "./_components/filterable-data-table";

export const metadata: Metadata = {
title: "提交記錄",
Expand All @@ -26,7 +26,7 @@ export default function Page() {
</div>
<div>
<Suspense fallback={<DataTableSkeleton />}>
<SubmissionsDataTable />
<FilterableDataTable />
</Suspense>
</div>
</main>
Expand Down
105 changes: 105 additions & 0 deletions app/(admin)/(user-management)/users/[id]/_components/points.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"use client";

import { CardLayout } from "@/components/card-layout";
import { type FragmentType, graphql, useFragment } from "@/gql";
import { Trophy } from "lucide-react";

const USER_POINTS_CARD_FRAGMENT = graphql(`
fragment UserPointsCard on User {
totalPoints

points(first: 5, orderBy: { field: GRANTED_AT, direction: DESC }) {
edges {
node {
id
...UserPointHistoryLine
}
}
}
}
`);

const USER_POINT_HISTORY_LINE_FRAGMENT = graphql(`
fragment UserPointHistoryLine on Point {
points
description
grantedAt
}
`);

export function PointsCard({
fragment,
}: {
fragment: FragmentType<typeof USER_POINTS_CARD_FRAGMENT>;
}) {
const { totalPoints, points } = useFragment(
USER_POINTS_CARD_FRAGMENT,
fragment,
);

return (
<CardLayout title="總積分" description="這個使用者的總積分與最近積分紀錄。">
<div className="space-y-4">
<div className="flex items-center gap-3">
<Trophy className="h-8 w-8 text-yellow-500" />
<div>
<p className="text-3xl font-bold">{totalPoints}</p>
<p className="text-sm text-muted-foreground">積分</p>
</div>
</div>

{points?.edges && points.edges.length > 0 && (
<div className="border-t pt-4">
<p className="mb-2 text-sm font-medium">最近積分紀錄</p>
<div className="space-y-2">
{points.edges
.map((edge) => {
if (!edge?.node) return null;
return <PointHistoryLine key={edge.node.id} fragment={edge.node} />;
})}
</div>
</div>
)}
</div>
</CardLayout>
);
}

function PointHistoryLine({
fragment,
}: {
fragment: FragmentType<typeof USER_POINT_HISTORY_LINE_FRAGMENT>;
}) {
const { points, description, grantedAt } = useFragment(
USER_POINT_HISTORY_LINE_FRAGMENT,
fragment,
);

return (
<div className={`flex items-start justify-between gap-2 text-sm`}>
<div className="flex-1">
<p className="font-medium">{description || "積分取得"}</p>
<p className="text-xs text-muted-foreground">
{new Date(grantedAt).toLocaleString("zh-TW", {
timeZone: "Asia/Taipei",
})}
</p>
</div>
<Point point={points} />
</div>
);
}

function Point({ point }: { point: number }) {
const pointAbs = Math.abs(point);

if (point > 0) {
return <span className="font-bold text-green-600">+{pointAbs}</span>;
}

if (point < 0) {
return <span className="font-bold text-red-600">-{pointAbs}</span>;
}

return <span className="text-muted-foreground">{pointAbs}</span>;
}
88 changes: 88 additions & 0 deletions app/(admin)/(user-management)/users/[id]/_components/questions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"use client";

import { CardLayout } from "@/components/card-layout";
import { type FragmentType, graphql, useFragment } from "@/gql";
import { DIFFICULTY_TRANSLATION } from "@/lib/translation";
import { BookOpen, CheckCircle2, FileQuestion } from "lucide-react";

const USER_QUESTIONS_CARD_FRAGMENT = graphql(`
fragment UserQuestionsCard on User {
submissionStatistics {
totalQuestions
solvedQuestions
attemptedQuestions

solvedQuestionByDifficulty {
difficulty
solvedQuestions
}
}
}
`);

export function QuestionsCard({ fragment }: { fragment: FragmentType<typeof USER_QUESTIONS_CARD_FRAGMENT> }) {
const { submissionStatistics } = useFragment(USER_QUESTIONS_CARD_FRAGMENT, fragment);

if (!submissionStatistics) {
return (
<CardLayout title="做題統計" description="這個使用者的做題統計資訊。">
<p className="text-sm text-muted-foreground">暫無資料</p>
</CardLayout>
);
}

const { totalQuestions, solvedQuestions, attemptedQuestions, solvedQuestionByDifficulty } = submissionStatistics;

return (
<CardLayout title="做題統計" description="這個使用者的做題統計資訊。">
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div className="flex flex-col gap-1">
<div
className={`flex items-center gap-2 text-sm text-muted-foreground`}
>
<FileQuestion className="h-4 w-4" />
<span>總題數</span>
</div>
<p className="text-2xl font-bold">{totalQuestions}</p>
</div>
<div className="flex flex-col gap-1">
<div
className={`flex items-center gap-2 text-sm text-muted-foreground`}
>
<BookOpen className="h-4 w-4" />
<span>嘗試題數</span>
</div>
<p className="text-2xl font-bold">{attemptedQuestions}</p>
</div>
<div className="flex flex-col gap-1">
<div
className={`flex items-center gap-2 text-sm text-muted-foreground`}
>
<CheckCircle2 className="h-4 w-4" />
<span>完成題數</span>
</div>
<p className="text-2xl font-bold">{solvedQuestions}</p>
</div>
</div>

{solvedQuestionByDifficulty && solvedQuestionByDifficulty.length > 0 && (
<div className="border-t pt-4">
<p className="mb-2 text-sm font-medium">各難度完成題數</p>
<div className="space-y-2">
{solvedQuestionByDifficulty.map(({ difficulty, solvedQuestions }) => (
<div
key={difficulty}
className={`flex items-center justify-between`}
>
<span className="text-sm">{DIFFICULTY_TRANSLATION[difficulty]}</span>
<span className="font-medium">{solvedQuestions}</span>
</div>
))}
</div>
</div>
)}
</div>
</CardLayout>
);
}
Loading