feat: implement staff management features including staff creation and dashboard layout

This commit is contained in:
Ardeman 2025-03-25 00:56:14 +08:00
parent 724a14d741
commit de1802c597
7 changed files with 367 additions and 4 deletions

View File

@ -0,0 +1,28 @@
import { z } from 'zod'
import { HttpServer, type THttpServer } from '~/libs/http-server'
import type { TStaffSchema } from '~/pages/form-staff'
const createStaffResponseSchema = z.object({
data: z.object({
Message: z.string(),
}),
})
type TParameter = {
payload: TStaffSchema
} & THttpServer
export const createStaffsRequest = async (parameters: TParameter) => {
const { payload, ...restParameters } = parameters
try {
const { data } = await HttpServer(restParameters).post(
'/api/staff/register',
payload,
)
return createStaffResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.reject(error)
}
}

View File

@ -21,7 +21,7 @@ export const profileSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Email is invalid'),
profile_picture: z.string().url({
message: 'URL must be valid',
message: 'Gambar profil must be valid',
}),
})
@ -93,7 +93,7 @@ export const DialogProfile = () => {
<Input
name="name"
id="name"
label="Name"
label="Nama"
placeholder="Enter your name"
/>
<Input
@ -105,8 +105,8 @@ export const DialogProfile = () => {
<InputFile
name="profile_picture"
id="profile_picture"
label="Profile Picture"
placeholder="Upload your profile picture"
label="Gambar Profil"
placeholder="Unggah gambar profil Anda"
category="profile_picture"
/>
<Button

View File

@ -0,0 +1,89 @@
import { PlusIcon } from '@heroicons/react/24/solid'
import { Link } from 'react-router'
import { Button } from '~/components/ui/button'
import { TitleDashboard } from '~/components/ui/title-dashboard'
export const StaffsPage = () => {
// const loaderData = useRouteLoaderData<typeof loader>(
// 'routes/_admin.lg-admin._dashboard.staffs._index',
// )
// DataTable.use(DT)
// const dataTable =
// loaderData?.usersData?.sort(
// (a, b) =>
// new Date(b.subscribe.start_date).getTime() -
// new Date(a.subscribe.start_date).getTime(),
// ) || []
// const dataColumns: ConfigColumns[] = [
// {
// title: 'No',
// render: (
// _data: unknown,
// _type: unknown,
// _row: unknown,
// meta: { row: number },
// ) => {
// return meta.row + 1
// },
// },
// {
// title: 'Tanggal Daftar',
// data: 'created_at',
// },
// {
// title: 'User',
// },
// {
// title: 'Phone',
// data: 'phone',
// },
// {
// title: 'Status',
// data: 'subscribe.subscribe_plan.name',
// },
// ]
// const dataSlot: DataTableSlots = {
// 1: (value: string) => formatDate(value),
// 2: (_value: unknown, _type: unknown, data: TUserResponse) => (
// <div>
// <div>{data.email}</div>
// <div className="text-sm text-[#7C7C7C]">ID: {data.id.slice(0, 8)}</div>
// </div>
// ),
// 3: (value: string) => <span>{value}</span>,
// 4: (value: TColorBadge, _type: unknown, data: TUserResponse) => (
// <span
// className={`rounded-lg px-2 text-sm ${getStatusBadge(data.subscribe.status as TColorBadge)}`}
// >
// {value}
// </span>
// ),
// }
return (
<div className="relative">
<TitleDashboard title="Staffs" />
<div className="mb-8 flex items-end justify-between gap-5">
<div className="flex-1">{/* TODO: Filter */}</div>
<Button
as={Link}
to="/lg-admin/staffs/create"
size="lg"
className="text-md h-[42px] px-4"
>
<PlusIcon className="size-8" /> Buat Staff
</Button>
</div>
{/* <UiTable
data={dataTable || []}
columns={dataColumns}
slots={dataSlot}
title="Daftar Users"
/> */}
</div>
)
}

View File

@ -0,0 +1,134 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useEffect } from 'react'
import toast from 'react-hot-toast'
import { useFetcher, useNavigate } from 'react-router'
import { RemixFormProvider, useRemixForm } from 'remix-hook-form'
import { z } from 'zod'
import { Button } from '~/components/ui/button'
import { Input } from '~/components/ui/input'
import { InputFile } from '~/components/ui/input-file'
import { TitleDashboard } from '~/components/ui/title-dashboard'
export const staffSchema = z
.object({
profile_picture: z
.string()
.url({
message: 'Profile picture must be a valid URL',
})
.or(z.literal('')),
name: z.string().min(1, {
message: 'Nama staff is required',
}),
password: z.string().min(6, 'Kata sandi minimal 6 karakter'),
rePassword: z.string().min(6, 'Kata sandi minimal 6 karakter'),
email: z.string().email('Email tidak valid'),
})
.refine((field) => field.password === field.rePassword, {
message: 'Kata sandi tidak sama',
path: ['rePassword'],
})
export type TStaffSchema = z.infer<typeof staffSchema>
export const FormStaffPage = () => {
const fetcher = useFetcher()
const navigate = useNavigate()
const formMethods = useRemixForm<TStaffSchema>({
mode: 'onSubmit',
fetcher,
resolver: zodResolver(staffSchema),
})
const { handleSubmit } = formMethods
useEffect(() => {
if (!fetcher.data?.success && fetcher.data?.message) {
toast.error(fetcher.data.message)
}
if (fetcher.data?.success) {
toast.success(`Staff berhasil dibuat!`)
navigate('/lg-admin/staffs')
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetcher.data])
return (
<div className="relative">
<TitleDashboard title={`Buat Staff`} />
<div>
<RemixFormProvider {...formMethods}>
<fetcher.Form
method="post"
onSubmit={handleSubmit}
action={`/actions/admin/staffs/create`}
className="space-y-4"
>
<div className="flex items-end justify-between gap-4">
<Input
id="name"
label="Nama Staff"
placeholder="Masukkan Url Link"
name="name"
className="border-0 bg-white shadow read-only:bg-gray-100 focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none disabled:bg-gray-100"
labelClassName="text-sm font-medium text-[#363636]"
containerClassName="flex-1"
/>
<Input
id="email"
label="Email"
placeholder="Contoh: legal@legalgo.id"
name="email"
className="border-0 bg-white shadow read-only:bg-gray-100 focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none disabled:bg-gray-100"
labelClassName="text-sm font-medium text-[#363636]"
containerClassName="flex-1"
/>
<Button
isLoading={fetcher.state !== 'idle'}
disabled={fetcher.state !== 'idle'}
type="submit"
size="lg"
className="text-md h-[42px] rounded-md"
>
Save
</Button>
</div>
<div className="flex items-end justify-between gap-4">
<Input
id="password"
label="Kata Sandi"
placeholder="Masukkan Kata Sandi"
name="password"
className="border-0 bg-white shadow read-only:bg-gray-100 focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none disabled:bg-gray-100"
labelClassName="text-sm font-medium text-[#363636]"
containerClassName="flex-1"
type="password"
/>
<Input
id="re-password"
label="Ulangi Kata Sandi"
placeholder="Masukkan Kata Sandi"
name="rePassword"
className="border-0 bg-white shadow read-only:bg-gray-100 focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none disabled:bg-gray-100"
labelClassName="text-sm font-medium text-[#363636]"
containerClassName="flex-1"
type="password"
/>
<InputFile
id="profile_picture"
label="Profile Picture"
placeholder="Upload your profile picture"
name="profile_picture"
className="border-0 bg-white shadow read-only:bg-gray-100 focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none disabled:bg-gray-100"
labelClassName="text-sm font-medium text-[#363636]"
containerClassName="flex-1"
category="profile_picture"
/>
</div>
</fetcher.Form>
</RemixFormProvider>
</div>
</div>
)
}

View File

@ -0,0 +1,44 @@
import { isRouteErrorResponse } from 'react-router'
import { StaffsPage } from '~/pages/dashboard-staffs'
import type { Route } from './+types/_admin.lg-admin._dashboard.staffs._index'
// export const loader = async ({ request }: Route.LoaderArgs) => {
// const { staffToken: accessToken } = await handleCookie(request)
// const { data: usersData } = await getUsers({ accessToken })
// return { usersData }
// }
export const ErrorBoundary = ({ error }: Route.ErrorBoundaryProps) => {
let message = 'Oops!'
let details = 'An unexpected error occurred.'
let stack: string | undefined
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? '404' : 'Error'
details =
error.status === 404
? 'The requested page could not be found.'
: error.statusText || details
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message
stack = error.stack
}
return (
<div className="container mx-auto p-4">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 whitespace-pre-wrap">
<code>{stack}</code>
</pre>
)}
</div>
)
}
const DashboardStaffsLayout = () => <StaffsPage />
export default DashboardStaffsLayout

View File

@ -0,0 +1,4 @@
import { FormStaffPage } from '~/pages/form-staff'
const DashboardStaffsCreateLayout = () => <FormStaffPage />
export default DashboardStaffsCreateLayout

View File

@ -0,0 +1,64 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { data } from 'react-router'
import { getValidatedFormData } from 'remix-hook-form'
import { XiorError } from 'xior'
import { createStaffsRequest } from '~/apis/admin/create-staffs'
import { handleCookie } from '~/libs/cookies'
import { staffSchema, type TStaffSchema } from '~/pages/form-staff'
import type { Route } from './+types/actions.admin.staffs.create'
export const action = async ({ request }: Route.ActionArgs) => {
const { staffToken: accessToken } = await handleCookie(request)
try {
const {
errors,
data: payload,
receivedValues: defaultValues,
} = await getValidatedFormData<TStaffSchema>(
request,
zodResolver(staffSchema),
false,
)
if (errors) {
return data({ success: false, errors, defaultValues }, { status: 400 })
}
const { data: staffData } = await createStaffsRequest({
accessToken,
payload,
})
return data(
{
success: true,
staffData,
},
{
status: 200,
statusText: 'OK',
},
)
} catch (error) {
if (error instanceof XiorError) {
return data(
{
success: false,
message: error?.response?.data?.error?.message || error.message,
},
{
status: error?.response?.status || 500,
},
)
}
return data(
{
success: false,
message: 'Internal server error',
},
{ status: 500 },
)
}
}