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
@@ -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 (
<Card>
<CardHeader>
<CardTitle>事件詳情</CardTitle>
<CardDescription>查看事件的詳細資訊和負載資料</CardDescription>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">找不到事件記錄</p>
</CardContent>
</Card>
);
}

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 (
<Card>
<CardHeader>
<CardTitle>事件詳情</CardTitle>
<CardDescription>查看事件的詳細資訊和負載資料</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h4 className="mb-2 font-semibold">事件類型</h4>
<p className="text-sm text-muted-foreground">{event.type}</p>
</div>

<div>
<h4 className="mb-2 font-semibold">觸發時間</h4>
<p className="text-sm text-muted-foreground">
{new Date(event.triggeredAt).toLocaleString("zh-tw")}
</p>
</div>

{payloadData && (
<div>
<h4 className="mb-2 font-semibold">負載資料</h4>
<pre className="rounded-md bg-muted p-4 text-sm whitespace-pre-wrap">
<code>
{typeof payloadData === "string"
? payloadData
: JSON.stringify(payloadData, null, 2)}
</code>
</pre>
</div>
)}
</CardContent>
</Card>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-2">
<h2 className="text-2xl font-bold tracking-tight">
事件 #{event.id}
</h2>
<div className="flex items-center gap-2">
<Badge variant="outline">{event.type}</Badge>
<span className="text-muted-foreground">
觸發時間:{new Date(event.triggeredAt).toLocaleString("zh-tw")}
</span>
</div>
</div>
);
}
16 changes: 16 additions & 0 deletions app/(admin)/(activity-management)/events/[id]/_components/query.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
`);
Original file line number Diff line number Diff line change
@@ -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 (
<Card>
<CardHeader>
<CardTitle>使用者資訊</CardTitle>
<CardDescription>查看觸發此事件的使用者</CardDescription>
</CardHeader>
<CardContent>
<div className="mb-2">
{event.user.name} (#{event.user.id})
</div>
<div className="text-sm text-muted-foreground">
<StyledLink href={`/users/${event.user.id}`}>
檢視使用者資訊 →
</StyledLink>
</div>
</CardContent>
</Card>
);
}
40 changes: 40 additions & 0 deletions app/(admin)/(activity-management)/events/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<SiteHeader title="事件詳情" hasBackButton />
<main
className={`
flex-1 space-y-4 p-4 pt-6
md:p-8
`}
>
<div className="flex items-center justify-between space-y-2">
<Header id={id as string} />
</div>
<div
className={`
grid grid-cols-1 gap-4
lg:grid-cols-2
`}
>
<Suspense>
<EventDetailsCard id={id as string} />
<UserCard id={id as string} />
</Suspense>
</div>
</main>
</>
);
}
Original file line number Diff line number Diff line change
@@ -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<Event>[] = [
{
accessorKey: "id",
header: "事件 ID",
cell: ({ row }) => {
const event = row.original;
return (
<StyledLink href={`/events/${event.id}`}>
{event.id}
</StyledLink>
);
},
},
{
accessorKey: "user.id",
header: "使用者",
cell: ({ row }) => {
const userId = row.original.user.id;
const userName = row.original.user.name;

return (
<StyledLink href={`/users/${userId}`}>
{userName} (#{userId})
</StyledLink>
);
},
},
{
accessorKey: "type",
header: "事件類型",
cell: ({ row }) => {
const type = row.original.type;
return <Badge variant="outline">{type}</Badge>;
},
},
{
accessorKey: "triggeredAt",
header: "觸發時間",
cell: ({ row }) => {
const triggeredAt = new Date(row.original.triggeredAt);
return <div>{triggeredAt.toLocaleString("zh-tw")}</div>;
},
},
{
id: "actions",
cell: ({ row }) => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">開啟選單</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>動作</DropdownMenuLabel>
<DropdownMenuItem asChild>
<Link href={`/events/${row.original.id}`}>檢視事件詳情</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/users/${row.original.user.id}`}>檢視使用者</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);
const [before, setBefore] = useState<string | null>(null);
const [direction, setDirection] = useState<Direction>("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 (
<CursorDataTable
columns={columns}
data={eventList}
totalCount={data?.events.totalCount ?? 0}
hasNextPage={!!pageInfo?.hasNextPage}
hasPreviousPage={!!pageInfo?.hasPreviousPage}
onPageChange={handlePageChange}
/>
);
}
31 changes: 31 additions & 0 deletions app/(admin)/(activity-management)/events/_components/query.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
}
`);
Loading