diff --git a/app/(admin)/(activity-management)/points/_components/create.tsx b/app/(admin)/(activity-management)/points/_components/create.tsx new file mode 100644 index 0000000..9bff7e6 --- /dev/null +++ b/app/(admin)/(activity-management)/points/_components/create.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { buttonVariants } from "@/components/ui/button"; +import { ConfirmationDialog } from "@/components/ui/confirmation-dialog"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { graphql } from "@/gql"; +import { useDialogCloseConfirmation } from "@/hooks/use-dialog-close-confirmation"; +import { useMutation } from "@apollo/client/react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { POINTS_TABLE_QUERY } from "./data-table"; +import { UpdatePointsForm } from "./update-form"; + +const CREATE_POINT_MUTATION = graphql(` + mutation CreatePoint($input: CreatePointInput!) { + createPoint(input: $input) { + id + } + } +`); + +export function CreatePointTrigger() { + const [open, setOpen] = useState(false); + const [isFormDirty, setIsFormDirty] = useState(false); + + const { + showConfirmation, + handleDialogOpenChange, + handleConfirmClose, + handleCancelClose, + } = useDialogCloseConfirmation({ + isDirty: isFormDirty, + setOpen, + onConfirmedClose: () => { + setIsFormDirty(false); + }, + }); + + const handleFormStateChange = (isDirty: boolean) => { + setIsFormDirty(isDirty); + }; + + const handleCompleted = () => { + setIsFormDirty(false); + setOpen(false); + }; + + return ( + <> + + 給予點數 + + + + {}} + onConfirm={handleConfirmClose} + onCancel={handleCancelClose} + /> + + ); +} + +function CreatePointDialogContent({ + onCompleted, + onFormStateChange, +}: { + onCompleted: () => void; + onFormStateChange: (isDirty: boolean) => void; +}) { + const [createPoint] = useMutation(CREATE_POINT_MUTATION, { + refetchQueries: [POINTS_TABLE_QUERY], + + onError(error) { + toast.error("給予點數失敗", { + description: error.message, + }); + }, + + onCompleted() { + toast.success("給予點數成功"); + onCompleted(); + }, + }); + + const onSubmit = (formData: { userID: string; points: number; description?: string }) => { + createPoint({ + variables: { + input: { + userID: formData.userID, + points: formData.points, + description: formData.description, + }, + }, + }); + }; + + return ( + + + 給予點數 + + 給一個使用者手動發放點數。 + + + + + ); +} diff --git a/app/(admin)/(activity-management)/points/_components/data-table.tsx b/app/(admin)/(activity-management)/points/_components/data-table.tsx index a034825..67447a6 100644 --- a/app/(admin)/(activity-management)/points/_components/data-table.tsx +++ b/app/(admin)/(activity-management)/points/_components/data-table.tsx @@ -2,11 +2,55 @@ import { CursorDataTable } from "@/components/data-table/cursor"; import type { Direction } from "@/components/data-table/pagination"; +import { graphql, useFragment as readFragment } from "@/gql"; import { useSuspenseQuery } from "@apollo/client/react"; import type { VariablesOf } from "@graphql-typed-document-node/core"; import { useState } from "react"; import { columns, type Point } from "./data-table-columns"; -import { POINTS_TABLE_QUERY } from "./query"; + +export const POINTS_TABLE_QUERY = graphql(` + query PointsTable( + $first: Int + $after: Cursor + $last: Int + $before: Cursor + $where: PointWhereInput + ) { + points( + first: $first + after: $after + last: $last + before: $before + where: $where + orderBy: { field: GRANTED_AT, direction: DESC } + ) { + edges { + node { + id + ...PointsTableRow + } + } + totalCount + pageInfo { + hasNextPage + endCursor + } + } + } +`); + +const POINTS_TABLE_ROW_FRAGMENT = graphql(` + fragment PointsTableRow on Point { + id + user { + id + name + } + points + description + grantedAt + } +`); export function PointsDataTable({ query }: { query?: string }) { const PAGE_SIZE = 20; @@ -27,7 +71,11 @@ export function PointsDataTable({ query }: { query?: string }) { const pointsList = data?.points.edges ?.map((edge) => { - const point = edge?.node; + const node = edge?.node; + if (!node) return null; + + const point = readFragment(POINTS_TABLE_ROW_FRAGMENT, node); + if (!point) return null; return { id: point.id, @@ -48,7 +96,7 @@ export function PointsDataTable({ query }: { query?: string }) { if (!pageInfo) return; if (direction === "forward" && pageInfo.hasNextPage) { const nextCursor = pageInfo.endCursor ?? null; - setCursors(prev => { + setCursors((prev) => { const newCursors = prev.slice(0, currentIndex + 1); newCursors.push(nextCursor); return newCursors; diff --git a/app/(admin)/(activity-management)/points/_components/query.ts b/app/(admin)/(activity-management)/points/_components/query.ts deleted file mode 100644 index f56dee5..0000000 --- a/app/(admin)/(activity-management)/points/_components/query.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { graphql } from "@/gql"; - -export const POINTS_TABLE_QUERY = graphql(` - query PointsTable( - $first: Int - $after: Cursor - $last: Int - $before: Cursor - $where: PointWhereInput - ) { - points(first: $first, after: $after, last: $last, before: $before, where: $where, 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/_components/update-form.tsx b/app/(admin)/(activity-management)/points/_components/update-form.tsx new file mode 100644 index 0000000..6fb3c2d --- /dev/null +++ b/app/(admin)/(activity-management)/points/_components/update-form.tsx @@ -0,0 +1,151 @@ +import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/spinner"; +import { Textarea } from "@/components/ui/textarea"; +import { UpdateFormBody } from "@/components/update-modal/form-body"; +import type { UpdateFormBaseProps } from "@/components/update-modal/types"; +import { graphql } from "@/gql"; +import { skipToken, useQuery } from "@apollo/client/react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useDebouncedValue } from "foxact/use-debounced-value"; +import { useForm, useWatch } from "react-hook-form"; +import { z } from "zod"; + +export const formSchema = z.object({ + userID: z.string(), + points: z.number(), + description: z.string().optional(), +}); + +export type UpdatePointsFormData = z.infer; + +export interface UpdatePointsFormProps extends Omit>, "onSubmit"> { + onSubmit: (newValues: { + userID: string; + points: number; + description?: string; + }) => void; +} + +const UPDATE_POINTS_FORM_USER_INFO_QUERY = graphql(` + query UpdatePointsFormUserInfo($id: ID!) { + user(id: $id) { + id + name + email + } + } +`); + +export function UpdatePointsForm({ + defaultValues, + onSubmit, + action, + onFormStateChange, +}: UpdatePointsFormProps) { + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + userID: "", + points: 0, + description: "", + ...defaultValues, + } as z.infer, + }); + + const userID = useWatch({ control: form.control, name: "userID" }); + const userIDDebounced = useDebouncedValue(userID, 200); + + const { data: userInfoData, loading } = useQuery( + UPDATE_POINTS_FORM_USER_INFO_QUERY, + userIDDebounced + ? { + variables: { + id: userIDDebounced, + }, + errorPolicy: "ignore", + } + : skipToken, + ); + + const handleSubmit = (data: z.infer) => { + onSubmit({ + userID: data.userID, + points: data.points, + description: data.description, + }); + }; + + return ( + + ( + + 使用者 ID + + + + +
+ 選擇要發放點數的使用者。可以到使用者管理頁面確認對應代號。 +
+ +
+ {loading ? : null} + {userInfoData?.user + ? `您正要發放給:${userInfoData.user.name} (${userInfoData.user.email})` + : "您輸入的使用者 ID 不存在。"} +
+
+ +
+ )} + /> + + ( + + 點數 + + + + 要發放給使用者的點數數量。 + + + )} + /> + + ( + + 備註(可選) + +