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
129 changes: 129 additions & 0 deletions app/(admin)/(activity-management)/points/_components/create.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
<DialogTrigger className={buttonVariants()}>給予點數</DialogTrigger>
<CreatePointDialogContent
onCompleted={handleCompleted}
onFormStateChange={handleFormStateChange}
/>
</Dialog>

<ConfirmationDialog
open={showConfirmation}
onOpenChange={() => {}}
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 (
<DialogContent className="max-h-[85vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle>給予點數</DialogTitle>
<DialogDescription>
給一個使用者手動發放點數。
</DialogDescription>
</DialogHeader>
<UpdatePointsForm
defaultValues={{
userID: "",
points: 0,
description: "",
}}
onSubmit={onSubmit}
action="create"
onFormStateChange={onFormStateChange}
/>
</DialogContent>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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;
Expand Down
33 changes: 0 additions & 33 deletions app/(admin)/(activity-management)/points/_components/query.ts

This file was deleted.

151 changes: 151 additions & 0 deletions app/(admin)/(activity-management)/points/_components/update-form.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof formSchema>;

export interface UpdatePointsFormProps extends Omit<UpdateFormBaseProps<z.infer<typeof formSchema>>, "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<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
userID: "",
points: 0,
description: "",
...defaultValues,
} as z.infer<typeof formSchema>,
});

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<typeof formSchema>) => {
onSubmit({
userID: data.userID,
points: data.points,
description: data.description,
});
};

return (
<UpdateFormBody
form={form}
onSubmit={handleSubmit}
action={action}
onFormStateChange={onFormStateChange}
>
<FormField
control={form.control}
name="userID"
render={({ field }) => (
<FormItem>
<FormLabel>使用者 ID</FormLabel>
<FormControl>
<Input {...field} placeholder="請輸入使用者 ID" />
</FormControl>
<FormDescription>
<div>
選擇要發放點數的使用者。可以到使用者管理頁面確認對應代號。
</div>

<div className="flex items-center gap-4">
{loading ? <Spinner className="mr-4 inline-block size-4" /> : null}
{userInfoData?.user
? `您正要發放給:${userInfoData.user.name} (${userInfoData.user.email})`
: "您輸入的使用者 ID 不存在。"}
</div>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>

<FormField
control={form.control}
name="points"
render={() => (
<FormItem>
<FormLabel>點數</FormLabel>
<FormControl>
<Input
{...form.register("points", { valueAsNumber: true })}
type="number"
placeholder="請輸入要發放的點數"
/>
</FormControl>
<FormDescription>要發放給使用者的點數數量。</FormDescription>
<FormMessage />
</FormItem>
)}
/>

<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>備註(可選)</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder="請輸入發放點數的原因或備註"
className="min-h-[80px]"
/>
</FormControl>
<FormDescription>發放點數的原因說明。</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</UpdateFormBody>
);
}
Loading