Skip to content

Commit 04f144c

Browse files
authored
Merge pull request #16 from database-playground/pan93412/dbp-41-implement-event-display-in-admin
feat: implement activity details
2 parents 55a315a + 9378669 commit 04f144c

33 files changed

+2499
-40
lines changed
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"use client";
2+
3+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
4+
import { useSuspenseQuery } from "@apollo/client/react";
5+
import { EVENT_BY_ID_QUERY } from "./query";
6+
7+
interface EventDetailsCardProps {
8+
id: string;
9+
}
10+
11+
export function EventDetailsCard({ id }: EventDetailsCardProps) {
12+
const { data } = useSuspenseQuery(EVENT_BY_ID_QUERY, {
13+
variables: { id },
14+
});
15+
16+
const event = data?.event;
17+
18+
if (!event) {
19+
return (
20+
<Card>
21+
<CardHeader>
22+
<CardTitle>事件詳情</CardTitle>
23+
<CardDescription>查看事件的詳細資訊和負載資料</CardDescription>
24+
</CardHeader>
25+
<CardContent>
26+
<p className="text-muted-foreground">找不到事件記錄</p>
27+
</CardContent>
28+
</Card>
29+
);
30+
}
31+
32+
let payloadData = null;
33+
try {
34+
payloadData = event.payload ? JSON.parse(event.payload) : null;
35+
} catch {
36+
// If payload is not valid JSON, treat as string
37+
payloadData = event.payload;
38+
}
39+
40+
return (
41+
<Card>
42+
<CardHeader>
43+
<CardTitle>事件詳情</CardTitle>
44+
<CardDescription>查看事件的詳細資訊和負載資料</CardDescription>
45+
</CardHeader>
46+
<CardContent className="space-y-4">
47+
<div>
48+
<h4 className="mb-2 font-semibold">事件類型</h4>
49+
<p className="text-sm text-muted-foreground">{event.type}</p>
50+
</div>
51+
52+
<div>
53+
<h4 className="mb-2 font-semibold">觸發時間</h4>
54+
<p className="text-sm text-muted-foreground">
55+
{new Date(event.triggeredAt).toLocaleString("zh-tw")}
56+
</p>
57+
</div>
58+
59+
{payloadData && (
60+
<div>
61+
<h4 className="mb-2 font-semibold">負載資料</h4>
62+
<pre className="rounded-md bg-muted p-4 text-sm whitespace-pre-wrap">
63+
<code>
64+
{typeof payloadData === "string"
65+
? payloadData
66+
: JSON.stringify(payloadData, null, 2)}
67+
</code>
68+
</pre>
69+
</div>
70+
)}
71+
</CardContent>
72+
</Card>
73+
);
74+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"use client";
2+
3+
import { Badge } from "@/components/ui/badge";
4+
import { useSuspenseQuery } from "@apollo/client/react";
5+
import { EVENT_BY_ID_QUERY } from "./query";
6+
7+
interface HeaderProps {
8+
id: string;
9+
}
10+
11+
export function Header({ id }: HeaderProps) {
12+
const { data } = useSuspenseQuery(EVENT_BY_ID_QUERY, {
13+
variables: { id },
14+
});
15+
16+
const event = data.event;
17+
18+
return (
19+
<div className="space-y-2">
20+
<h2 className="text-2xl font-bold tracking-tight">
21+
事件 #{event.id}
22+
</h2>
23+
<div className="flex items-center gap-2">
24+
<Badge variant="outline">{event.type}</Badge>
25+
<span className="text-muted-foreground">
26+
觸發時間:{new Date(event.triggeredAt).toLocaleString("zh-tw")}
27+
</span>
28+
</div>
29+
</div>
30+
);
31+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { graphql } from "@/gql";
2+
3+
export const EVENT_BY_ID_QUERY = graphql(`
4+
query EventById($id: ID!) {
5+
event(id: $id) {
6+
id
7+
user {
8+
id
9+
name
10+
}
11+
type
12+
payload
13+
triggeredAt
14+
}
15+
}
16+
`);
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"use client";
2+
3+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
4+
import { StyledLink } from "@/components/ui/link";
5+
import { useSuspenseQuery } from "@apollo/client/react";
6+
import { EVENT_BY_ID_QUERY } from "./query";
7+
8+
interface UserCardProps {
9+
id: string;
10+
}
11+
12+
export function UserCard({ id }: UserCardProps) {
13+
const { data } = useSuspenseQuery(EVENT_BY_ID_QUERY, {
14+
variables: { id },
15+
});
16+
17+
const event = data.event;
18+
19+
return (
20+
<Card>
21+
<CardHeader>
22+
<CardTitle>使用者資訊</CardTitle>
23+
<CardDescription>查看觸發此事件的使用者</CardDescription>
24+
</CardHeader>
25+
<CardContent>
26+
<div className="mb-2">
27+
{event.user.name} (#{event.user.id})
28+
</div>
29+
<div className="text-sm text-muted-foreground">
30+
<StyledLink href={`/users/${event.user.id}`}>
31+
檢視使用者資訊 →
32+
</StyledLink>
33+
</div>
34+
</CardContent>
35+
</Card>
36+
);
37+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { SiteHeader } from "@/components/site-header";
2+
import { Suspense } from "react";
3+
import { EventDetailsCard } from "./_components/event-details-card";
4+
import { Header } from "./_components/header";
5+
import { UserCard } from "./_components/user-card";
6+
7+
export default async function EventPage({
8+
params,
9+
}: {
10+
params: Promise<{ id: string }>;
11+
}) {
12+
const { id } = await params;
13+
14+
return (
15+
<>
16+
<SiteHeader title="事件詳情" hasBackButton />
17+
<main
18+
className={`
19+
flex-1 space-y-4 p-4 pt-6
20+
md:p-8
21+
`}
22+
>
23+
<div className="flex items-center justify-between space-y-2">
24+
<Header id={id as string} />
25+
</div>
26+
<div
27+
className={`
28+
grid grid-cols-1 gap-4
29+
lg:grid-cols-2
30+
`}
31+
>
32+
<Suspense>
33+
<EventDetailsCard id={id as string} />
34+
<UserCard id={id as string} />
35+
</Suspense>
36+
</div>
37+
</main>
38+
</>
39+
);
40+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { Badge } from "@/components/ui/badge";
2+
import { Button } from "@/components/ui/button";
3+
import {
4+
DropdownMenu,
5+
DropdownMenuContent,
6+
DropdownMenuItem,
7+
DropdownMenuLabel,
8+
DropdownMenuSeparator,
9+
DropdownMenuTrigger,
10+
} from "@/components/ui/dropdown-menu";
11+
import { StyledLink } from "@/components/ui/link";
12+
import type { ColumnDef } from "@tanstack/react-table";
13+
import { MoreHorizontal } from "lucide-react";
14+
import Link from "next/link";
15+
16+
export interface Event {
17+
id: string;
18+
user: { id: string; name: string };
19+
type: string;
20+
triggeredAt: string;
21+
}
22+
23+
export const columns: ColumnDef<Event>[] = [
24+
{
25+
accessorKey: "id",
26+
header: "事件 ID",
27+
cell: ({ row }) => {
28+
const event = row.original;
29+
return (
30+
<StyledLink href={`/events/${event.id}`}>
31+
{event.id}
32+
</StyledLink>
33+
);
34+
},
35+
},
36+
{
37+
accessorKey: "user.id",
38+
header: "使用者",
39+
cell: ({ row }) => {
40+
const userId = row.original.user.id;
41+
const userName = row.original.user.name;
42+
43+
return (
44+
<StyledLink href={`/users/${userId}`}>
45+
{userName} (#{userId})
46+
</StyledLink>
47+
);
48+
},
49+
},
50+
{
51+
accessorKey: "type",
52+
header: "事件類型",
53+
cell: ({ row }) => {
54+
const type = row.original.type;
55+
return <Badge variant="outline">{type}</Badge>;
56+
},
57+
},
58+
{
59+
accessorKey: "triggeredAt",
60+
header: "觸發時間",
61+
cell: ({ row }) => {
62+
const triggeredAt = new Date(row.original.triggeredAt);
63+
return <div>{triggeredAt.toLocaleString("zh-tw")}</div>;
64+
},
65+
},
66+
{
67+
id: "actions",
68+
cell: ({ row }) => {
69+
return (
70+
<DropdownMenu>
71+
<DropdownMenuTrigger asChild>
72+
<Button variant="ghost" className="h-8 w-8 p-0">
73+
<span className="sr-only">開啟選單</span>
74+
<MoreHorizontal className="h-4 w-4" />
75+
</Button>
76+
</DropdownMenuTrigger>
77+
<DropdownMenuContent align="end">
78+
<DropdownMenuLabel>動作</DropdownMenuLabel>
79+
<DropdownMenuItem asChild>
80+
<Link href={`/events/${row.original.id}`}>檢視事件詳情</Link>
81+
</DropdownMenuItem>
82+
<DropdownMenuSeparator />
83+
<DropdownMenuItem asChild>
84+
<Link href={`/users/${row.original.user.id}`}>檢視使用者</Link>
85+
</DropdownMenuItem>
86+
</DropdownMenuContent>
87+
</DropdownMenu>
88+
);
89+
},
90+
},
91+
];
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"use client";
2+
3+
import { CursorDataTable } from "@/components/data-table/cursor";
4+
import type { Direction } from "@/components/data-table/pagination";
5+
import { useSuspenseQuery } from "@apollo/client/react";
6+
import { useState } from "react";
7+
import { columns, type Event } from "./data-table-columns";
8+
import { EVENTS_TABLE_QUERY } from "./query";
9+
10+
export function EventsDataTable() {
11+
const PAGE_SIZE = 10;
12+
const [after, setAfter] = useState<string | null>(null);
13+
const [before, setBefore] = useState<string | null>(null);
14+
const [direction, setDirection] = useState<Direction>("backward");
15+
16+
const variables = direction === "backward"
17+
? { first: PAGE_SIZE, after, last: undefined, before: undefined }
18+
: { last: PAGE_SIZE, before, first: undefined, after: undefined };
19+
20+
const { data } = useSuspenseQuery(EVENTS_TABLE_QUERY, {
21+
variables,
22+
});
23+
24+
const eventList = data?.events.edges
25+
?.map((edge) => {
26+
const event = edge?.node;
27+
if (!event) return null;
28+
return {
29+
id: event.id,
30+
user: {
31+
id: event.user.id,
32+
name: event.user.name,
33+
},
34+
type: event.type,
35+
triggeredAt: event.triggeredAt,
36+
} satisfies Event;
37+
})
38+
.filter((event) => event !== null) ?? [];
39+
40+
const pageInfo = data?.events.pageInfo;
41+
42+
const handlePageChange = (direction: Direction) => {
43+
if (!pageInfo) return;
44+
if (direction === "forward" && pageInfo.hasNextPage) {
45+
setAfter(pageInfo.endCursor ?? null);
46+
setBefore(null);
47+
setDirection("forward");
48+
} else if (direction === "backward" && pageInfo.hasPreviousPage) {
49+
setBefore(pageInfo.startCursor ?? null);
50+
setAfter(null);
51+
setDirection("backward");
52+
}
53+
};
54+
55+
return (
56+
<CursorDataTable
57+
columns={columns}
58+
data={eventList}
59+
totalCount={data?.events.totalCount ?? 0}
60+
hasNextPage={!!pageInfo?.hasNextPage}
61+
hasPreviousPage={!!pageInfo?.hasPreviousPage}
62+
onPageChange={handlePageChange}
63+
/>
64+
);
65+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { graphql } from "@/gql";
2+
3+
export const EVENTS_TABLE_QUERY = graphql(`
4+
query EventsTable(
5+
$first: Int
6+
$after: Cursor
7+
$last: Int
8+
$before: Cursor
9+
) {
10+
events(first: $first, after: $after, last: $last, before: $before, orderBy: { field: TRIGGERED_AT, direction: DESC }) {
11+
edges {
12+
node {
13+
id
14+
user {
15+
id
16+
name
17+
}
18+
type
19+
triggeredAt
20+
}
21+
}
22+
totalCount
23+
pageInfo {
24+
hasNextPage
25+
hasPreviousPage
26+
endCursor
27+
startCursor
28+
}
29+
}
30+
}
31+
`);

0 commit comments

Comments
 (0)