added Create User

This commit is contained in:
2026-01-23 09:24:05 +07:00
parent a8745327d6
commit 51c26f3704
15 changed files with 500 additions and 46 deletions

View File

@@ -1,4 +1,5 @@
import { m } from '@/paraglide/messages';
import { LOG_ACTION } from '@/types/enum';
import { Badge } from '../ui/badge';
export type UserActionType = {
@@ -9,9 +10,10 @@ export type UserActionType = {
unban: string;
sign_in: string;
sign_out: string;
change_password: string;
};
const ActionBadge = ({ action }: { action: keyof UserActionType }) => {
const ActionBadge = ({ action }: { action: LOG_ACTION }) => {
const USER_ACTION = Object.freeze(
new Proxy(
{
@@ -22,6 +24,7 @@ const ActionBadge = ({ action }: { action: keyof UserActionType }) => {
sign_out: 'bg-yellow-400',
ban: 'bg-rose-400',
unban: 'bg-emerald-400',
change_password: 'bg-red-600',
} as UserActionType,
{
get: function (target: UserActionType, name: string | symbol) {

View File

@@ -3,7 +3,8 @@ import { formatters } from '@/utils/formatters';
import { ColumnDef } from '@tanstack/react-table';
import { Badge } from '../ui/badge';
import ActionBadge, { UserActionType } from './action-badge';
import { LOG_ACTION } from '@/types/enum';
import ActionBadge from './action-badge';
import ViewDetail from './view-detail-dialog';
export const logColumns: ColumnDef<AuditWithUser>[] = [
@@ -35,14 +36,12 @@ export const logColumns: ColumnDef<AuditWithUser>[] = [
thClass: 'w-1/6',
},
cell: ({ row }) => {
return (
<ActionBadge action={row.original.action as keyof UserActionType} />
);
return <ActionBadge action={row.original.action as LOG_ACTION} />;
},
},
{
accessorKey: 'createdAt',
header: m.logs_page_ui_table_header_action(),
header: m.logs_page_ui_table_header_create_at(),
meta: {
thClass: 'w-2/6',
},

View File

@@ -1,8 +1,10 @@
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
import usePreventAutoFocus from '@/hooks/use-prevent-auto-focus';
import { m } from '@/paraglide/messages';
import { LOG_ACTION } from '@/types/enum';
import { formatters } from '@/utils/formatters';
import { jsonSupport } from '@/utils/helper';
import { EyeIcon } from '@phosphor-icons/react';
import { CheckIcon, CopyIcon, EyeIcon } from '@phosphor-icons/react';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
import {
@@ -15,7 +17,7 @@ import {
} from '../ui/dialog';
import { Label } from '../ui/label';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import ActionBadge, { UserActionType } from './action-badge';
import ActionBadge from './action-badge';
type ViewDetailProps = {
data: AuditWithUser;
@@ -23,6 +25,7 @@ type ViewDetailProps = {
const ViewDetail = ({ data }: ViewDetailProps) => {
const prevent = usePreventAutoFocus();
const { isCopied, copyToClipboard } = useCopyToClipboard();
return (
<Dialog>
@@ -80,7 +83,7 @@ const ViewDetail = ({ data }: ViewDetailProps) => {
<span className="font-bold">
{m.logs_page_ui_table_header_action()}:
</span>
<ActionBadge action={data.action as keyof UserActionType} />
<ActionBadge action={data.action as LOG_ACTION} />
</div>
{data.oldValue && (
<div className="flex flex-col gap-2">
@@ -92,13 +95,32 @@ const ViewDetail = ({ data }: ViewDetailProps) => {
</pre>
</div>
)}
<div className="flex flex-col gap-2">
{data.newValue && (
<div className="flex flex-col gap-2">
<span className="font-bold">
{m.logs_page_ui_table_header_new_value()}:
</span>
<pre className="whitespace-pre-wrap wrap-break-word">
{data.newValue ? jsonSupport(data.newValue) : ''}
</pre>
</div>
)}
<div className="flex items-center gap-2">
<span className="font-bold">
{m.logs_page_ui_table_header_new_value()}:
{m.logs_page_ui_table_header_record_id()}:
</span>
<pre className="whitespace-pre-wrap wrap-break-word">
{data.newValue ? jsonSupport(data.newValue) : ''}
</pre>
<div className="flex gap-1.5 items-center">
{data.recordId}
<Button
type="button"
variant="outline"
size="icon-xs"
className="rounded-full cursor-pointer"
onClick={() => copyToClipboard(data.recordId)}
>
{isCopied ? <CheckIcon /> : <CopyIcon />}
</Button>
</div>
</div>
<div className="flex items-center gap-2">
<span className="font-bold">

View File

@@ -0,0 +1,198 @@
import { m } from '@/paraglide/messages';
import { usersQueries } from '@/service/queries';
import { createUser } from '@/service/user.api';
import { RoleEnum, userCreateSchema } from '@/service/user.schema';
import { ReturnError } from '@/types/common';
import { useForm } from '@tanstack/react-form';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Button } from '../ui/button';
import { DialogClose, DialogFooter } from '../ui/dialog';
import { Field, FieldError, FieldGroup, FieldLabel } from '../ui/field';
import { Input } from '../ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../ui/select';
type FormProps = {
onSubmit: (open: boolean) => void;
};
const AdminCreateUserForm = ({ onSubmit }: FormProps) => {
const queryClient = useQueryClient();
const { mutate: createUserMutation } = useMutation({
mutationFn: createUser,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [...usersQueries.all, 'list'],
});
onSubmit(false);
toast.success(m.users_page_message_created_user_success(), {
richColors: true,
});
},
onError: (error: ReturnError) => {
console.error(error);
toast.error(m.backend_message({ code: error.code }), {
richColors: true,
});
},
});
const form = useForm({
defaultValues: {
email: '',
password: '',
name: '',
role: '',
},
validators: {
onChange: userCreateSchema,
onSubmit: userCreateSchema,
},
onSubmit: ({ value }) => {
createUserMutation({ data: userCreateSchema.parse(value) });
},
});
return (
<form
id="admin-create-user-form"
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
<FieldGroup>
<form.Field
name="email"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid} className="col-span-2">
<FieldLabel htmlFor={field.name}>
{m.login_page_form_email()}:
</FieldLabel>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
type="email"
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
/>
<form.Field
name="password"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>
{m.login_page_form_password()}
</FieldLabel>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
type="password"
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
/>
<form.Field
name="name"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>
{m.profile_form_name()}
</FieldLabel>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
type="text"
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
/>
<form.Field
name="role"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>
{m.profile_form_role()}
</FieldLabel>
<Select
name={field.name}
value={field.state.value}
onValueChange={(value) =>
field.handleChange(RoleEnum.parse(value))
}
>
<SelectTrigger aria-invalid={isInvalid}>
<SelectValue
placeholder={m.users_page_ui_select_placeholder_role()}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">
{m.role_tags({ role: 'admin' })}
</SelectItem>
<SelectItem value="user">
{m.role_tags({ role: 'user' })}
</SelectItem>
</SelectContent>
</Select>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
/>
<Field>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline" type="button">
{m.ui_cancel_btn()}
</Button>
</DialogClose>
<Button type="submit" variant="destructive">
{m.ui_signup_btn()}
</Button>
</DialogFooter>
</Field>
</FieldGroup>
</form>
);
};
export default AdminCreateUserForm;

View File

@@ -5,7 +5,7 @@ import * as React from 'react';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-xs/relaxed font-medium focus-visible:ring-[2px] aria-invalid:ring-[2px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 disabled:data-[active=true]:opacity-100 disabled:data-[active=true]:bg-teal-400 disabled:data-[active=true]:border-teal-400 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
"focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-xs/relaxed font-medium focus-visible:ring-[2px] aria-invalid:ring-[2px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 disabled:data-[active=true]:opacity-100 disabled:data-[active=true]:bg-primary disabled:data-[active=true]:border-primary disabled:data-[active=true]:text-white [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
{
variants: {
variant: {

View File

@@ -0,0 +1,48 @@
import usePreventAutoFocus from '@/hooks/use-prevent-auto-focus';
import { m } from '@/paraglide/messages';
import { PlusIcon } from '@phosphor-icons/react';
import { useState } from 'react';
import AdminCreateUserForm from '../form/admin-create-user-form';
import { Button } from '../ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '../ui/dialog';
const AddNewUserButton = () => {
const [_open, _setOpen] = useState(false);
const prevent = usePreventAutoFocus();
return (
<Dialog>
<DialogTrigger>
<Button type="button" variant="default">
<PlusIcon />
{m.nav_add_new()}
</Button>
</DialogTrigger>
<DialogContent
className="max-w-80 xl:max-w-xl"
{...prevent}
onPointerDownOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-3 text-lg font-bold text-red-600">
<PlusIcon size={16} />
{m.nav_add_new()}
</DialogTitle>
<DialogDescription className="sr-only">
{m.nav_add_new()}
</DialogDescription>
</DialogHeader>
<AdminCreateUserForm />
</DialogContent>
</Dialog>
);
};
export default AddNewUserButton;