diff --git a/app/(admin)/(activity-management)/events/[id]/_components/event-details-card.tsx b/app/(admin)/(activity-management)/events/[id]/_components/event-details-card.tsx
new file mode 100644
index 0000000..a1df8bf
--- /dev/null
+++ b/app/(admin)/(activity-management)/events/[id]/_components/event-details-card.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { useSuspenseQuery } from "@apollo/client/react";
+import { EVENT_BY_ID_QUERY } from "./query";
+
+interface EventDetailsCardProps {
+ id: string;
+}
+
+export function EventDetailsCard({ id }: EventDetailsCardProps) {
+ const { data } = useSuspenseQuery(EVENT_BY_ID_QUERY, {
+ variables: { id },
+ });
+
+ const event = data?.event;
+
+ if (!event) {
+ return (
+
+
+ 事件詳情
+ 查看事件的詳細資訊和負載資料
+
+
+ 找不到事件記錄
+
+
+ );
+ }
+
+ let payloadData = null;
+ try {
+ payloadData = event.payload ? JSON.parse(event.payload) : null;
+ } catch {
+ // If payload is not valid JSON, treat as string
+ payloadData = event.payload;
+ }
+
+ return (
+
+
+ 事件詳情
+ 查看事件的詳細資訊和負載資料
+
+
+
+
+
+
觸發時間
+
+ {new Date(event.triggeredAt).toLocaleString("zh-tw")}
+
+
+
+ {payloadData && (
+
+
負載資料
+
+
+ {typeof payloadData === "string"
+ ? payloadData
+ : JSON.stringify(payloadData, null, 2)}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/app/(admin)/(activity-management)/events/[id]/_components/header.tsx b/app/(admin)/(activity-management)/events/[id]/_components/header.tsx
new file mode 100644
index 0000000..f77fee6
--- /dev/null
+++ b/app/(admin)/(activity-management)/events/[id]/_components/header.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import { Badge } from "@/components/ui/badge";
+import { useSuspenseQuery } from "@apollo/client/react";
+import { EVENT_BY_ID_QUERY } from "./query";
+
+interface HeaderProps {
+ id: string;
+}
+
+export function Header({ id }: HeaderProps) {
+ const { data } = useSuspenseQuery(EVENT_BY_ID_QUERY, {
+ variables: { id },
+ });
+
+ const event = data.event;
+
+ return (
+
+
+ 事件 #{event.id}
+
+
+ {event.type}
+
+ 觸發時間:{new Date(event.triggeredAt).toLocaleString("zh-tw")}
+
+
+
+ );
+}
diff --git a/app/(admin)/(activity-management)/events/[id]/_components/query.ts b/app/(admin)/(activity-management)/events/[id]/_components/query.ts
new file mode 100644
index 0000000..32abdf4
--- /dev/null
+++ b/app/(admin)/(activity-management)/events/[id]/_components/query.ts
@@ -0,0 +1,16 @@
+import { graphql } from "@/gql";
+
+export const EVENT_BY_ID_QUERY = graphql(`
+ query EventById($id: ID!) {
+ event(id: $id) {
+ id
+ user {
+ id
+ name
+ }
+ type
+ payload
+ triggeredAt
+ }
+ }
+`);
diff --git a/app/(admin)/(activity-management)/events/[id]/_components/user-card.tsx b/app/(admin)/(activity-management)/events/[id]/_components/user-card.tsx
new file mode 100644
index 0000000..fe1fe2b
--- /dev/null
+++ b/app/(admin)/(activity-management)/events/[id]/_components/user-card.tsx
@@ -0,0 +1,37 @@
+"use client";
+
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { StyledLink } from "@/components/ui/link";
+import { useSuspenseQuery } from "@apollo/client/react";
+import { EVENT_BY_ID_QUERY } from "./query";
+
+interface UserCardProps {
+ id: string;
+}
+
+export function UserCard({ id }: UserCardProps) {
+ const { data } = useSuspenseQuery(EVENT_BY_ID_QUERY, {
+ variables: { id },
+ });
+
+ const event = data.event;
+
+ return (
+
+
+ 使用者資訊
+ 查看觸發此事件的使用者
+
+
+
+ {event.user.name} (#{event.user.id})
+
+
+
+ 檢視使用者資訊 →
+
+
+
+
+ );
+}
diff --git a/app/(admin)/(activity-management)/events/[id]/page.tsx b/app/(admin)/(activity-management)/events/[id]/page.tsx
new file mode 100644
index 0000000..717323c
--- /dev/null
+++ b/app/(admin)/(activity-management)/events/[id]/page.tsx
@@ -0,0 +1,40 @@
+import { SiteHeader } from "@/components/site-header";
+import { Suspense } from "react";
+import { EventDetailsCard } from "./_components/event-details-card";
+import { Header } from "./_components/header";
+import { UserCard } from "./_components/user-card";
+
+export default async function EventPage({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const { id } = await params;
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/app/(admin)/(activity-management)/events/_components/data-table-columns.tsx b/app/(admin)/(activity-management)/events/_components/data-table-columns.tsx
new file mode 100644
index 0000000..fc99058
--- /dev/null
+++ b/app/(admin)/(activity-management)/events/_components/data-table-columns.tsx
@@ -0,0 +1,91 @@
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { StyledLink } from "@/components/ui/link";
+import type { ColumnDef } from "@tanstack/react-table";
+import { MoreHorizontal } from "lucide-react";
+import Link from "next/link";
+
+export interface Event {
+ id: string;
+ user: { id: string; name: string };
+ type: string;
+ triggeredAt: string;
+}
+
+export const columns: ColumnDef[] = [
+ {
+ accessorKey: "id",
+ header: "事件 ID",
+ cell: ({ row }) => {
+ const event = row.original;
+ return (
+
+ {event.id}
+
+ );
+ },
+ },
+ {
+ accessorKey: "user.id",
+ header: "使用者",
+ cell: ({ row }) => {
+ const userId = row.original.user.id;
+ const userName = row.original.user.name;
+
+ return (
+
+ {userName} (#{userId})
+
+ );
+ },
+ },
+ {
+ accessorKey: "type",
+ header: "事件類型",
+ cell: ({ row }) => {
+ const type = row.original.type;
+ return {type};
+ },
+ },
+ {
+ accessorKey: "triggeredAt",
+ header: "觸發時間",
+ cell: ({ row }) => {
+ const triggeredAt = new Date(row.original.triggeredAt);
+ return {triggeredAt.toLocaleString("zh-tw")}
;
+ },
+ },
+ {
+ id: "actions",
+ cell: ({ row }) => {
+ return (
+
+
+
+
+
+ 動作
+
+ 檢視事件詳情
+
+
+
+ 檢視使用者
+
+
+
+ );
+ },
+ },
+];
diff --git a/app/(admin)/(activity-management)/events/_components/data-table.tsx b/app/(admin)/(activity-management)/events/_components/data-table.tsx
new file mode 100644
index 0000000..4bdfece
--- /dev/null
+++ b/app/(admin)/(activity-management)/events/_components/data-table.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+import { CursorDataTable } from "@/components/data-table/cursor";
+import type { Direction } from "@/components/data-table/pagination";
+import { useSuspenseQuery } from "@apollo/client/react";
+import { useState } from "react";
+import { columns, type Event } from "./data-table-columns";
+import { EVENTS_TABLE_QUERY } from "./query";
+
+export function EventsDataTable() {
+ const PAGE_SIZE = 10;
+ const [after, setAfter] = useState(null);
+ const [before, setBefore] = useState(null);
+ const [direction, setDirection] = useState("backward");
+
+ const variables = direction === "backward"
+ ? { first: PAGE_SIZE, after, last: undefined, before: undefined }
+ : { last: PAGE_SIZE, before, first: undefined, after: undefined };
+
+ const { data } = useSuspenseQuery(EVENTS_TABLE_QUERY, {
+ variables,
+ });
+
+ const eventList = data?.events.edges
+ ?.map((edge) => {
+ const event = edge?.node;
+ if (!event) return null;
+ return {
+ id: event.id,
+ user: {
+ id: event.user.id,
+ name: event.user.name,
+ },
+ type: event.type,
+ triggeredAt: event.triggeredAt,
+ } satisfies Event;
+ })
+ .filter((event) => event !== null) ?? [];
+
+ const pageInfo = data?.events.pageInfo;
+
+ const handlePageChange = (direction: Direction) => {
+ if (!pageInfo) return;
+ if (direction === "forward" && pageInfo.hasNextPage) {
+ setAfter(pageInfo.endCursor ?? null);
+ setBefore(null);
+ setDirection("forward");
+ } else if (direction === "backward" && pageInfo.hasPreviousPage) {
+ setBefore(pageInfo.startCursor ?? null);
+ setAfter(null);
+ setDirection("backward");
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/app/(admin)/(activity-management)/events/_components/query.ts b/app/(admin)/(activity-management)/events/_components/query.ts
new file mode 100644
index 0000000..8c0db5f
--- /dev/null
+++ b/app/(admin)/(activity-management)/events/_components/query.ts
@@ -0,0 +1,31 @@
+import { graphql } from "@/gql";
+
+export const EVENTS_TABLE_QUERY = graphql(`
+ query EventsTable(
+ $first: Int
+ $after: Cursor
+ $last: Int
+ $before: Cursor
+ ) {
+ events(first: $first, after: $after, last: $last, before: $before, orderBy: { field: TRIGGERED_AT, direction: DESC }) {
+ edges {
+ node {
+ id
+ user {
+ id
+ name
+ }
+ type
+ triggeredAt
+ }
+ }
+ totalCount
+ pageInfo {
+ hasNextPage
+ hasPreviousPage
+ endCursor
+ startCursor
+ }
+ }
+ }
+`);
diff --git a/app/(admin)/(activity-management)/events/page.tsx b/app/(admin)/(activity-management)/events/page.tsx
new file mode 100644
index 0000000..ae36676
--- /dev/null
+++ b/app/(admin)/(activity-management)/events/page.tsx
@@ -0,0 +1,26 @@
+import { SiteHeader } from "@/components/site-header";
+import { EventsDataTable } from "./_components/data-table";
+
+export default function Page() {
+ return (
+ <>
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/app/(admin)/(activity-management)/points/[id]/_components/header.tsx b/app/(admin)/(activity-management)/points/[id]/_components/header.tsx
new file mode 100644
index 0000000..0213362
--- /dev/null
+++ b/app/(admin)/(activity-management)/points/[id]/_components/header.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import { Badge } from "@/components/ui/badge";
+import { useSuspenseQuery } from "@apollo/client/react";
+import { POINT_BY_ID_QUERY } from "./query";
+
+interface HeaderProps {
+ id: string;
+}
+
+export function Header({ id }: HeaderProps) {
+ const { data } = useSuspenseQuery(POINT_BY_ID_QUERY, {
+ variables: { id },
+ });
+
+ const point = data.pointGrant;
+
+ const isPositive = point.points >= 0;
+
+ return (
+
+
+ 積分記錄 #{point.id}
+
+
+
+ {isPositive ? "+" : ""}
+ {point.points} 積分
+
+
+ 獲得時間:{new Date(point.grantedAt).toLocaleString("zh-tw")}
+
+
+
+ );
+}
diff --git a/app/(admin)/(activity-management)/points/[id]/_components/point-details-card.tsx b/app/(admin)/(activity-management)/points/[id]/_components/point-details-card.tsx
new file mode 100644
index 0000000..088b6c8
--- /dev/null
+++ b/app/(admin)/(activity-management)/points/[id]/_components/point-details-card.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { useSuspenseQuery } from "@apollo/client/react";
+import { POINT_BY_ID_QUERY } from "./query";
+
+interface PointDetailsCardProps {
+ id: string;
+}
+
+export function PointDetailsCard({ id }: PointDetailsCardProps) {
+ const { data } = useSuspenseQuery(POINT_BY_ID_QUERY, {
+ variables: { id },
+ });
+
+ const point = data.pointGrant;
+
+ const isPositive = point.points >= 0;
+
+ return (
+
+
+ 積分詳情
+ 查看積分獲得的詳細資訊
+
+
+
+
積分數量
+
+ {isPositive ? "+" : ""}
+ {point.points}
+
+
+
+
+
描述
+
{point.description}
+
+
+
+
獲得時間
+
+ {new Date(point.grantedAt).toLocaleString("zh-tw")}
+
+
+
+
+ );
+}
diff --git a/app/(admin)/(activity-management)/points/[id]/_components/query.ts b/app/(admin)/(activity-management)/points/[id]/_components/query.ts
new file mode 100644
index 0000000..04c539b
--- /dev/null
+++ b/app/(admin)/(activity-management)/points/[id]/_components/query.ts
@@ -0,0 +1,16 @@
+import { graphql } from "@/gql";
+
+export const POINT_BY_ID_QUERY = graphql(`
+ query PointById($id: ID!) {
+ pointGrant(id: $id) {
+ id
+ user {
+ id
+ name
+ }
+ points
+ description
+ grantedAt
+ }
+ }
+`);
diff --git a/app/(admin)/(activity-management)/points/[id]/_components/user-card.tsx b/app/(admin)/(activity-management)/points/[id]/_components/user-card.tsx
new file mode 100644
index 0000000..12828ef
--- /dev/null
+++ b/app/(admin)/(activity-management)/points/[id]/_components/user-card.tsx
@@ -0,0 +1,37 @@
+"use client";
+
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { StyledLink } from "@/components/ui/link";
+import { useSuspenseQuery } from "@apollo/client/react";
+import { POINT_BY_ID_QUERY } from "./query";
+
+interface UserCardProps {
+ id: string;
+}
+
+export function UserCard({ id }: UserCardProps) {
+ const { data } = useSuspenseQuery(POINT_BY_ID_QUERY, {
+ variables: { id },
+ });
+
+ const point = data.pointGrant;
+
+ return (
+
+
+ 使用者資訊
+ 查看獲得此積分的使用者
+
+
+
+ {point.user.name} (#{point.user.id})
+
+
+
+ 檢視使用者資訊 →
+
+
+
+
+ );
+}
diff --git a/app/(admin)/(activity-management)/points/[id]/page.tsx b/app/(admin)/(activity-management)/points/[id]/page.tsx
new file mode 100644
index 0000000..fd85db1
--- /dev/null
+++ b/app/(admin)/(activity-management)/points/[id]/page.tsx
@@ -0,0 +1,40 @@
+import { SiteHeader } from "@/components/site-header";
+import { Suspense } from "react";
+import { Header } from "./_components/header";
+import { PointDetailsCard } from "./_components/point-details-card";
+import { UserCard } from "./_components/user-card";
+
+export default async function PointPage({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const { id } = await params;
+
+ return (
+ <>
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/app/(admin)/(activity-management)/points/_components/data-table-columns.tsx b/app/(admin)/(activity-management)/points/_components/data-table-columns.tsx
new file mode 100644
index 0000000..3b464f4
--- /dev/null
+++ b/app/(admin)/(activity-management)/points/_components/data-table-columns.tsx
@@ -0,0 +1,111 @@
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { StyledLink } from "@/components/ui/link";
+import type { ColumnDef } from "@tanstack/react-table";
+import { MoreHorizontal } from "lucide-react";
+import Link from "next/link";
+
+export interface Point {
+ id: string;
+ user: { id: string; name: string };
+ points: number;
+ description: string;
+ grantedAt: string;
+}
+
+export const columns: ColumnDef[] = [
+ {
+ accessorKey: "id",
+ header: "記錄 ID",
+ cell: ({ row }) => {
+ const point = row.original;
+ return (
+
+ {point.id}
+
+ );
+ },
+ },
+ {
+ accessorKey: "user.id",
+ header: "使用者",
+ cell: ({ row }) => {
+ const userId = row.original.user.id;
+ const userName = row.original.user.name;
+ return (
+
+ {userName} (#{userId})
+
+ );
+ },
+ },
+ {
+ accessorKey: "points",
+ header: "積分",
+ cell: ({ row }) => {
+ const points = row.original.points;
+ const isPositive = points >= 0;
+ return (
+
+ {isPositive ? "+" : ""}
+ {points}
+
+ );
+ },
+ },
+ {
+ accessorKey: "description",
+ header: "描述",
+ cell: ({ row }) => {
+ const description = row.original.description;
+ return (
+
+ );
+ },
+ },
+ {
+ accessorKey: "grantedAt",
+ header: "獲得時間",
+ cell: ({ row }) => {
+ const grantedAt = new Date(row.original.grantedAt);
+ return {grantedAt.toLocaleString("zh-tw")}
;
+ },
+ },
+ {
+ id: "actions",
+ cell: ({ row }) => {
+ return (
+
+
+
+
+
+ 動作
+
+ 檢視積分記錄
+
+
+
+ 檢視使用者
+
+
+
+ );
+ },
+ },
+];
diff --git a/app/(admin)/(activity-management)/points/_components/data-table.tsx b/app/(admin)/(activity-management)/points/_components/data-table.tsx
new file mode 100644
index 0000000..5df8679
--- /dev/null
+++ b/app/(admin)/(activity-management)/points/_components/data-table.tsx
@@ -0,0 +1,66 @@
+"use client";
+
+import { CursorDataTable } from "@/components/data-table/cursor";
+import type { Direction } from "@/components/data-table/pagination";
+import { useSuspenseQuery } from "@apollo/client/react";
+import { useState } from "react";
+import { columns, type Point } from "./data-table-columns";
+import { POINTS_TABLE_QUERY } from "./query";
+
+export function PointsDataTable() {
+ const PAGE_SIZE = 10;
+ const [after, setAfter] = useState(null);
+ const [before, setBefore] = useState(null);
+ const [direction, setDirection] = useState("backward");
+
+ const variables = direction === "backward"
+ ? { first: PAGE_SIZE, after, last: undefined, before: undefined }
+ : { last: PAGE_SIZE, before, first: undefined, after: undefined };
+
+ const { data } = useSuspenseQuery(POINTS_TABLE_QUERY, {
+ variables,
+ });
+
+ const pointsList = data?.points.edges
+ ?.map((edge) => {
+ const point = edge?.node;
+ if (!point) return null;
+ return {
+ id: point.id,
+ user: {
+ id: point.user.id,
+ name: point.user.name,
+ },
+ points: point.points,
+ description: point.description ?? "",
+ grantedAt: point.grantedAt,
+ } satisfies Point;
+ })
+ .filter((point) => point !== null) ?? [];
+
+ const pageInfo = data?.points.pageInfo;
+
+ const handlePageChange = (direction: Direction) => {
+ if (!pageInfo) return;
+ if (direction === "forward" && pageInfo.hasNextPage) {
+ setAfter(pageInfo.endCursor ?? null);
+ setBefore(null);
+ setDirection("forward");
+ } else if (direction === "backward" && pageInfo.hasPreviousPage) {
+ setBefore(pageInfo.startCursor ?? null);
+ setAfter(null);
+ setDirection("backward");
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/app/(admin)/(activity-management)/points/_components/query.ts b/app/(admin)/(activity-management)/points/_components/query.ts
new file mode 100644
index 0000000..948b2e9
--- /dev/null
+++ b/app/(admin)/(activity-management)/points/_components/query.ts
@@ -0,0 +1,32 @@
+import { graphql } from "@/gql";
+
+export const POINTS_TABLE_QUERY = graphql(`
+ query PointsTable(
+ $first: Int
+ $after: Cursor
+ $last: Int
+ $before: Cursor
+ ) {
+ points(first: $first, after: $after, last: $last, before: $before, orderBy: { field: GRANTED_AT, direction: DESC }) {
+ edges {
+ node {
+ id
+ user {
+ id
+ name
+ }
+ points
+ description
+ grantedAt
+ }
+ }
+ totalCount
+ pageInfo {
+ hasNextPage
+ hasPreviousPage
+ endCursor
+ startCursor
+ }
+ }
+ }
+`);
diff --git a/app/(admin)/(activity-management)/points/page.tsx b/app/(admin)/(activity-management)/points/page.tsx
new file mode 100644
index 0000000..6368de7
--- /dev/null
+++ b/app/(admin)/(activity-management)/points/page.tsx
@@ -0,0 +1,26 @@
+import { SiteHeader } from "@/components/site-header";
+import { PointsDataTable } from "./_components/data-table";
+
+export default function Page() {
+ return (
+ <>
+
+
+
+
+
積分管理
+
查看和管理使用者的積分獲得記錄。
+
+
+
+
+ >
+ );
+}
diff --git a/app/(admin)/(activity-management)/submissions/[id]/_components/header.tsx b/app/(admin)/(activity-management)/submissions/[id]/_components/header.tsx
new file mode 100644
index 0000000..d2781a1
--- /dev/null
+++ b/app/(admin)/(activity-management)/submissions/[id]/_components/header.tsx
@@ -0,0 +1,42 @@
+"use client";
+
+import { Badge } from "@/components/ui/badge";
+import { SubmissionStatus } from "@/gql/graphql";
+import { useSuspenseQuery } from "@apollo/client/react";
+import { SUBMISSION_BY_ID_QUERY } from "./query";
+
+interface HeaderProps {
+ id: string;
+}
+
+const statusMap: Record<
+ SubmissionStatus,
+ { label: string; variant: "default" | "secondary" | "destructive" | "outline" }
+> = {
+ [SubmissionStatus.Success]: { label: "成功", variant: "default" },
+ [SubmissionStatus.Failed]: { label: "錯誤", variant: "destructive" },
+ [SubmissionStatus.Pending]: { label: "處理中", variant: "secondary" },
+};
+
+export function Header({ id }: HeaderProps) {
+ const { data } = useSuspenseQuery(SUBMISSION_BY_ID_QUERY, {
+ variables: { id },
+ });
+
+ const submission = data.submission;
+ const statusInfo = statusMap[submission.status] || { label: submission.status, variant: "outline" as const };
+
+ return (
+
+
+ 提交記錄 #{submission.id}
+
+
+ {statusInfo.label}
+
+ 提交時間:{new Date(submission.submittedAt).toLocaleString("zh-tw")}
+
+
+
+ );
+}
diff --git a/app/(admin)/(activity-management)/submissions/[id]/_components/query.ts b/app/(admin)/(activity-management)/submissions/[id]/_components/query.ts
new file mode 100644
index 0000000..d16051d
--- /dev/null
+++ b/app/(admin)/(activity-management)/submissions/[id]/_components/query.ts
@@ -0,0 +1,25 @@
+import { graphql } from "@/gql";
+
+export const SUBMISSION_BY_ID_QUERY = graphql(`
+ query SubmissionById($id: ID!) {
+ submission(id: $id) {
+ id
+ user {
+ id
+ name
+ }
+ queryResult {
+ columns
+ rows
+ matchAnswer
+ }
+ question {
+ id
+ }
+ error
+ submittedCode
+ status
+ submittedAt
+ }
+ }
+`);
diff --git a/app/(admin)/(activity-management)/submissions/[id]/_components/result-card.tsx b/app/(admin)/(activity-management)/submissions/[id]/_components/result-card.tsx
new file mode 100644
index 0000000..2f3b0c8
--- /dev/null
+++ b/app/(admin)/(activity-management)/submissions/[id]/_components/result-card.tsx
@@ -0,0 +1,85 @@
+"use client";
+
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { StyledLink } from "@/components/ui/link";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+import { useSuspenseQuery } from "@apollo/client/react";
+import { AlertTriangle } from "lucide-react";
+import { SUBMISSION_BY_ID_QUERY } from "./query";
+
+interface ResultCardProps {
+ id: string;
+}
+
+export function ResultCard({ id }: ResultCardProps) {
+ const { data } = useSuspenseQuery(SUBMISSION_BY_ID_QUERY, {
+ variables: { id },
+ });
+
+ const submission = data.submission;
+ const question = submission.question;
+ const queryResult = submission.queryResult;
+
+ if (!queryResult) {
+ return null;
+ }
+
+ const { columns, rows } = queryResult;
+
+ return (
+
+
+ 查詢結果
+ 查看查詢執行的結果
+
+
+ {!queryResult.matchAnswer && (
+
+
+
+ 和正確答案不一致
+
+
+ 您可以到原始問題中取得正確答案應該輸出的結果。
+ 原始問題 →
+
+
+ )}
+
+ {columns.length === 0 || rows.length === 0
+ ? 查詢沒有回傳結果
+ : (
+
+
+
+
+ {columns.map((column, index) => {column})}
+
+
+
+ {rows.map((row, rowIndex) => (
+
+ {row.map((cell, cellIndex) => (
+
+ {cell == null
+ ? (
+
+ NULL
+
+ )
+ : (
+ String(cell)
+ )}
+
+ ))}
+
+ ))}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/app/(admin)/(activity-management)/submissions/[id]/_components/submission-details-card.tsx b/app/(admin)/(activity-management)/submissions/[id]/_components/submission-details-card.tsx
new file mode 100644
index 0000000..e16f480
--- /dev/null
+++ b/app/(admin)/(activity-management)/submissions/[id]/_components/submission-details-card.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { useSuspenseQuery } from "@apollo/client/react";
+import { SUBMISSION_BY_ID_QUERY } from "./query";
+
+interface SubmissionDetailsCardProps {
+ id: string;
+}
+
+export function SubmissionDetailsCard({ id }: SubmissionDetailsCardProps) {
+ const { data } = useSuspenseQuery(SUBMISSION_BY_ID_QUERY, {
+ variables: { id },
+ });
+
+ const submission = data.submission;
+
+ return (
+
+
+ 提交詳情
+ 查看提交的程式碼和錯誤資訊
+
+
+
+
提交的程式碼
+
+ {submission.submittedCode}
+
+
+
+ {submission.error && (
+
+
錯誤訊息
+
+ {submission.error}
+
+
+ )}
+
+
+ );
+}
diff --git a/app/(admin)/(activity-management)/submissions/[id]/_components/user-card.tsx b/app/(admin)/(activity-management)/submissions/[id]/_components/user-card.tsx
new file mode 100644
index 0000000..1cc78fd
--- /dev/null
+++ b/app/(admin)/(activity-management)/submissions/[id]/_components/user-card.tsx
@@ -0,0 +1,37 @@
+"use client";
+
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { StyledLink } from "@/components/ui/link";
+import { useSuspenseQuery } from "@apollo/client/react";
+import { SUBMISSION_BY_ID_QUERY } from "./query";
+
+interface UserCardProps {
+ id: string;
+}
+
+export function UserCard({ id }: UserCardProps) {
+ const { data } = useSuspenseQuery(SUBMISSION_BY_ID_QUERY, {
+ variables: { id },
+ });
+
+ const submission = data.submission;
+
+ return (
+
+
+ 使用者資訊
+ 查看提交此查詢的使用者
+
+
+
+ {submission.user.name} (#{submission.user.id})
+
+
+
+ 檢視使用者資訊 →
+
+
+
+
+ );
+}
diff --git a/app/(admin)/(activity-management)/submissions/[id]/page.tsx b/app/(admin)/(activity-management)/submissions/[id]/page.tsx
new file mode 100644
index 0000000..e6ef914
--- /dev/null
+++ b/app/(admin)/(activity-management)/submissions/[id]/page.tsx
@@ -0,0 +1,42 @@
+import { SiteHeader } from "@/components/site-header";
+import { Suspense } from "react";
+import { Header } from "./_components/header";
+import { ResultCard } from "./_components/result-card";
+import { SubmissionDetailsCard } from "./_components/submission-details-card";
+import { UserCard } from "./_components/user-card";
+
+export default async function SubmissionPage({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const { id } = await params;
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/app/(admin)/(activity-management)/submissions/_components/data-table-columns.tsx b/app/(admin)/(activity-management)/submissions/_components/data-table-columns.tsx
new file mode 100644
index 0000000..053c664
--- /dev/null
+++ b/app/(admin)/(activity-management)/submissions/_components/data-table-columns.tsx
@@ -0,0 +1,123 @@
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { StyledLink } from "@/components/ui/link";
+import { SubmissionStatus } from "@/gql/graphql";
+import type { ColumnDef } from "@tanstack/react-table";
+import { MoreHorizontal } from "lucide-react";
+import Link from "next/link";
+
+export interface Submission {
+ id: string;
+ submittedCode: string;
+ status: SubmissionStatus;
+ user: {
+ id: string;
+ name: string;
+ };
+ question: {
+ id: string;
+ title: string;
+ };
+}
+
+const statusMap: Record<
+ SubmissionStatus,
+ { label: string; variant: "default" | "secondary" | "destructive" | "outline" }
+> = {
+ [SubmissionStatus.Success]: { label: "成功", variant: "default" },
+ [SubmissionStatus.Failed]: { label: "錯誤", variant: "destructive" },
+ [SubmissionStatus.Pending]: { label: "處理中", variant: "secondary" },
+};
+
+export const columns: ColumnDef[] = [
+ {
+ accessorKey: "id",
+ header: "ID",
+ cell: ({ row }) => {
+ const submission = row.original;
+ return (
+
+ {submission.id}
+
+ );
+ },
+ },
+ {
+ accessorKey: "user.id",
+ header: "使用者",
+ cell: ({ row }) => {
+ const userId = row.original.user.id;
+ const userName = row.original.user.name;
+ return (
+
+ {userName} (#{userId})
+
+ );
+ },
+ },
+ {
+ accessorKey: "question.title",
+ header: "題目",
+ cell: ({ row }) => {
+ const question = row.original.question;
+ return {question.title};
+ },
+ },
+ {
+ accessorKey: "status",
+ header: "狀態",
+ cell: ({ row }) => {
+ const status = row.original.status;
+ const statusInfo = statusMap[status] || { label: status, variant: "outline" as const };
+ return {statusInfo.label};
+ },
+ },
+ {
+ accessorKey: "submittedCode",
+ header: "提交程式碼",
+ cell: ({ row }) => {
+ const code = row.original.submittedCode;
+ return (
+
+ {code}
+
+ );
+ },
+ },
+ {
+ id: "actions",
+ cell: ({ row }) => {
+ return (
+
+
+
+
+
+ 動作
+
+ 檢視提交記錄
+
+
+
+ 檢視使用者
+
+
+ 檢視題目
+
+
+
+ );
+ },
+ },
+];
diff --git a/app/(admin)/(activity-management)/submissions/_components/data-table.tsx b/app/(admin)/(activity-management)/submissions/_components/data-table.tsx
new file mode 100644
index 0000000..76b7b44
--- /dev/null
+++ b/app/(admin)/(activity-management)/submissions/_components/data-table.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import { CursorDataTable } from "@/components/data-table/cursor";
+import type { Direction } from "@/components/data-table/pagination";
+import { useSuspenseQuery } from "@apollo/client/react";
+import { useState } from "react";
+import { columns, type Submission } from "./data-table-columns";
+import { SUBMISSIONS_TABLE_QUERY } from "./query";
+
+export function SubmissionsDataTable() {
+ const PAGE_SIZE = 10;
+ const [after, setAfter] = useState(null);
+ const [before, setBefore] = useState(null);
+ const [direction, setDirection] = useState("backward");
+
+ const variables = direction === "backward"
+ ? { first: PAGE_SIZE, after, last: undefined, before: undefined }
+ : { last: PAGE_SIZE, before, first: undefined, after: undefined };
+
+ const { data } = useSuspenseQuery(SUBMISSIONS_TABLE_QUERY, {
+ variables,
+ });
+
+ const submissionList = data?.submissions.edges
+ ?.map((edge) => {
+ const submission = edge?.node;
+ if (!submission) return null;
+ return {
+ id: submission.id,
+ submittedCode: submission.submittedCode,
+ status: submission.status,
+ user: {
+ id: submission.user.id,
+ name: submission.user.name,
+ },
+ question: {
+ id: submission.question.id,
+ title: submission.question.title,
+ },
+ } satisfies Submission;
+ })
+ .filter((submission) => submission !== null) ?? [];
+
+ const pageInfo = data?.submissions.pageInfo;
+
+ const handlePageChange = (direction: Direction) => {
+ if (!pageInfo) return;
+ if (direction === "forward" && pageInfo.hasNextPage) {
+ setAfter(pageInfo.endCursor ?? null);
+ setBefore(null);
+ setDirection("forward");
+ } else if (direction === "backward" && pageInfo.hasPreviousPage) {
+ setBefore(pageInfo.startCursor ?? null);
+ setAfter(null);
+ setDirection("backward");
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/app/(admin)/(activity-management)/submissions/_components/query.ts b/app/(admin)/(activity-management)/submissions/_components/query.ts
new file mode 100644
index 0000000..e75ca41
--- /dev/null
+++ b/app/(admin)/(activity-management)/submissions/_components/query.ts
@@ -0,0 +1,35 @@
+import { graphql } from "@/gql";
+
+export const SUBMISSIONS_TABLE_QUERY = graphql(`
+ query SubmissionsTable(
+ $first: Int
+ $after: Cursor
+ $last: Int
+ $before: Cursor
+ ) {
+ submissions(first: $first, after: $after, last: $last, before: $before, orderBy: { field: SUBMITTED_AT, direction: DESC }) {
+ edges {
+ node {
+ id
+ submittedCode
+ status
+ user {
+ id
+ name
+ }
+ question {
+ id
+ title
+ }
+ }
+ }
+ totalCount
+ pageInfo {
+ hasNextPage
+ hasPreviousPage
+ endCursor
+ startCursor
+ }
+ }
+ }
+`);
diff --git a/app/(admin)/(activity-management)/submissions/page.tsx b/app/(admin)/(activity-management)/submissions/page.tsx
new file mode 100644
index 0000000..7e5fe9f
--- /dev/null
+++ b/app/(admin)/(activity-management)/submissions/page.tsx
@@ -0,0 +1,26 @@
+import { SiteHeader } from "@/components/site-header";
+import { SubmissionsDataTable } from "./_components/data-table";
+
+export default function Page() {
+ return (
+ <>
+
+
+
+
+
提交記錄管理
+
查看和管理使用者的查詢提交記錄。
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx
index 85304d0..8e8a691 100644
--- a/components/app-sidebar.tsx
+++ b/components/app-sidebar.tsx
@@ -1,6 +1,6 @@
"use client";
-import { Book, Code, LibrarySquare, type LucideIcon, SquareUser } from "lucide-react";
+import { Activity, Book, Code, Coins, LibrarySquare, type LucideIcon, Send, SquareUser } from "lucide-react";
import * as React from "react";
import { NavMain } from "@/components/nav-main";
@@ -50,48 +50,76 @@ const isUserManagement = (pathname: string) =>
const buildNavbar = (
pathname: string,
): {
- navMain: NavItem[];
+ navMain: { group: string; items: NavItem[] }[];
navSecondary: NavItem[];
} => ({
navMain: [
{
- title: "使用者管理",
- url: "/users",
- icon: SquareUser,
- isActive: isUserManagement(pathname),
+ group: "資料管理",
items: [
{
- title: "使用者",
+ title: "使用者管理",
url: "/users",
- isActive: pathname.startsWith("/users"),
+ icon: SquareUser,
+ isActive: isUserManagement(pathname),
+ items: [
+ {
+ title: "使用者",
+ url: "/users",
+ isActive: pathname.startsWith("/users"),
+ },
+ {
+ title: "群組",
+ url: "/groups",
+ isActive: pathname.startsWith("/groups"),
+ },
+ {
+ title: "權限集",
+ url: "/scopesets",
+ isActive: pathname.startsWith("/scopesets"),
+ },
+ ],
},
{
- title: "群組",
- url: "/groups",
- isActive: pathname.startsWith("/groups"),
- },
- {
- title: "權限集",
- url: "/scopesets",
- isActive: pathname.startsWith("/scopesets"),
+ title: "題庫管理",
+ url: "/questions",
+ icon: LibrarySquare,
+ isActive: pathname.startsWith("/questions") || pathname.startsWith("/database"),
+ items: [
+ {
+ title: "題庫",
+ url: "/questions",
+ isActive: pathname.startsWith("/questions"),
+ },
+ {
+ title: "資料庫",
+ url: "/database",
+ isActive: pathname.startsWith("/database"),
+ },
+ ],
},
],
},
{
- title: "題庫管理",
- url: "/questions",
- icon: LibrarySquare,
- isActive: pathname.startsWith("/questions") || pathname.startsWith("/database"),
+ group: "系統操作動態",
items: [
{
- title: "題庫",
- url: "/questions",
- isActive: pathname.startsWith("/questions"),
+ title: "提交記錄",
+ url: "/submissions",
+ icon: Send,
+ isActive: pathname.startsWith("/submissions"),
+ },
+ {
+ title: "事件記錄",
+ url: "/events",
+ icon: Activity,
+ isActive: pathname.startsWith("/events"),
},
{
- title: "資料庫",
- url: "/database",
- isActive: pathname.startsWith("/database"),
+ title: "積分記錄",
+ url: "/points",
+ icon: Coins,
+ isActive: pathname.startsWith("/points"),
},
],
},
@@ -139,7 +167,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
-
+ {data.navMain.map((group) => )}
diff --git a/components/nav-main.tsx b/components/nav-main.tsx
index 7adebc5..b71f307 100644
--- a/components/nav-main.tsx
+++ b/components/nav-main.tsx
@@ -19,17 +19,19 @@ import type { NavItem } from "./app-sidebar";
export function NavMain({
items,
+ groupLabel,
}: {
items: NavItem[];
+ groupLabel?: string;
}) {
return (
- 資料管理
+ {groupLabel || "資料管理"}
{items.map((item) => (
-
+
{item.title}
diff --git a/gql/gql.ts b/gql/gql.ts
index 5a28ea3..57a6d37 100644
--- a/gql/gql.ts
+++ b/gql/gql.ts
@@ -14,6 +14,12 @@ import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-
* Learn more about it here: https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#reducing-bundle-size
*/
type Documents = {
+ "\n query EventById($id: ID!) {\n event(id: $id) {\n id\n user {\n id\n name\n }\n type\n payload\n triggeredAt\n }\n }\n": typeof types.EventByIdDocument,
+ "\n query EventsTable(\n $first: Int\n $after: Cursor\n $last: Int\n $before: Cursor\n ) {\n events(first: $first, after: $after, last: $last, before: $before, orderBy: { field: TRIGGERED_AT, direction: DESC }) {\n edges {\n node {\n id\n user {\n id\n name\n }\n type\n triggeredAt\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n endCursor\n startCursor\n }\n }\n }\n": typeof types.EventsTableDocument,
+ "\n query PointById($id: ID!) {\n pointGrant(id: $id) {\n id\n user {\n id\n name\n }\n points\n description\n grantedAt\n }\n }\n": typeof types.PointByIdDocument,
+ "\n query PointsTable(\n $first: Int\n $after: Cursor\n $last: Int\n $before: Cursor\n ) {\n points(first: $first, after: $after, last: $last, before: $before, orderBy: { field: GRANTED_AT, direction: DESC }) {\n edges {\n node {\n id\n user {\n id\n name\n }\n points\n description\n grantedAt\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n endCursor\n startCursor\n }\n }\n }\n": typeof types.PointsTableDocument,
+ "\n query SubmissionById($id: ID!) {\n submission(id: $id) {\n id\n user {\n id\n name\n }\n queryResult {\n columns\n rows\n matchAnswer\n }\n question {\n id\n }\n error\n submittedCode\n status\n submittedAt\n }\n }\n": typeof types.SubmissionByIdDocument,
+ "\n query SubmissionsTable(\n $first: Int\n $after: Cursor\n $last: Int\n $before: Cursor\n ) {\n submissions(first: $first, after: $after, last: $last, before: $before, orderBy: { field: SUBMITTED_AT, direction: DESC }) {\n edges {\n node {\n id\n submittedCode\n status\n user {\n id\n name\n }\n question {\n id\n title\n }\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n endCursor\n startCursor\n }\n }\n }\n": typeof types.SubmissionsTableDocument,
"\n query DatabaseDetail($id: ID!) {\n database(id: $id) {\n id\n slug\n description\n schema\n relationFigure\n }\n }\n": typeof types.DatabaseDetailDocument,
"\n mutation CreateDatabase($input: CreateDatabaseInput!) {\n createDatabase(input: $input) {\n id\n }\n }\n": typeof types.CreateDatabaseDocument,
"\n mutation UpdateDatabase($id: ID!, $input: UpdateDatabaseInput!) {\n updateDatabase(id: $id, input: $input) {\n id\n }\n }\n": typeof types.UpdateDatabaseDocument,
@@ -61,6 +67,12 @@ type Documents = {
"\n query BasicUserInfo {\n me {\n id\n name\n email\n avatar\n\n group {\n name\n }\n }\n }\n": typeof types.BasicUserInfoDocument,
};
const documents: Documents = {
+ "\n query EventById($id: ID!) {\n event(id: $id) {\n id\n user {\n id\n name\n }\n type\n payload\n triggeredAt\n }\n }\n": types.EventByIdDocument,
+ "\n query EventsTable(\n $first: Int\n $after: Cursor\n $last: Int\n $before: Cursor\n ) {\n events(first: $first, after: $after, last: $last, before: $before, orderBy: { field: TRIGGERED_AT, direction: DESC }) {\n edges {\n node {\n id\n user {\n id\n name\n }\n type\n triggeredAt\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n endCursor\n startCursor\n }\n }\n }\n": types.EventsTableDocument,
+ "\n query PointById($id: ID!) {\n pointGrant(id: $id) {\n id\n user {\n id\n name\n }\n points\n description\n grantedAt\n }\n }\n": types.PointByIdDocument,
+ "\n query PointsTable(\n $first: Int\n $after: Cursor\n $last: Int\n $before: Cursor\n ) {\n points(first: $first, after: $after, last: $last, before: $before, orderBy: { field: GRANTED_AT, direction: DESC }) {\n edges {\n node {\n id\n user {\n id\n name\n }\n points\n description\n grantedAt\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n endCursor\n startCursor\n }\n }\n }\n": types.PointsTableDocument,
+ "\n query SubmissionById($id: ID!) {\n submission(id: $id) {\n id\n user {\n id\n name\n }\n queryResult {\n columns\n rows\n matchAnswer\n }\n question {\n id\n }\n error\n submittedCode\n status\n submittedAt\n }\n }\n": types.SubmissionByIdDocument,
+ "\n query SubmissionsTable(\n $first: Int\n $after: Cursor\n $last: Int\n $before: Cursor\n ) {\n submissions(first: $first, after: $after, last: $last, before: $before, orderBy: { field: SUBMITTED_AT, direction: DESC }) {\n edges {\n node {\n id\n submittedCode\n status\n user {\n id\n name\n }\n question {\n id\n title\n }\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n endCursor\n startCursor\n }\n }\n }\n": types.SubmissionsTableDocument,
"\n query DatabaseDetail($id: ID!) {\n database(id: $id) {\n id\n slug\n description\n schema\n relationFigure\n }\n }\n": types.DatabaseDetailDocument,
"\n mutation CreateDatabase($input: CreateDatabaseInput!) {\n createDatabase(input: $input) {\n id\n }\n }\n": types.CreateDatabaseDocument,
"\n mutation UpdateDatabase($id: ID!, $input: UpdateDatabaseInput!) {\n updateDatabase(id: $id, input: $input) {\n id\n }\n }\n": types.UpdateDatabaseDocument,
@@ -122,6 +134,30 @@ const documents: Documents = {
*/
export function graphql(source: string): unknown;
+/**
+ * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
+ */
+export function graphql(source: "\n query EventById($id: ID!) {\n event(id: $id) {\n id\n user {\n id\n name\n }\n type\n payload\n triggeredAt\n }\n }\n"): (typeof documents)["\n query EventById($id: ID!) {\n event(id: $id) {\n id\n user {\n id\n name\n }\n type\n payload\n triggeredAt\n }\n }\n"];
+/**
+ * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
+ */
+export function graphql(source: "\n query EventsTable(\n $first: Int\n $after: Cursor\n $last: Int\n $before: Cursor\n ) {\n events(first: $first, after: $after, last: $last, before: $before, orderBy: { field: TRIGGERED_AT, direction: DESC }) {\n edges {\n node {\n id\n user {\n id\n name\n }\n type\n triggeredAt\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n endCursor\n startCursor\n }\n }\n }\n"): (typeof documents)["\n query EventsTable(\n $first: Int\n $after: Cursor\n $last: Int\n $before: Cursor\n ) {\n events(first: $first, after: $after, last: $last, before: $before, orderBy: { field: TRIGGERED_AT, direction: DESC }) {\n edges {\n node {\n id\n user {\n id\n name\n }\n type\n triggeredAt\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n endCursor\n startCursor\n }\n }\n }\n"];
+/**
+ * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
+ */
+export function graphql(source: "\n query PointById($id: ID!) {\n pointGrant(id: $id) {\n id\n user {\n id\n name\n }\n points\n description\n grantedAt\n }\n }\n"): (typeof documents)["\n query PointById($id: ID!) {\n pointGrant(id: $id) {\n id\n user {\n id\n name\n }\n points\n description\n grantedAt\n }\n }\n"];
+/**
+ * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
+ */
+export function graphql(source: "\n query PointsTable(\n $first: Int\n $after: Cursor\n $last: Int\n $before: Cursor\n ) {\n points(first: $first, after: $after, last: $last, before: $before, orderBy: { field: GRANTED_AT, direction: DESC }) {\n edges {\n node {\n id\n user {\n id\n name\n }\n points\n description\n grantedAt\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n endCursor\n startCursor\n }\n }\n }\n"): (typeof documents)["\n query PointsTable(\n $first: Int\n $after: Cursor\n $last: Int\n $before: Cursor\n ) {\n points(first: $first, after: $after, last: $last, before: $before, orderBy: { field: GRANTED_AT, direction: DESC }) {\n edges {\n node {\n id\n user {\n id\n name\n }\n points\n description\n grantedAt\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n endCursor\n startCursor\n }\n }\n }\n"];
+/**
+ * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
+ */
+export function graphql(source: "\n query SubmissionById($id: ID!) {\n submission(id: $id) {\n id\n user {\n id\n name\n }\n queryResult {\n columns\n rows\n matchAnswer\n }\n question {\n id\n }\n error\n submittedCode\n status\n submittedAt\n }\n }\n"): (typeof documents)["\n query SubmissionById($id: ID!) {\n submission(id: $id) {\n id\n user {\n id\n name\n }\n queryResult {\n columns\n rows\n matchAnswer\n }\n question {\n id\n }\n error\n submittedCode\n status\n submittedAt\n }\n }\n"];
+/**
+ * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
+ */
+export function graphql(source: "\n query SubmissionsTable(\n $first: Int\n $after: Cursor\n $last: Int\n $before: Cursor\n ) {\n submissions(first: $first, after: $after, last: $last, before: $before, orderBy: { field: SUBMITTED_AT, direction: DESC }) {\n edges {\n node {\n id\n submittedCode\n status\n user {\n id\n name\n }\n question {\n id\n title\n }\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n endCursor\n startCursor\n }\n }\n }\n"): (typeof documents)["\n query SubmissionsTable(\n $first: Int\n $after: Cursor\n $last: Int\n $before: Cursor\n ) {\n submissions(first: $first, after: $after, last: $last, before: $before, orderBy: { field: SUBMITTED_AT, direction: DESC }) {\n edges {\n node {\n id\n submittedCode\n status\n user {\n id\n name\n }\n question {\n id\n title\n }\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n endCursor\n startCursor\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
diff --git a/gql/graphql.ts b/gql/graphql.ts
index 5a4313c..48e988e 100644
--- a/gql/graphql.ts
+++ b/gql/graphql.ts
@@ -1,7 +1,7 @@
/* eslint-disable */
import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
export type Maybe = T | null;
-export type InputMaybe = Maybe;
+export type InputMaybe = T | null | undefined;
export type Exact = { [K in keyof T]: T[K] };
export type MakeOptional = Omit & { [SubKey in K]?: Maybe };
export type MakeMaybe = Omit & { [SubKey in K]: Maybe };
@@ -19,6 +19,8 @@ export type Scalars = {
* https://relay.dev/graphql/connections.htm#sec-Cursor
*/
Cursor: { input: any; output: any; }
+ /** The builtin Map type */
+ Map: { input: any; output: any; }
/** The builtin Time type */
Time: { input: string; output: string; }
};
@@ -61,6 +63,7 @@ export type CreateQuestionInput = {
difficulty?: InputMaybe;
/** Reference answer */
referenceAnswer: Scalars['String']['input'];
+ submissionIDs?: InputMaybe>;
/** Question title */
title: Scalars['String']['input'];
};
@@ -83,8 +86,11 @@ export type CreateScopeSetInput = {
export type CreateUserInput = {
avatar?: InputMaybe;
email: Scalars['String']['input'];
+ eventIDs?: InputMaybe>;
groupID: Scalars['ID']['input'];
name: Scalars['String']['input'];
+ pointIDs?: InputMaybe>;
+ submissionIDs?: InputMaybe>;
};
export type Database = Node & {
@@ -179,6 +185,99 @@ export type DatabaseWhereInput = {
slugNotIn?: InputMaybe>;
};
+export type Event = Node & {
+ __typename?: 'Event';
+ id: Scalars['ID']['output'];
+ payload?: Maybe;
+ triggeredAt: Scalars['Time']['output'];
+ type: Scalars['String']['output'];
+ user: User;
+ userID: Scalars['ID']['output'];
+};
+
+/** A connection to a list of items. */
+export type EventConnection = {
+ __typename?: 'EventConnection';
+ /** A list of edges. */
+ edges?: Maybe>>;
+ /** Information to aid in pagination. */
+ pageInfo: PageInfo;
+ /** Identifies the total count of items in the connection. */
+ totalCount: Scalars['Int']['output'];
+};
+
+/** An edge in a connection. */
+export type EventEdge = {
+ __typename?: 'EventEdge';
+ /** A cursor for use in pagination. */
+ cursor: Scalars['Cursor']['output'];
+ /** The item at the end of the edge. */
+ node?: Maybe;
+};
+
+/** Ordering options for Event connections */
+export type EventOrder = {
+ /** The ordering direction. */
+ direction?: OrderDirection;
+ /** The field by which to order Events. */
+ field: EventOrderField;
+};
+
+/** Properties by which Event connections can be ordered. */
+export enum EventOrderField {
+ TriggeredAt = 'TRIGGERED_AT'
+}
+
+/**
+ * EventWhereInput is used for filtering Event objects.
+ * Input was generated by ent.
+ */
+export type EventWhereInput = {
+ and?: InputMaybe>;
+ /** user edge predicates */
+ hasUser?: InputMaybe;
+ hasUserWith?: InputMaybe>;
+ /** id field predicates */
+ id?: InputMaybe;
+ idGT?: InputMaybe;
+ idGTE?: InputMaybe;
+ idIn?: InputMaybe>;
+ idLT?: InputMaybe;
+ idLTE?: InputMaybe;
+ idNEQ?: InputMaybe;
+ idNotIn?: InputMaybe>;
+ not?: InputMaybe;
+ or?: InputMaybe>;
+ /** triggered_at field predicates */
+ triggeredAt?: InputMaybe;
+ triggeredAtGT?: InputMaybe;
+ triggeredAtGTE?: InputMaybe;
+ triggeredAtIn?: InputMaybe>;
+ triggeredAtLT?: InputMaybe;
+ triggeredAtLTE?: InputMaybe;
+ triggeredAtNEQ?: InputMaybe;
+ triggeredAtNotIn?: InputMaybe>;
+ /** type field predicates */
+ type?: InputMaybe;
+ typeContains?: InputMaybe;
+ typeContainsFold?: InputMaybe;
+ typeEqualFold?: InputMaybe;
+ typeGT?: InputMaybe;
+ typeGTE?: InputMaybe;
+ typeHasPrefix?: InputMaybe;
+ typeHasSuffix?: InputMaybe;
+ typeIn?: InputMaybe>;
+ typeLT?: InputMaybe;
+ typeLTE?: InputMaybe;
+ typeNEQ?: InputMaybe;
+ typeNotIn?: InputMaybe>;
+ /** user_id field predicates */
+ userID?: InputMaybe;
+ userIDIn?: InputMaybe>;
+ userIDNEQ?: InputMaybe;
+ userIDNotIn?: InputMaybe>;
+};
+
export type Group = Node & {
__typename?: 'Group';
createdAt: Scalars['Time']['output'];
@@ -302,6 +401,8 @@ export type Mutation = {
logoutAll: Scalars['Boolean']['output'];
/** Logout a user from all his devices. */
logoutUser: Scalars['Boolean']['output'];
+ /** Submit your answer to a question. */
+ submitAnswer: SubmissionResult;
/** Update a database. */
updateDatabase: Database;
/** Update a group. */
@@ -374,6 +475,12 @@ export type MutationLogoutUserArgs = {
};
+export type MutationSubmitAnswerArgs = {
+ answer: Scalars['String']['input'];
+ id: Scalars['ID']['input'];
+};
+
+
export type MutationUpdateDatabaseArgs = {
id: Scalars['ID']['input'];
input: UpdateDatabaseInput;
@@ -441,11 +548,117 @@ export type PageInfo = {
startCursor?: Maybe;
};
+export type Point = Node & {
+ __typename?: 'Point';
+ description?: Maybe;
+ grantedAt: Scalars['Time']['output'];
+ id: Scalars['ID']['output'];
+ points: Scalars['Int']['output'];
+ user: User;
+};
+
+/** A connection to a list of items. */
+export type PointConnection = {
+ __typename?: 'PointConnection';
+ /** A list of edges. */
+ edges?: Maybe>>;
+ /** Information to aid in pagination. */
+ pageInfo: PageInfo;
+ /** Identifies the total count of items in the connection. */
+ totalCount: Scalars['Int']['output'];
+};
+
+/** An edge in a connection. */
+export type PointEdge = {
+ __typename?: 'PointEdge';
+ /** A cursor for use in pagination. */
+ cursor: Scalars['Cursor']['output'];
+ /** The item at the end of the edge. */
+ node?: Maybe;
+};
+
+/** Ordering options for Point connections */
+export type PointOrder = {
+ /** The ordering direction. */
+ direction?: OrderDirection;
+ /** The field by which to order Points. */
+ field: PointOrderField;
+};
+
+/** Properties by which Point connections can be ordered. */
+export enum PointOrderField {
+ GrantedAt = 'GRANTED_AT'
+}
+
+/**
+ * PointWhereInput is used for filtering Point objects.
+ * Input was generated by ent.
+ */
+export type PointWhereInput = {
+ and?: InputMaybe>;
+ /** description field predicates */
+ description?: InputMaybe;
+ descriptionContains?: InputMaybe;
+ descriptionContainsFold?: InputMaybe;
+ descriptionEqualFold?: InputMaybe;
+ descriptionGT?: InputMaybe;
+ descriptionGTE?: InputMaybe;
+ descriptionHasPrefix?: InputMaybe;
+ descriptionHasSuffix?: InputMaybe;
+ descriptionIn?: InputMaybe>;
+ descriptionIsNil?: InputMaybe;
+ descriptionLT?: InputMaybe;
+ descriptionLTE?: InputMaybe;
+ descriptionNEQ?: InputMaybe;
+ descriptionNotIn?: InputMaybe>;
+ descriptionNotNil?: InputMaybe;
+ /** granted_at field predicates */
+ grantedAt?: InputMaybe;
+ grantedAtGT?: InputMaybe;
+ grantedAtGTE?: InputMaybe;
+ grantedAtIn?: InputMaybe>;
+ grantedAtLT?: InputMaybe;
+ grantedAtLTE?: InputMaybe;
+ grantedAtNEQ?: InputMaybe;
+ grantedAtNotIn?: InputMaybe>;
+ /** user edge predicates */
+ hasUser?: InputMaybe;
+ hasUserWith?: InputMaybe>;
+ /** id field predicates */
+ id?: InputMaybe;
+ idGT?: InputMaybe;
+ idGTE?: InputMaybe;
+ idIn?: InputMaybe>;
+ idLT?: InputMaybe;
+ idLTE?: InputMaybe;
+ idNEQ?: InputMaybe;
+ idNotIn?: InputMaybe>;
+ not?: InputMaybe;
+ or?: InputMaybe>;
+ /** points field predicates */
+ points?: InputMaybe;
+ pointsGT?: InputMaybe;
+ pointsGTE?: InputMaybe;
+ pointsIn?: InputMaybe>;
+ pointsLT?: InputMaybe;
+ pointsLTE?: InputMaybe;
+ pointsNEQ?: InputMaybe;
+ pointsNotIn?: InputMaybe>;
+};
+
export type Query = {
__typename?: 'Query';
/** Get a database by ID. */
database: Database;
databases: Array;
+ /**
+ * Get an event by ID.
+ *
+ * If you have the "event:read" scope, you can get any event by ID;
+ * otherwise, you can only get your own events.
+ */
+ event: Event;
+ events: EventConnection;
/** Get a group by ID. */
group: Group;
groups: Array;
@@ -454,12 +667,28 @@ export type Query = {
node?: Maybe;
/** Lookup nodes by a list of IDs. */
nodes: Array>;
+ /**
+ * Get a point grant by ID.
+ *
+ * If you have the "point:read" scope, you can get any point grant by ID;
+ * otherwise, you can only get your own point grants.
+ */
+ pointGrant: Point;
+ points: PointConnection;
/** Get a question by ID. */
question: Question;
questions: QuestionConnection;
/** Get a scope set by ID or slug. */
scopeSet: ScopeSet;
scopeSets: Array;
+ /**
+ * Get a submission by ID.
+ *
+ * If you have the "submission:read" scope, you can get any submission by ID;
+ * otherwise, you can only get your own submissions.
+ */
+ submission: Submission;
+ submissions: SubmissionConnection;
/** Get a user by ID. */
user: User;
users: UserConnection;
@@ -471,6 +700,21 @@ export type QueryDatabaseArgs = {
};
+export type QueryEventArgs = {
+ id: Scalars['ID']['input'];
+};
+
+
+export type QueryEventsArgs = {
+ after?: InputMaybe;
+ before?: InputMaybe;
+ first?: InputMaybe;
+ last?: InputMaybe;
+ orderBy?: InputMaybe;
+ where?: InputMaybe;
+};
+
+
export type QueryGroupArgs = {
id: Scalars['ID']['input'];
};
@@ -486,6 +730,21 @@ export type QueryNodesArgs = {
};
+export type QueryPointGrantArgs = {
+ id: Scalars['ID']['input'];
+};
+
+
+export type QueryPointsArgs = {
+ after?: InputMaybe;
+ before?: InputMaybe;
+ first?: InputMaybe;
+ last?: InputMaybe;
+ orderBy?: InputMaybe;
+ where?: InputMaybe;
+};
+
+
export type QueryQuestionArgs = {
id: Scalars['ID']['input'];
};
@@ -506,6 +765,21 @@ export type QueryScopeSetArgs = {
};
+export type QuerySubmissionArgs = {
+ id: Scalars['ID']['input'];
+};
+
+
+export type QuerySubmissionsArgs = {
+ after?: InputMaybe;
+ before?: InputMaybe;
+ first?: InputMaybe;
+ last?: InputMaybe;
+ orderBy?: InputMaybe;
+ where?: InputMaybe;
+};
+
+
export type QueryUserArgs = {
id: Scalars['ID']['input'];
};
@@ -532,7 +806,8 @@ export type Question = Node & {
id: Scalars['ID']['output'];
/** Reference answer */
referenceAnswer: Scalars['String']['output'];
- referenceAnswerResult: SqlResponse;
+ referenceAnswerResult: SqlExecutionResult;
+ submissions?: Maybe>;
/** Question title */
title: Scalars['String']['output'];
};
@@ -621,6 +896,9 @@ export type QuestionWhereInput = {
/** database edge predicates */
hasDatabase?: InputMaybe;
hasDatabaseWith?: InputMaybe>;
+ /** submissions edge predicates */
+ hasSubmissions?: InputMaybe;
+ hasSubmissionsWith?: InputMaybe>;
/** id field predicates */
id?: InputMaybe;
idGT?: InputMaybe;
@@ -662,6 +940,12 @@ export type QuestionWhereInput = {
titleNotIn?: InputMaybe>;
};
+export type SqlExecutionResult = {
+ __typename?: 'SQLExecutionResult';
+ columns: Array;
+ rows: Array>;
+};
+
export type ScopeSet = Node & {
__typename?: 'ScopeSet';
description?: Maybe;
@@ -735,10 +1019,135 @@ export type ScopeSetWhereInput = {
slugNotIn?: InputMaybe>;
};
-export type SqlResponse = {
- __typename?: 'SqlResponse';
- columns: Array;
- rows: Array>;
+export type Submission = Node & {
+ __typename?: 'Submission';
+ error?: Maybe;
+ id: Scalars['ID']['output'];
+ queryResult?: Maybe;
+ question: Question;
+ status: SubmissionStatus;
+ submittedAt: Scalars['Time']['output'];
+ submittedCode: Scalars['String']['output'];
+ user: User;
+};
+
+/** A connection to a list of items. */
+export type SubmissionConnection = {
+ __typename?: 'SubmissionConnection';
+ /** A list of edges. */
+ edges?: Maybe>>;
+ /** Information to aid in pagination. */
+ pageInfo: PageInfo;
+ /** Identifies the total count of items in the connection. */
+ totalCount: Scalars['Int']['output'];
+};
+
+/** An edge in a connection. */
+export type SubmissionEdge = {
+ __typename?: 'SubmissionEdge';
+ /** A cursor for use in pagination. */
+ cursor: Scalars['Cursor']['output'];
+ /** The item at the end of the edge. */
+ node?: Maybe;
+};
+
+/** Ordering options for Submission connections */
+export type SubmissionOrder = {
+ /** The ordering direction. */
+ direction?: OrderDirection;
+ /** The field by which to order Submissions. */
+ field: SubmissionOrderField;
+};
+
+/** Properties by which Submission connections can be ordered. */
+export enum SubmissionOrderField {
+ SubmittedAt = 'SUBMITTED_AT'
+}
+
+export type SubmissionResult = {
+ __typename?: 'SubmissionResult';
+ error?: Maybe;
+ result?: Maybe;
+};
+
+/** SubmissionStatus is enum for the field status */
+export enum SubmissionStatus {
+ Failed = 'failed',
+ Pending = 'pending',
+ Success = 'success'
+}
+
+/**
+ * SubmissionWhereInput is used for filtering Submission objects.
+ * Input was generated by ent.
+ */
+export type SubmissionWhereInput = {
+ and?: InputMaybe>;
+ /** error field predicates */
+ error?: InputMaybe;
+ errorContains?: InputMaybe;
+ errorContainsFold?: InputMaybe;
+ errorEqualFold?: InputMaybe;
+ errorGT?: InputMaybe;
+ errorGTE?: InputMaybe;
+ errorHasPrefix?: InputMaybe;
+ errorHasSuffix?: InputMaybe;
+ errorIn?: InputMaybe>;
+ errorIsNil?: InputMaybe;
+ errorLT?: InputMaybe;
+ errorLTE?: InputMaybe;
+ errorNEQ?: InputMaybe;
+ errorNotIn?: InputMaybe>;
+ errorNotNil?: InputMaybe;
+ /** question edge predicates */
+ hasQuestion?: InputMaybe;
+ hasQuestionWith?: InputMaybe>;
+ /** user edge predicates */
+ hasUser?: InputMaybe;
+ hasUserWith?: InputMaybe>;
+ /** id field predicates */
+ id?: InputMaybe;
+ idGT?: InputMaybe;
+ idGTE?: InputMaybe;
+ idIn?: InputMaybe>;
+ idLT?: InputMaybe;
+ idLTE?: InputMaybe;
+ idNEQ?: InputMaybe;
+ idNotIn?: InputMaybe>;
+ not?: InputMaybe;
+ or?: InputMaybe>;
+ /** status field predicates */
+ status?: InputMaybe;
+ statusIn?: InputMaybe>;
+ statusNEQ?: InputMaybe;
+ statusNotIn?: InputMaybe>;
+ /** submitted_at field predicates */
+ submittedAt?: InputMaybe;
+ submittedAtGT?: InputMaybe;
+ submittedAtGTE?: InputMaybe;
+ submittedAtIn?: InputMaybe>;
+ submittedAtLT?: InputMaybe;
+ submittedAtLTE?: InputMaybe;
+ submittedAtNEQ?: InputMaybe;
+ submittedAtNotIn?: InputMaybe>;
+ /** submitted_code field predicates */
+ submittedCode?: InputMaybe;
+ submittedCodeContains?: InputMaybe;
+ submittedCodeContainsFold?: InputMaybe;
+ submittedCodeEqualFold?: InputMaybe;
+ submittedCodeGT?: InputMaybe;
+ submittedCodeGTE?: InputMaybe;
+ submittedCodeHasPrefix?: InputMaybe;
+ submittedCodeHasSuffix?: InputMaybe;
+ submittedCodeIn?: InputMaybe>;
+ submittedCodeLT?: InputMaybe;
+ submittedCodeLTE?: InputMaybe;
+ submittedCodeNEQ?: InputMaybe;
+ submittedCodeNotIn?: InputMaybe>;
+};
+
+export type SubmissionsOfQuestionWhereInput = {
+ status?: InputMaybe;
};
/**
@@ -775,6 +1184,8 @@ export type UpdateGroupInput = {
* Input was generated by ent.
*/
export type UpdateQuestionInput = {
+ addSubmissionIDs?: InputMaybe>;
+ clearSubmissions?: InputMaybe;
databaseID?: InputMaybe;
/** Question stem */
description?: InputMaybe;
@@ -782,6 +1193,7 @@ export type UpdateQuestionInput = {
difficulty?: InputMaybe;
/** Reference answer */
referenceAnswer?: InputMaybe;
+ removeSubmissionIDs?: InputMaybe>;
/** Question title */
title?: InputMaybe;
};
@@ -805,10 +1217,19 @@ export type UpdateScopeSetInput = {
* Input was generated by ent.
*/
export type UpdateUserInput = {
+ addEventIDs?: InputMaybe>;
+ addPointIDs?: InputMaybe>;
+ addSubmissionIDs?: InputMaybe>;
avatar?: InputMaybe;
clearAvatar?: InputMaybe;
+ clearEvents?: InputMaybe;
+ clearPoints?: InputMaybe;
+ clearSubmissions?: InputMaybe;
groupID?: InputMaybe;
name?: InputMaybe;
+ removeEventIDs?: InputMaybe>;
+ removePointIDs?: InputMaybe>;
+ removeSubmissionIDs?: InputMaybe>;
};
export type User = Node & {
@@ -817,14 +1238,62 @@ export type User = Node & {
createdAt: Scalars['Time']['output'];
deletedAt?: Maybe;
email: Scalars['String']['output'];
+ events: EventConnection;
group: Group;
id: Scalars['ID']['output'];
/** The user who impersonated this user. */
impersonatedBy?: Maybe;
name: Scalars['String']['output'];
+ points: PointConnection;
+ submissions: SubmissionConnection;
+ /** Get all submissions of a question. */
+ submissionsOfQuestion: SubmissionConnection;
+ /** The total points of the user. */
+ totalPoints: Scalars['Int']['output'];
updatedAt: Scalars['Time']['output'];
};
+
+export type UserEventsArgs = {
+ after?: InputMaybe;
+ before?: InputMaybe;
+ first?: InputMaybe;
+ last?: InputMaybe;
+ orderBy?: InputMaybe