Skip to content

Commit 0883829

Browse files
authored
Merge pull request #28 from database-playground/pan93412/dbp-125-可以在-admin-頁面輕鬆加點數
DBP-125: 可以在 admin 頁面輕鬆加點數
2 parents 7c6bd33 + 12dc175 commit 0883829

File tree

9 files changed

+424
-44
lines changed

9 files changed

+424
-44
lines changed
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"use client";
2+
3+
import { buttonVariants } from "@/components/ui/button";
4+
import { ConfirmationDialog } from "@/components/ui/confirmation-dialog";
5+
import {
6+
Dialog,
7+
DialogContent,
8+
DialogDescription,
9+
DialogHeader,
10+
DialogTitle,
11+
DialogTrigger,
12+
} from "@/components/ui/dialog";
13+
import { graphql } from "@/gql";
14+
import { useDialogCloseConfirmation } from "@/hooks/use-dialog-close-confirmation";
15+
import { useMutation } from "@apollo/client/react";
16+
import { useState } from "react";
17+
import { toast } from "sonner";
18+
import { POINTS_TABLE_QUERY } from "./data-table";
19+
import { UpdatePointsForm } from "./update-form";
20+
21+
const CREATE_POINT_MUTATION = graphql(`
22+
mutation CreatePoint($input: CreatePointInput!) {
23+
createPoint(input: $input) {
24+
id
25+
}
26+
}
27+
`);
28+
29+
export function CreatePointTrigger() {
30+
const [open, setOpen] = useState(false);
31+
const [isFormDirty, setIsFormDirty] = useState(false);
32+
33+
const {
34+
showConfirmation,
35+
handleDialogOpenChange,
36+
handleConfirmClose,
37+
handleCancelClose,
38+
} = useDialogCloseConfirmation({
39+
isDirty: isFormDirty,
40+
setOpen,
41+
onConfirmedClose: () => {
42+
setIsFormDirty(false);
43+
},
44+
});
45+
46+
const handleFormStateChange = (isDirty: boolean) => {
47+
setIsFormDirty(isDirty);
48+
};
49+
50+
const handleCompleted = () => {
51+
setIsFormDirty(false);
52+
setOpen(false);
53+
};
54+
55+
return (
56+
<>
57+
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
58+
<DialogTrigger className={buttonVariants()}>給予點數</DialogTrigger>
59+
<CreatePointDialogContent
60+
onCompleted={handleCompleted}
61+
onFormStateChange={handleFormStateChange}
62+
/>
63+
</Dialog>
64+
65+
<ConfirmationDialog
66+
open={showConfirmation}
67+
onOpenChange={() => {}}
68+
onConfirm={handleConfirmClose}
69+
onCancel={handleCancelClose}
70+
/>
71+
</>
72+
);
73+
}
74+
75+
function CreatePointDialogContent({
76+
onCompleted,
77+
onFormStateChange,
78+
}: {
79+
onCompleted: () => void;
80+
onFormStateChange: (isDirty: boolean) => void;
81+
}) {
82+
const [createPoint] = useMutation(CREATE_POINT_MUTATION, {
83+
refetchQueries: [POINTS_TABLE_QUERY],
84+
85+
onError(error) {
86+
toast.error("給予點數失敗", {
87+
description: error.message,
88+
});
89+
},
90+
91+
onCompleted() {
92+
toast.success("給予點數成功");
93+
onCompleted();
94+
},
95+
});
96+
97+
const onSubmit = (formData: { userID: string; points: number; description?: string }) => {
98+
createPoint({
99+
variables: {
100+
input: {
101+
userID: formData.userID,
102+
points: formData.points,
103+
description: formData.description,
104+
},
105+
},
106+
});
107+
};
108+
109+
return (
110+
<DialogContent className="max-h-[85vh] max-w-3xl overflow-y-auto">
111+
<DialogHeader>
112+
<DialogTitle>給予點數</DialogTitle>
113+
<DialogDescription>
114+
給一個使用者手動發放點數。
115+
</DialogDescription>
116+
</DialogHeader>
117+
<UpdatePointsForm
118+
defaultValues={{
119+
userID: "",
120+
points: 0,
121+
description: "",
122+
}}
123+
onSubmit={onSubmit}
124+
action="create"
125+
onFormStateChange={onFormStateChange}
126+
/>
127+
</DialogContent>
128+
);
129+
}

app/(admin)/(activity-management)/points/_components/data-table.tsx

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,55 @@
22

33
import { CursorDataTable } from "@/components/data-table/cursor";
44
import type { Direction } from "@/components/data-table/pagination";
5+
import { graphql, useFragment as readFragment } from "@/gql";
56
import { useSuspenseQuery } from "@apollo/client/react";
67
import type { VariablesOf } from "@graphql-typed-document-node/core";
78
import { useState } from "react";
89
import { columns, type Point } from "./data-table-columns";
9-
import { POINTS_TABLE_QUERY } from "./query";
10+
11+
export const POINTS_TABLE_QUERY = graphql(`
12+
query PointsTable(
13+
$first: Int
14+
$after: Cursor
15+
$last: Int
16+
$before: Cursor
17+
$where: PointWhereInput
18+
) {
19+
points(
20+
first: $first
21+
after: $after
22+
last: $last
23+
before: $before
24+
where: $where
25+
orderBy: { field: GRANTED_AT, direction: DESC }
26+
) {
27+
edges {
28+
node {
29+
id
30+
...PointsTableRow
31+
}
32+
}
33+
totalCount
34+
pageInfo {
35+
hasNextPage
36+
endCursor
37+
}
38+
}
39+
}
40+
`);
41+
42+
const POINTS_TABLE_ROW_FRAGMENT = graphql(`
43+
fragment PointsTableRow on Point {
44+
id
45+
user {
46+
id
47+
name
48+
}
49+
points
50+
description
51+
grantedAt
52+
}
53+
`);
1054

1155
export function PointsDataTable({ query }: { query?: string }) {
1256
const PAGE_SIZE = 20;
@@ -27,7 +71,11 @@ export function PointsDataTable({ query }: { query?: string }) {
2771

2872
const pointsList = data?.points.edges
2973
?.map((edge) => {
30-
const point = edge?.node;
74+
const node = edge?.node;
75+
if (!node) return null;
76+
77+
const point = readFragment(POINTS_TABLE_ROW_FRAGMENT, node);
78+
3179
if (!point) return null;
3280
return {
3381
id: point.id,
@@ -48,7 +96,7 @@ export function PointsDataTable({ query }: { query?: string }) {
4896
if (!pageInfo) return;
4997
if (direction === "forward" && pageInfo.hasNextPage) {
5098
const nextCursor = pageInfo.endCursor ?? null;
51-
setCursors(prev => {
99+
setCursors((prev) => {
52100
const newCursors = prev.slice(0, currentIndex + 1);
53101
newCursors.push(nextCursor);
54102
return newCursors;

app/(admin)/(activity-management)/points/_components/query.ts

Lines changed: 0 additions & 33 deletions
This file was deleted.
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
2+
import { Input } from "@/components/ui/input";
3+
import { Spinner } from "@/components/ui/spinner";
4+
import { Textarea } from "@/components/ui/textarea";
5+
import { UpdateFormBody } from "@/components/update-modal/form-body";
6+
import type { UpdateFormBaseProps } from "@/components/update-modal/types";
7+
import { graphql } from "@/gql";
8+
import { skipToken, useQuery } from "@apollo/client/react";
9+
import { zodResolver } from "@hookform/resolvers/zod";
10+
import { useDebouncedValue } from "foxact/use-debounced-value";
11+
import { useForm, useWatch } from "react-hook-form";
12+
import { z } from "zod";
13+
14+
export const formSchema = z.object({
15+
userID: z.string(),
16+
points: z.number(),
17+
description: z.string().optional(),
18+
});
19+
20+
export type UpdatePointsFormData = z.infer<typeof formSchema>;
21+
22+
export interface UpdatePointsFormProps extends Omit<UpdateFormBaseProps<z.infer<typeof formSchema>>, "onSubmit"> {
23+
onSubmit: (newValues: {
24+
userID: string;
25+
points: number;
26+
description?: string;
27+
}) => void;
28+
}
29+
30+
const UPDATE_POINTS_FORM_USER_INFO_QUERY = graphql(`
31+
query UpdatePointsFormUserInfo($id: ID!) {
32+
user(id: $id) {
33+
id
34+
name
35+
email
36+
}
37+
}
38+
`);
39+
40+
export function UpdatePointsForm({
41+
defaultValues,
42+
onSubmit,
43+
action,
44+
onFormStateChange,
45+
}: UpdatePointsFormProps) {
46+
const form = useForm<z.infer<typeof formSchema>>({
47+
resolver: zodResolver(formSchema),
48+
defaultValues: {
49+
userID: "",
50+
points: 0,
51+
description: "",
52+
...defaultValues,
53+
} as z.infer<typeof formSchema>,
54+
});
55+
56+
const userID = useWatch({ control: form.control, name: "userID" });
57+
const userIDDebounced = useDebouncedValue(userID, 200);
58+
59+
const { data: userInfoData, loading } = useQuery(
60+
UPDATE_POINTS_FORM_USER_INFO_QUERY,
61+
userIDDebounced
62+
? {
63+
variables: {
64+
id: userIDDebounced,
65+
},
66+
errorPolicy: "ignore",
67+
}
68+
: skipToken,
69+
);
70+
71+
const handleSubmit = (data: z.infer<typeof formSchema>) => {
72+
onSubmit({
73+
userID: data.userID,
74+
points: data.points,
75+
description: data.description,
76+
});
77+
};
78+
79+
return (
80+
<UpdateFormBody
81+
form={form}
82+
onSubmit={handleSubmit}
83+
action={action}
84+
onFormStateChange={onFormStateChange}
85+
>
86+
<FormField
87+
control={form.control}
88+
name="userID"
89+
render={({ field }) => (
90+
<FormItem>
91+
<FormLabel>使用者 ID</FormLabel>
92+
<FormControl>
93+
<Input {...field} placeholder="請輸入使用者 ID" />
94+
</FormControl>
95+
<FormDescription>
96+
<div>
97+
選擇要發放點數的使用者。可以到使用者管理頁面確認對應代號。
98+
</div>
99+
100+
<div className="flex items-center gap-4">
101+
{loading ? <Spinner className="mr-4 inline-block size-4" /> : null}
102+
{userInfoData?.user
103+
? `您正要發放給:${userInfoData.user.name} (${userInfoData.user.email})`
104+
: "您輸入的使用者 ID 不存在。"}
105+
</div>
106+
</FormDescription>
107+
<FormMessage />
108+
</FormItem>
109+
)}
110+
/>
111+
112+
<FormField
113+
control={form.control}
114+
name="points"
115+
render={() => (
116+
<FormItem>
117+
<FormLabel>點數</FormLabel>
118+
<FormControl>
119+
<Input
120+
{...form.register("points", { valueAsNumber: true })}
121+
type="number"
122+
placeholder="請輸入要發放的點數"
123+
/>
124+
</FormControl>
125+
<FormDescription>要發放給使用者的點數數量。</FormDescription>
126+
<FormMessage />
127+
</FormItem>
128+
)}
129+
/>
130+
131+
<FormField
132+
control={form.control}
133+
name="description"
134+
render={({ field }) => (
135+
<FormItem>
136+
<FormLabel>備註(可選)</FormLabel>
137+
<FormControl>
138+
<Textarea
139+
{...field}
140+
placeholder="請輸入發放點數的原因或備註"
141+
className="min-h-[80px]"
142+
/>
143+
</FormControl>
144+
<FormDescription>發放點數的原因說明。</FormDescription>
145+
<FormMessage />
146+
</FormItem>
147+
)}
148+
/>
149+
</UpdateFormBody>
150+
);
151+
}

0 commit comments

Comments
 (0)