Skip to content

Commit becaf7a

Browse files
committed
feat(points): allow granting points
1 parent 7c1013d commit becaf7a

File tree

6 files changed

+337
-21
lines changed

6 files changed

+337
-21
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: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
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 { graphql, useFragment as readFragment } from "@/gql";
1010

11-
const POINTS_TABLE_QUERY = graphql(`
11+
export const POINTS_TABLE_QUERY = graphql(`
1212
query PointsTable(
1313
$first: Int
1414
$after: Cursor
@@ -69,27 +69,26 @@ export function PointsDataTable({ query }: { query?: string }) {
6969
variables,
7070
});
7171

72-
const pointsList =
73-
data?.points.edges
74-
?.map((edge) => {
75-
const node = edge?.node;
76-
if (!node) return null;
72+
const pointsList = data?.points.edges
73+
?.map((edge) => {
74+
const node = edge?.node;
75+
if (!node) return null;
7776

78-
const point = readFragment(POINTS_TABLE_ROW_FRAGEMENT, node);
77+
const point = readFragment(POINTS_TABLE_ROW_FRAGEMENT, node);
7978

80-
if (!point) return null;
81-
return {
82-
id: point.id,
83-
user: {
84-
id: point.user.id,
85-
name: point.user.name,
86-
},
87-
points: point.points,
88-
description: point.description ?? "",
89-
grantedAt: point.grantedAt,
90-
} satisfies Point;
91-
})
92-
.filter((point) => point !== null) ?? [];
79+
if (!point) return null;
80+
return {
81+
id: point.id,
82+
user: {
83+
id: point.user.id,
84+
name: point.user.name,
85+
},
86+
points: point.points,
87+
description: point.description ?? "",
88+
grantedAt: point.grantedAt,
89+
} satisfies Point;
90+
})
91+
.filter((point) => point !== null) ?? [];
9392

9493
const pageInfo = data?.points.pageInfo;
9594

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
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 as number,
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+
選擇要發放點數的使用者。可以到使用者管理頁面確認對應代號。<br />
97+
{loading ? <Spinner className="mr-4 inline-block size-4" /> : null}
98+
{userInfoData?.user
99+
? `您正要發放給:${userInfoData.user.name} (${userInfoData.user.email})`
100+
: "您輸入的使用者 ID 不存在。"}
101+
</FormDescription>
102+
<FormMessage />
103+
</FormItem>
104+
)}
105+
/>
106+
107+
<FormField
108+
control={form.control}
109+
name="points"
110+
render={() => (
111+
<FormItem>
112+
<FormLabel>點數</FormLabel>
113+
<FormControl>
114+
<Input
115+
{...form.register("points", { valueAsNumber: true })}
116+
type="number"
117+
placeholder="請輸入要發放的點數"
118+
/>
119+
</FormControl>
120+
<FormDescription>要發放給使用者的點數數量。</FormDescription>
121+
<FormMessage />
122+
</FormItem>
123+
)}
124+
/>
125+
126+
<FormField
127+
control={form.control}
128+
name="description"
129+
render={({ field }) => (
130+
<FormItem>
131+
<FormLabel>備註(可選)</FormLabel>
132+
<FormControl>
133+
<Textarea
134+
{...field}
135+
placeholder="請輸入發放點數的原因或備註"
136+
className="min-h-[80px]"
137+
/>
138+
</FormControl>
139+
<FormDescription>發放點數的原因說明。</FormDescription>
140+
<FormMessage />
141+
</FormItem>
142+
)}
143+
/>
144+
</UpdateFormBody>
145+
);
146+
}

app/(admin)/(activity-management)/points/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { SiteHeader } from "@/components/site-header";
22
import type { Metadata } from "next";
3+
import { CreatePointTrigger } from "./_components/create";
34
import FilterableDataTable from "./_components/filterable-data-table";
45

56
export const metadata: Metadata = {
@@ -21,6 +22,7 @@ export default function Page() {
2122
<h2 className="text-2xl font-bold tracking-tight">積分管理</h2>
2223
<p className="text-muted-foreground">查看和管理使用者的積分獲得記錄。</p>
2324
</div>
25+
<CreatePointTrigger />
2426
</div>
2527
<div>
2628
<FilterableDataTable />

0 commit comments

Comments
 (0)