diff --git a/app/(admin)/(activity-management)/cheat-records/[id]/_components/details.tsx b/app/(admin)/(activity-management)/cheat-records/[id]/_components/details.tsx new file mode 100644 index 0000000..3893109 --- /dev/null +++ b/app/(admin)/(activity-management)/cheat-records/[id]/_components/details.tsx @@ -0,0 +1,135 @@ +"use client"; + +import AppAvatar from "@/components/avatar"; +import { CardLayout } from "@/components/card-layout"; +import { Badge } from "@/components/ui/badge"; +import { StyledLink } from "@/components/ui/link"; +import { type FragmentType, graphql, useFragment } from "@/gql"; +import { useSuspenseQuery } from "@apollo/client/react"; + +const CHEAT_RECORD_DETAILS_QUERY = graphql(` + query CheatRecordDetails($id: ID!) { + cheatRecord(id: $id) { + ...CheatRecordDetailsCard + id + } + } +`); + +const CHEAT_RECORD_DETAILS_FRAGMENT = graphql(` + fragment CheatRecordDetailsCard on CheatRecord { + id + cheatedAt + reason + resolvedAt + resolvedReason + user { + id + avatar + email + name + } + } +`); + +export function CheatRecordDetails({ id }: { id: string }) { + const { data } = useSuspenseQuery(CHEAT_RECORD_DETAILS_QUERY, { + variables: { id }, + }); + + const fragment = data.cheatRecord; + + return ( +
+ +
+ ); +} + +function DetailsCard({ + fragment, +}: { + fragment: FragmentType; +}) { + const { + reason, + cheatedAt, + resolvedAt, + resolvedReason, + user, + } = useFragment(CHEAT_RECORD_DETAILS_FRAGMENT, fragment); + + const isResolved = !!resolvedAt; + + return ( + +
+
+

狀態

+ {isResolved + ? ( + + 已解決 + + ) + : 未解決} +
+ +
+

使用者

+
+ +
+ + {user.name} + +

{user.email}

+
+
+
+ +
+

原因

+

{reason}

+
+ +
+

作弊時間

+

+ {new Date(cheatedAt).toLocaleString("zh-TW", { + timeZone: "Asia/Taipei", + })} +

+
+ + {isResolved && ( + <> +
+

解決時間

+

+ {new Date(resolvedAt).toLocaleString("zh-TW", { + timeZone: "Asia/Taipei", + })} +

+
+ + {resolvedReason && ( +
+

解決原因

+

{resolvedReason}

+
+ )} + + )} +
+
+ ); +} diff --git a/app/(admin)/(activity-management)/cheat-records/[id]/_components/header.tsx b/app/(admin)/(activity-management)/cheat-records/[id]/_components/header.tsx new file mode 100644 index 0000000..9d8ae89 --- /dev/null +++ b/app/(admin)/(activity-management)/cheat-records/[id]/_components/header.tsx @@ -0,0 +1,59 @@ +"use client"; + +import AppAvatar from "@/components/avatar"; +import PageHeader, { PageHeaderSkeleton } from "@/components/page-header"; +import { graphql } from "@/gql"; +import { useSuspenseQuery } from "@apollo/client/react"; +import { Suspense } from "react"; + +const CHEAT_RECORD_HEADER_QUERY = graphql(` + query CheatRecordHeader($id: ID!) { + cheatRecord(id: $id) { + id + reason + user { + id + avatar + email + name + } + } + } +`); + +export function Header({ id }: { id: string }) { + return ( + }> + + + ); +} + +function HeaderMain({ id }: { id: string }) { + const { data } = useSuspenseQuery(CHEAT_RECORD_HEADER_QUERY, { + variables: { id }, + }); + + return ( +
+ + +
+ ); +} + +function HeaderSkeleton() { + return ( +
+ + +
+ ); +} diff --git a/app/(admin)/(activity-management)/cheat-records/[id]/_components/resolve-button.tsx b/app/(admin)/(activity-management)/cheat-records/[id]/_components/resolve-button.tsx new file mode 100644 index 0000000..c53e7ba --- /dev/null +++ b/app/(admin)/(activity-management)/cheat-records/[id]/_components/resolve-button.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { graphql } from "@/gql"; +import { useSuspenseQuery } from "@apollo/client/react"; +import { ResolveCheatRecordButtonTrigger } from "../../_components/resolve"; + +const CHEAT_RECORD_RESOLVE_BUTTON_QUERY = graphql(` + query CheatRecordResolveButton($id: ID!) { + cheatRecord(id: $id) { + id + resolvedAt + } + } +`); + +export function ResolveButton({ id }: { id: string }) { + const { data } = useSuspenseQuery(CHEAT_RECORD_RESOLVE_BUTTON_QUERY, { + variables: { id }, + }); + + if (data.cheatRecord.resolvedAt) { + return null; + } + + return ; +} diff --git a/app/(admin)/(activity-management)/cheat-records/[id]/page.tsx b/app/(admin)/(activity-management)/cheat-records/[id]/page.tsx new file mode 100644 index 0000000..d6a0f51 --- /dev/null +++ b/app/(admin)/(activity-management)/cheat-records/[id]/page.tsx @@ -0,0 +1,48 @@ +import { SiteHeader } from "@/components/site-header"; +import type { Metadata } from "next"; +import { Suspense } from "react"; +import { CheatRecordDetails } from "./_components/details"; +import { Header } from "./_components/header"; +import { ResolveButton } from "./_components/resolve-button"; + +export const metadata: Metadata = { + title: "作弊記錄詳情", +}; + +export default async function CheatRecordPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + + return ( + <> + +
+
+
+ +
+ + + +
+
+ + + +
+ + ); +} diff --git a/app/(admin)/(activity-management)/cheat-records/_components/create-form.tsx b/app/(admin)/(activity-management)/cheat-records/_components/create-form.tsx new file mode 100644 index 0000000..ecb2e64 --- /dev/null +++ b/app/(admin)/(activity-management)/cheat-records/_components/create-form.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/spinner"; +import { Textarea } from "@/components/ui/textarea"; +import { graphql } from "@/gql"; +import { skipToken, useQuery } from "@apollo/client/react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useDebouncedValue } from "foxact/use-debounced-value"; +import { useEffect } from "react"; +import { useForm, useWatch } from "react-hook-form"; +import { z } from "zod"; + +const createCheatRecordFormSchema = z.object({ + reason: z.string().min(1, "請輸入原因"), + userID: z.string().min(1, "請輸入使用者 ID"), +}); + +const CREATE_CHEAT_RECORD_FORM_USER_INFO_QUERY = graphql(` + query CreateCheatRecordFormUserInfo($id: ID!) { + user(id: $id) { + id + email + name + } + } +`); + +export type CreateCheatRecordFormData = z.infer< + typeof createCheatRecordFormSchema +>; + +export function CreateCheatRecordForm({ + defaultValues, + onSubmit, + onFormStateChange, +}: { + defaultValues: CreateCheatRecordFormData; + onSubmit: (data: CreateCheatRecordFormData) => void; + onFormStateChange?: (isDirty: boolean) => void; +}) { + const form = useForm({ + resolver: zodResolver(createCheatRecordFormSchema), + defaultValues, + }); + + const isDirty = form.formState.isDirty; + + useEffect(() => { + onFormStateChange?.(isDirty); + }, [isDirty, onFormStateChange]); + + const userID = useWatch({ control: form.control, name: "userID" }); + const userIDDebounced = useDebouncedValue(userID, 200); + + const { data: userInfoData, loading } = useQuery( + CREATE_CHEAT_RECORD_FORM_USER_INFO_QUERY, + userIDDebounced + ? { + variables: { + id: userIDDebounced, + }, + errorPolicy: "ignore", + } + : skipToken, + ); + + return ( +
+ + ( + + 使用者 ID + + + + +
+ 選擇要新增作弊記錄的使用者。可以到使用者管理頁面確認對應代號。 +
+ +
+ {loading ? : null} + {userInfoData?.user + ? `您正要為:${userInfoData.user.name} (${userInfoData.user.email}) 新增作弊記錄` + : userIDDebounced + ? "您輸入的使用者 ID 不存在。" + : ""} +
+
+ +
+ )} + /> + + ( + + 原因 + +