Compare commits
19 Commits
e7eda086e4
...
9a0c6c1f0b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a0c6c1f0b | ||
|
|
8c2298ff61 | ||
|
|
061f95d4e5 | ||
|
|
de92703fbe | ||
|
|
a9a350fdb2 | ||
|
|
4bc185434b | ||
|
|
4bb7a23795 | ||
|
|
f291290f55 | ||
|
|
e84c05d0a0 | ||
|
|
c7583413bb | ||
|
|
5b83c3f8dd | ||
|
|
0f414f6963 | ||
|
|
2940057cb1 | ||
|
|
9dca781d6d | ||
|
|
f17d64eb9b | ||
|
|
64bb369c59 | ||
|
|
422c9cbfe2 | ||
|
|
3d13707359 | ||
|
|
9f6c07c3de |
39
app/apis/admin/get-users.ts
Normal file
39
app/apis/admin/get-users.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||
|
||||
const subscribePlanResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
code: z.string(),
|
||||
name: z.string(),
|
||||
})
|
||||
const subscribeResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
subscribe_plan_id: z.string(),
|
||||
start_date: z.string(),
|
||||
end_date: z.string().nullable(),
|
||||
status: z.string(),
|
||||
auto_renew: z.boolean(),
|
||||
subscribe_plan: subscribePlanResponseSchema,
|
||||
})
|
||||
const userResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
email: z.string().email(),
|
||||
phone: z.string(),
|
||||
subscribe: subscribeResponseSchema,
|
||||
})
|
||||
const usersResponseSchema = z.object({
|
||||
data: z.array(userResponseSchema),
|
||||
})
|
||||
|
||||
export type TUserResponse = z.infer<typeof userResponseSchema>
|
||||
|
||||
export const getUsers = async (parameters: THttpServer) => {
|
||||
try {
|
||||
const { data } = await HttpServer(parameters).get(`/api/staff/users`)
|
||||
return usersResponseSchema.parse(data)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
37
app/apis/admin/upload-file.ts
Normal file
37
app/apis/admin/upload-file.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { TUploadSchema } from '~/layouts/admin/form-upload'
|
||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||
|
||||
const uploadResponseSchema = z.object({
|
||||
data: z.object({
|
||||
message: z.string(),
|
||||
data: z.object({
|
||||
file_path: z.string(),
|
||||
file_url: z.string(),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
type TParameter = {
|
||||
payload: TUploadSchema & {
|
||||
file: File
|
||||
}
|
||||
} & THttpServer
|
||||
|
||||
export const uploadFileRequest = async (parameters: TParameter) => {
|
||||
const { payload, ...restParameters } = parameters
|
||||
const formdata = new FormData()
|
||||
formdata.append('file', payload.file)
|
||||
|
||||
try {
|
||||
const { data } = await HttpServer(restParameters).post(
|
||||
'/api/file',
|
||||
formdata,
|
||||
)
|
||||
return uploadResponseSchema.parse(data)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
import type { JSX, SVGProps } from 'react'
|
||||
|
||||
/**
|
||||
* Note: `ChevronDoubleIcon` default mengarah ke kiri.
|
||||
* Gunakan class `rotate-xx` untuk mengubah arah ikon.
|
||||
*/
|
||||
export const ChevronDoubleIcon = (
|
||||
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) => {
|
||||
return (
|
||||
<svg
|
||||
width={20}
|
||||
height={20}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={properties.className}
|
||||
{...properties}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12.512 4.427l-2.984 3.58 2.877 3.575a.667.667 0 01-1.04.836l-3.218-4a.667.667 0 01.007-.844l3.334-4a.667.667 0 011.024.853zm-5.69-.853a.667.667 0 011.023.853l-2.984 3.58 2.877 3.575a.667.667 0 01-1.039.836l-3.218-4a.666.666 0 01.007-.844l3.333-4z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@ -21,7 +21,7 @@ export const EditorButton = (properties: TProperties) => {
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={twMerge(
|
||||
'flex h-6 w-8 cursor-pointer items-center justify-center rounded-md p-2 hover:bg-[#2E2F7C] hover:text-white disabled:cursor-not-allowed disabled:bg-[#2E2F7C]/50 disabled:text-white disabled:hover:bg-[#2E2F7C]/50',
|
||||
'flex h-6 w-8 cursor-pointer items-center justify-center rounded-md p-2 hover:bg-[#2E2F7C] hover:text-white disabled:cursor-not-allowed disabled:bg-[#2E2F7C]/50 disabled:text-white disabled:opacity-50',
|
||||
isActive ? 'bg-[#2E2F7C]/10' : '',
|
||||
className,
|
||||
)}
|
||||
|
||||
@ -71,7 +71,7 @@ export const UiChartPie = () => {
|
||||
|
||||
return (
|
||||
<div className="h-[300px] w-full items-center justify-center rounded-lg bg-white p-5 text-center">
|
||||
<h2 className="text-xl font-bold">Top 5 Konten</h2>
|
||||
<h2 className="text-xl font-bold">Top 5 Artikel</h2>
|
||||
<Pie
|
||||
height={225}
|
||||
width={450}
|
||||
|
||||
93
app/components/ui/input-file.tsx
Normal file
93
app/components/ui/input-file.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import { Field, Label, Input as HeadlessInput } from '@headlessui/react'
|
||||
import { CloudArrowUpIcon } from '@heroicons/react/20/solid'
|
||||
import { useEffect, type ComponentProps, type ReactNode } from 'react'
|
||||
import {
|
||||
get,
|
||||
type FieldError,
|
||||
type FieldValues,
|
||||
type Path,
|
||||
type RegisterOptions,
|
||||
} from 'react-hook-form'
|
||||
import { useRemixFormContext } from 'remix-hook-form'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
import { useAdminContext } from '~/contexts/admin'
|
||||
|
||||
import { Button } from './button'
|
||||
|
||||
type TInputProperties<T extends FieldValues> = Omit<
|
||||
ComponentProps<'input'>,
|
||||
'size'
|
||||
> & {
|
||||
id: string
|
||||
label?: ReactNode
|
||||
name: Path<T>
|
||||
rules?: RegisterOptions
|
||||
containerClassName?: string
|
||||
labelClassName?: string
|
||||
}
|
||||
|
||||
export const InputFile = <TFormValues extends Record<string, unknown>>(
|
||||
properties: TInputProperties<TFormValues>,
|
||||
) => {
|
||||
const {
|
||||
id,
|
||||
label,
|
||||
name,
|
||||
rules,
|
||||
placeholder,
|
||||
disabled,
|
||||
className,
|
||||
containerClassName,
|
||||
labelClassName,
|
||||
...restProperties
|
||||
} = properties
|
||||
const { setIsUploadOpen, uploadedFile } = useAdminContext()
|
||||
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
setValue,
|
||||
} = useRemixFormContext()
|
||||
|
||||
const error: FieldError = get(errors, name)
|
||||
|
||||
useEffect(() => {
|
||||
if (uploadedFile) {
|
||||
setValue('featured_image', uploadedFile)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [uploadedFile])
|
||||
|
||||
return (
|
||||
<Field
|
||||
className={twMerge('relative', containerClassName)}
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
>
|
||||
<Label className={twMerge('mb-1 block text-gray-700', labelClassName)}>
|
||||
{label} {error && <span className="text-red-500">{error.message}</span>}
|
||||
</Label>
|
||||
<HeadlessInput
|
||||
className={twMerge(
|
||||
'h-[42px] w-full rounded-md border border-[#DFDFDF] p-2 pr-8',
|
||||
className,
|
||||
)}
|
||||
placeholder={placeholder}
|
||||
{...register(name, rules)}
|
||||
{...restProperties}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="icon"
|
||||
size="fit"
|
||||
className="absolute right-3 h-[42px]"
|
||||
onClick={() => {
|
||||
setIsUploadOpen('featured_image')
|
||||
}}
|
||||
>
|
||||
<CloudArrowUpIcon className="h-4 w-4 text-gray-500/50" />
|
||||
</Button>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
@ -63,7 +63,7 @@ export const Input = <TFormValues extends Record<string, unknown>>(
|
||||
<HeadlessInput
|
||||
type={inputType}
|
||||
className={twMerge(
|
||||
'h-[42px] w-full rounded-md border border-[#DFDFDF] p-2',
|
||||
'h-[42px] w-full rounded-md border border-[#DFDFDF] p-2 pr-8',
|
||||
className,
|
||||
)}
|
||||
placeholder={inputType === 'password' ? '******' : placeholder}
|
||||
@ -75,7 +75,7 @@ export const Input = <TFormValues extends Record<string, unknown>>(
|
||||
type="button"
|
||||
variant="icon"
|
||||
size="fit"
|
||||
className="absolute right-3 h-[42px] text-gray-500"
|
||||
className="absolute right-3 h-[42px]"
|
||||
onClick={() =>
|
||||
setInputType(inputType === 'password' ? 'text' : 'password')
|
||||
}
|
||||
|
||||
@ -1,66 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
import { ChevronIcon } from '~/components/icons/chevron'
|
||||
import { ChevronDoubleIcon } from '~/components/icons/chevron-double'
|
||||
|
||||
type PaginationProperties = {
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
onPageChange: (page: number) => void
|
||||
}
|
||||
|
||||
export const Pagination: React.FC<PaginationProperties> = ({
|
||||
currentPage = 1,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
}) => {
|
||||
const renderPageNumbers = () => {
|
||||
const pages = []
|
||||
for (let index = 1; index <= totalPages; index++) {
|
||||
pages.push(
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => onPageChange(index)}
|
||||
className={`rounded-lg px-3 py-1 ${
|
||||
currentPage === index ? 'bg-[#2E2F7C] text-white' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{index}
|
||||
</button>,
|
||||
)
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center space-x-2 text-[#2E2F7C]">
|
||||
<button
|
||||
onClick={() => onPageChange(1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronDoubleIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronIcon className="rotate-90" />
|
||||
</button>
|
||||
|
||||
{renderPageNumbers()}
|
||||
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<ChevronIcon className="rotate-270" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<ChevronDoubleIcon className="rotate-180" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,14 +1,10 @@
|
||||
import DT, { type Config, type ConfigColumns } from 'datatables.net-dt'
|
||||
import DataTable from 'datatables.net-react'
|
||||
import DT from 'datatables.net-dt'
|
||||
import DataTable, { type DataTableProps } from 'datatables.net-react'
|
||||
import React from 'react'
|
||||
|
||||
type UiTableProperties = {
|
||||
data: any // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
columns: ConfigColumns[]
|
||||
slots?: any // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
options?: Config
|
||||
title: string
|
||||
}
|
||||
} & DataTableProps
|
||||
|
||||
const renderPaginationIcon = (icon: string) => {
|
||||
return `<div class="pagination-icon">${icon}</div>`
|
||||
@ -27,11 +23,14 @@ export const UiTable: React.FC<UiTableProperties> = ({
|
||||
<h3 className="py-1 font-semibold text-[#4C5CA0]">{title}</h3>
|
||||
<div className="rounded-lg">
|
||||
<DataTable
|
||||
className="cell-border"
|
||||
className="cell-border text-sm"
|
||||
data={data}
|
||||
columns={columns}
|
||||
slots={slots}
|
||||
options={{
|
||||
headerCallback: (thead) => {
|
||||
thead.classList.add('text-left')
|
||||
},
|
||||
paging: true,
|
||||
searching: true,
|
||||
ordering: true,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { MENU as ADMIN_MENU } from '~/layouts/admin/menu'
|
||||
import { MENU as ADMIN_MENU, SUB_MENU } from '~/layouts/admin/menu'
|
||||
|
||||
export const APP = {
|
||||
title: 'LegalGo',
|
||||
@ -23,4 +23,5 @@ export const META_TITLE_CONFIG: TMetaTitleConfig = [
|
||||
...ADMIN_MENU.flatMap((menu) =>
|
||||
menu.items.map((item) => ({ path: item.url, title: item.title })),
|
||||
),
|
||||
...SUB_MENU,
|
||||
]
|
||||
|
||||
52
app/contexts/admin.tsx
Normal file
52
app/contexts/admin.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import {
|
||||
createContext,
|
||||
useState,
|
||||
useContext,
|
||||
type PropsWithChildren,
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
} from 'react'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const uploadCategorySchema = z
|
||||
.enum(['featured_image', 'ads', 'content', 'profile_picture'])
|
||||
.optional()
|
||||
|
||||
type TUpload = z.infer<typeof uploadCategorySchema>
|
||||
|
||||
type AdminContextProperties = {
|
||||
isUploadOpen: TUpload
|
||||
setIsUploadOpen: Dispatch<SetStateAction<TUpload>>
|
||||
uploadedFile?: string
|
||||
setUploadedFile: Dispatch<SetStateAction<string | undefined>>
|
||||
}
|
||||
|
||||
const AdminContext = createContext<AdminContextProperties | undefined>(
|
||||
undefined,
|
||||
)
|
||||
|
||||
export const AdminProvider = ({ children }: PropsWithChildren) => {
|
||||
const [isUploadOpen, setIsUploadOpen] = useState<TUpload>()
|
||||
const [uploadedFile, setUploadedFile] = useState<string | undefined>()
|
||||
|
||||
return (
|
||||
<AdminContext.Provider
|
||||
value={{
|
||||
isUploadOpen,
|
||||
setIsUploadOpen,
|
||||
uploadedFile,
|
||||
setUploadedFile,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AdminContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useAdminContext = (): AdminContextProperties => {
|
||||
const context = useContext(AdminContext)
|
||||
if (!context) {
|
||||
throw new Error('useAdminContext must be used within a AdminProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@ -1,10 +1,15 @@
|
||||
import { Dialog, DialogBackdrop, DialogPanel } from '@headlessui/react'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
|
||||
import { useAdminContext } from '~/contexts/admin'
|
||||
|
||||
import { FormUpload } from './form-upload'
|
||||
import { Navbar } from './navbar'
|
||||
import { Sidebar } from './sidebar'
|
||||
|
||||
export const AdminDashboardLayout = (properties: PropsWithChildren) => {
|
||||
const { children } = properties
|
||||
const { isUploadOpen, setIsUploadOpen } = useAdminContext()
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Navbar />
|
||||
@ -12,6 +17,28 @@ export const AdminDashboardLayout = (properties: PropsWithChildren) => {
|
||||
<Sidebar />
|
||||
<div className="min-h-[calc(100dvh-80px)] flex-1 p-8">{children}</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={!!isUploadOpen}
|
||||
onClose={() => {
|
||||
setIsUploadOpen(undefined)
|
||||
}}
|
||||
className="relative z-50"
|
||||
transition
|
||||
>
|
||||
<DialogBackdrop
|
||||
className="fixed inset-0 bg-black/50 duration-300 ease-out data-[closed]:opacity-0"
|
||||
transition
|
||||
/>
|
||||
<div className="fixed inset-0 flex w-screen justify-center overflow-y-auto p-0 max-sm:bg-white sm:items-center sm:p-4">
|
||||
<DialogPanel
|
||||
transition
|
||||
className="max-w-lg space-y-6 rounded-lg bg-white p-8 duration-300 ease-out data-[closed]:scale-95 data-[closed]:opacity-0 sm:shadow-lg"
|
||||
>
|
||||
<FormUpload />
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
122
app/layouts/admin/form-upload.tsx
Normal file
122
app/layouts/admin/form-upload.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
import { Button } from '@headlessui/react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useEffect, useState, type ChangeEvent } from 'react'
|
||||
import { useFetcher } from 'react-router'
|
||||
import { RemixFormProvider, useRemixForm } from 'remix-hook-form'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { uploadCategorySchema, useAdminContext } from '~/contexts/admin'
|
||||
|
||||
export const uploadSchema = z.object({
|
||||
file: z.instanceof(File),
|
||||
category: uploadCategorySchema,
|
||||
})
|
||||
|
||||
export type TUploadSchema = z.infer<typeof uploadSchema>
|
||||
|
||||
export const FormUpload = () => {
|
||||
const { isUploadOpen, setUploadedFile, setIsUploadOpen } = useAdminContext()
|
||||
const fetcher = useFetcher()
|
||||
const [disabled, setDisabled] = useState(false)
|
||||
const [error, setError] = useState<string>()
|
||||
const maxFileSize = 1024 // 1MB
|
||||
|
||||
const formMethods = useRemixForm<TUploadSchema>({
|
||||
mode: 'onSubmit',
|
||||
fetcher,
|
||||
resolver: zodResolver(uploadSchema),
|
||||
})
|
||||
|
||||
const { handleSubmit, register, setValue } = formMethods
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetcher.data?.success) {
|
||||
setError(fetcher.data?.message)
|
||||
setDisabled(false)
|
||||
return
|
||||
}
|
||||
|
||||
setUploadedFile(fetcher.data.uploadData.data.file_url)
|
||||
setIsUploadOpen(undefined)
|
||||
|
||||
setDisabled(true)
|
||||
setError(undefined)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fetcher])
|
||||
|
||||
const handleChange = async function (event: ChangeEvent<HTMLInputElement>) {
|
||||
event.preventDefault()
|
||||
if (event.target.files && event.target.files[0]) {
|
||||
const files: File[] = [...event.target.files]
|
||||
|
||||
onChange(files, event)
|
||||
}
|
||||
}
|
||||
|
||||
const onChange = async function (
|
||||
files: File[],
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) {
|
||||
const file = files[0]
|
||||
const img = new Image()
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
setError('Please upload an image file.')
|
||||
return
|
||||
}
|
||||
|
||||
if (file.size > maxFileSize * 1024) {
|
||||
setError(`File size is too big!`)
|
||||
return
|
||||
}
|
||||
|
||||
img.addEventListener('load', () => {
|
||||
handleFiles(event)
|
||||
})
|
||||
|
||||
img.src = URL.createObjectURL(file)
|
||||
}
|
||||
|
||||
const handleFiles = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0]
|
||||
setValue('file', file)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<RemixFormProvider {...formMethods}>
|
||||
<fetcher.Form
|
||||
method="post"
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-4"
|
||||
action="/actions/admin/upload"
|
||||
encType="multipart/form-data"
|
||||
>
|
||||
{error && (
|
||||
<div className="text-sm text-red-500 capitalize">{error}</div>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
id="input-file-upload"
|
||||
accept="image/*"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
id="input-file-upload-type"
|
||||
value={isUploadOpen}
|
||||
{...register('category')}
|
||||
/>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
type="submit"
|
||||
className="w-full rounded-md bg-[#2E2F7C] py-2 text-white transition hover:bg-blue-800"
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
</fetcher.Form>
|
||||
</RemixFormProvider>
|
||||
)
|
||||
}
|
||||
@ -30,7 +30,7 @@ export const MENU: TMenu[] = [
|
||||
icon: DocumentIcon,
|
||||
},
|
||||
{
|
||||
title: 'Konten',
|
||||
title: 'Artikel',
|
||||
url: '/lg-admin/contents',
|
||||
icon: ChatIcon,
|
||||
},
|
||||
@ -59,6 +59,38 @@ export const MENU: TMenu[] = [
|
||||
url: '/lg-admin/tags',
|
||||
icon: TagIcon,
|
||||
},
|
||||
{
|
||||
title: 'Subscribe Plan',
|
||||
url: '/lg-admin/subscribe-plan',
|
||||
icon: TagIcon,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const SUB_MENU = [
|
||||
{
|
||||
title: 'Buat Artikel',
|
||||
path: '/lg-admin/contents/create',
|
||||
},
|
||||
{
|
||||
title: 'Update Artikel',
|
||||
path: '/lg-admin/contents/update',
|
||||
},
|
||||
{
|
||||
title: 'Buat Kategori',
|
||||
path: '/lg-admin/categories/create',
|
||||
},
|
||||
{
|
||||
title: 'Update Kategori',
|
||||
path: '/lg-admin/categories/update',
|
||||
},
|
||||
{
|
||||
title: 'Buat Tag',
|
||||
path: '/lg-admin/tags/create',
|
||||
},
|
||||
{
|
||||
title: 'Update Tag',
|
||||
path: '/lg-admin/tags/update',
|
||||
},
|
||||
]
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { Field, Input, Label, Select } from '@headlessui/react'
|
||||
import { MagnifyingGlassIcon } from '@heroicons/react/20/solid'
|
||||
import type { ConfigColumns } from 'datatables.net-dt'
|
||||
import type { DataTableSlots } from 'datatables.net-react'
|
||||
import { useState } from 'react'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
@ -36,14 +38,14 @@ export const AdvertisementsPage = ({
|
||||
}
|
||||
|
||||
const dataBanner = BANNER
|
||||
const dataColumns = [
|
||||
const dataColumns: ConfigColumns[] = [
|
||||
{ title: 'No', data: 'id' },
|
||||
{ title: 'Banner', data: 'urlImage' },
|
||||
{ title: 'Link', data: 'link' },
|
||||
{ title: 'Tgl Create', data: 'createdAt' },
|
||||
{ title: 'Status', data: 'status' },
|
||||
]
|
||||
const dataSlot = {
|
||||
const dataSlot: DataTableSlots = {
|
||||
1: (value: string) => {
|
||||
return (
|
||||
<div>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import DT from 'datatables.net-dt'
|
||||
import DataTable from 'datatables.net-react'
|
||||
import DT, { type Config, type ConfigColumns } from 'datatables.net-dt'
|
||||
import DataTable, { type DataTableSlots } from 'datatables.net-react'
|
||||
import { Link, useRouteLoaderData } from 'react-router'
|
||||
|
||||
import type { TCategoryResponse } from '~/apis/common/get-categories'
|
||||
@ -13,17 +13,18 @@ export const CategoriesPage = () => {
|
||||
)
|
||||
|
||||
DataTable.use(DT)
|
||||
const dataTable = loaderData?.categoriesData?.sort((a, b) => {
|
||||
if (a.sequence === null) return 1
|
||||
if (b.sequence === null) return -1
|
||||
return a.sequence - b.sequence
|
||||
})
|
||||
const dataColumns = [
|
||||
const dataTable =
|
||||
loaderData?.categoriesData?.sort((a, b) => {
|
||||
if (a.sequence === null) return 1
|
||||
if (b.sequence === null) return -1
|
||||
return a.sequence - b.sequence
|
||||
}) || []
|
||||
const dataColumns: ConfigColumns[] = [
|
||||
{
|
||||
title: 'No',
|
||||
render: (
|
||||
data: unknown,
|
||||
type: unknown,
|
||||
_data: unknown,
|
||||
_type: unknown,
|
||||
row: TCategoryResponse,
|
||||
meta: { row: number },
|
||||
) => {
|
||||
@ -46,7 +47,7 @@ export const CategoriesPage = () => {
|
||||
data: 'id',
|
||||
},
|
||||
]
|
||||
const dataSlot = {
|
||||
const dataSlot: DataTableSlots = {
|
||||
1: (_value: unknown, _type: unknown, data: TCategoryResponse) => (
|
||||
<div>
|
||||
<div>{data.name}</div>
|
||||
@ -64,7 +65,7 @@ export const CategoriesPage = () => {
|
||||
</Button>
|
||||
),
|
||||
}
|
||||
const dataOptions = {
|
||||
const dataOptions: Config = {
|
||||
paging: true,
|
||||
searching: true,
|
||||
ordering: true,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import DT from 'datatables.net-dt'
|
||||
import DataTable from 'datatables.net-react'
|
||||
import DT, { type Config, type ConfigColumns } from 'datatables.net-dt'
|
||||
import DataTable, { type DataTableSlots } from 'datatables.net-react'
|
||||
import { Link, useRouteLoaderData } from 'react-router'
|
||||
|
||||
import type { TCategoryResponse } from '~/apis/common/get-categories'
|
||||
@ -17,16 +17,17 @@ export const ContentsPage = () => {
|
||||
)
|
||||
|
||||
DataTable.use(DT)
|
||||
const dataTable = loaderData?.newsData?.sort(
|
||||
(a, b) => new Date(b.live_at).getTime() - new Date(a.live_at).getTime(),
|
||||
)
|
||||
const dataColumns = [
|
||||
const dataTable =
|
||||
loaderData?.newsData?.sort(
|
||||
(a, b) => new Date(b.live_at).getTime() - new Date(a.live_at).getTime(),
|
||||
) || []
|
||||
const dataColumns: ConfigColumns[] = [
|
||||
{
|
||||
title: 'No',
|
||||
render: (
|
||||
data: unknown,
|
||||
type: unknown,
|
||||
row: unknown,
|
||||
_data: unknown,
|
||||
_type: unknown,
|
||||
_row: unknown,
|
||||
meta: { row: number },
|
||||
) => {
|
||||
return meta.row + 1
|
||||
@ -54,7 +55,7 @@ export const ContentsPage = () => {
|
||||
data: 'slug',
|
||||
},
|
||||
]
|
||||
const dataSlot = {
|
||||
const dataSlot: DataTableSlots = {
|
||||
1: (value: string) => formatDate(value),
|
||||
2: (_value: unknown, _type: unknown, data: TNewsResponse) => (
|
||||
<div>
|
||||
@ -62,6 +63,7 @@ export const ContentsPage = () => {
|
||||
<div className="text-sm text-[#7C7C7C]">ID: {data.id.slice(0, 8)}</div>
|
||||
</div>
|
||||
),
|
||||
3: (value: string) => <span className="text-sm">{value}</span>,
|
||||
4: (value: TCategoryResponse[]) => (
|
||||
<div className="text-xs">{value.map((item) => item.name).join(', ')}</div>
|
||||
),
|
||||
@ -89,7 +91,7 @@ export const ContentsPage = () => {
|
||||
</Button>
|
||||
),
|
||||
}
|
||||
const dataOptions = {
|
||||
const dataOptions: Config = {
|
||||
paging: true,
|
||||
searching: true,
|
||||
ordering: true,
|
||||
@ -98,7 +100,7 @@ export const ContentsPage = () => {
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<TitleDashboard title="Konten" />
|
||||
<TitleDashboard title="Artikel" />
|
||||
<div className="mb-8 flex items-end justify-between gap-5">
|
||||
<div className="flex-1">{/* TODO: Filter */}</div>
|
||||
<Button
|
||||
@ -116,7 +118,7 @@ export const ContentsPage = () => {
|
||||
columns={dataColumns}
|
||||
slots={dataSlot}
|
||||
options={dataOptions}
|
||||
title="Daftar Konten"
|
||||
title="Daftar Artikel"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
83
app/pages/dashboard-plan-subscribe/index.tsx
Normal file
83
app/pages/dashboard-plan-subscribe/index.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import DT from 'datatables.net-dt'
|
||||
import DataTable from 'datatables.net-react'
|
||||
import { Link, useRouteLoaderData } from 'react-router'
|
||||
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { UiTable } from '~/components/ui/table'
|
||||
import { TitleDashboard } from '~/components/ui/title-dashboard'
|
||||
import type { loader } from '~/routes/_admin.lg-admin._dashboard.subscribe-plan._index'
|
||||
|
||||
export const SubscribePlanPage = () => {
|
||||
const loaderData = useRouteLoaderData<typeof loader>(
|
||||
'routes/_admin.lg-admin._dashboard.subscribe-plan._index',
|
||||
)
|
||||
|
||||
DataTable.use(DT)
|
||||
const { subscriptionsData: dataTable } = loaderData || {}
|
||||
const dataColumns = [
|
||||
{
|
||||
title: 'No',
|
||||
render: (
|
||||
_data: unknown,
|
||||
_type: unknown,
|
||||
_row: unknown,
|
||||
meta: { row: number },
|
||||
) => {
|
||||
return meta.row + 1
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Nama',
|
||||
data: 'name',
|
||||
},
|
||||
{
|
||||
title: 'Kode',
|
||||
data: 'code',
|
||||
},
|
||||
{
|
||||
title: 'Action',
|
||||
data: 'id',
|
||||
},
|
||||
]
|
||||
const dataSlot = {
|
||||
3: (value: string) => (
|
||||
<Button
|
||||
as="a"
|
||||
href={`/lg-admin/subscribe-plan/update/${value}`}
|
||||
className="text-md rounded-md"
|
||||
size="sm"
|
||||
>
|
||||
Update Subscribe Plan
|
||||
</Button>
|
||||
),
|
||||
}
|
||||
return (
|
||||
<div className="relative">
|
||||
<TitleDashboard title="Subscribe Plan" />
|
||||
<div className="mb-8 flex items-end justify-between">
|
||||
<div className="flex-1">{/* TODO: Filter */}</div>
|
||||
<Button
|
||||
as={Link}
|
||||
to="/lg-admin/subscribe-plan/create"
|
||||
className="text-md h-[42px] rounded-md"
|
||||
size="lg"
|
||||
>
|
||||
Buat Subscribe Plan
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<UiTable
|
||||
data={dataTable || []}
|
||||
columns={dataColumns}
|
||||
slots={dataSlot}
|
||||
options={{
|
||||
paging: true,
|
||||
searching: true,
|
||||
ordering: true,
|
||||
info: true,
|
||||
}}
|
||||
title=" Daftar Subscribe Plan"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -8,15 +8,6 @@ type TSubscriptions = {
|
||||
status: string
|
||||
}
|
||||
|
||||
type TSubSettings = {
|
||||
id: number
|
||||
createdAt: string
|
||||
subscriber: 'Monthly' | 'Yearly' | 'Weekly' | 'Special'
|
||||
price: string
|
||||
length: '30' | '365' | '7' | '1'
|
||||
status: string
|
||||
}
|
||||
|
||||
export const SUBSCRIPTIONS: TSubscriptions[] = [
|
||||
{
|
||||
id: 1,
|
||||
@ -199,86 +190,3 @@ export const SUBSCRIPTIONS: TSubscriptions[] = [
|
||||
status: 'Draft',
|
||||
},
|
||||
]
|
||||
|
||||
export const SUBSETTINGS: TSubSettings[] = [
|
||||
{
|
||||
id: 1,
|
||||
createdAt: '24/10/2024',
|
||||
subscriber: 'Monthly',
|
||||
price: '100',
|
||||
length: '30',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
createdAt: '25/10/2024',
|
||||
subscriber: 'Yearly',
|
||||
price: '1000',
|
||||
length: '365',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
createdAt: '26/10/2024',
|
||||
subscriber: 'Weekly',
|
||||
price: '25',
|
||||
length: '7',
|
||||
status: 'inactive',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
createdAt: '27/10/2024',
|
||||
subscriber: 'Monthly',
|
||||
price: '100',
|
||||
length: '30',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
createdAt: '28/10/2024',
|
||||
subscriber: 'Yearly',
|
||||
price: '1000',
|
||||
length: '365',
|
||||
status: 'inactive',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
createdAt: '29/10/2024',
|
||||
subscriber: 'Weekly',
|
||||
price: '25',
|
||||
length: '7',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
createdAt: '30/10/2024',
|
||||
subscriber: 'Monthly',
|
||||
price: '100',
|
||||
length: '30',
|
||||
status: 'inactive',
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
createdAt: '31/10/2024',
|
||||
subscriber: 'Yearly',
|
||||
price: '1000',
|
||||
length: '365',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
createdAt: '01/11/2024',
|
||||
subscriber: 'Weekly',
|
||||
price: '25',
|
||||
length: '7',
|
||||
status: 'inactive',
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
createdAt: '02/11/2024',
|
||||
subscriber: 'Monthly',
|
||||
price: '100',
|
||||
length: '30',
|
||||
status: 'active',
|
||||
},
|
||||
]
|
||||
|
||||
@ -1,18 +1,14 @@
|
||||
import { Field, Input, Label, Select } from '@headlessui/react'
|
||||
import DT from 'datatables.net-dt'
|
||||
import DataTable from 'datatables.net-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { SearchIcon } from '~/components/icons/search'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { UiTable } from '~/components/ui/table'
|
||||
import { TitleDashboard } from '~/components/ui/title-dashboard'
|
||||
|
||||
import { SUBSCRIPTIONS, SUBSETTINGS } from './data'
|
||||
import { SUBSCRIPTIONS } from './data'
|
||||
|
||||
export const SubscriptionsPage = () => {
|
||||
const [SubscribtionOpen, setSubscribtionOpen] = useState(true)
|
||||
// const [SubSettingOpen, setSubSettingOpen] = useState(false)
|
||||
DataTable.use(DT)
|
||||
const colTableSubscription = [
|
||||
{ title: 'No', data: 'id' },
|
||||
@ -28,158 +24,55 @@ export const SubscriptionsPage = () => {
|
||||
},
|
||||
},
|
||||
]
|
||||
const colTableSubSetting = [
|
||||
{ title: 'No', data: 'id' },
|
||||
{ title: 'Tanggal Pembuatan', data: 'createdAt' },
|
||||
{ title: 'Nama Subscription', data: 'subscriber' },
|
||||
{ title: 'Price', data: 'price' },
|
||||
{ title: 'Length', data: 'length' },
|
||||
{
|
||||
title: 'Status',
|
||||
data: 'status',
|
||||
render: (value: string) => {
|
||||
return value == 'active'
|
||||
? `<span class="bg-[#DFE5FF] text-[#4C5CA0] px-2 py-1 rounded-md">${value}</span>`
|
||||
: `<span class="bg-[#FFD1D1] text-[#FF4E4E] px-2 py-1 rounded-md">${value}</span>`
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const switchView = () => {
|
||||
setSubscribtionOpen(!SubscribtionOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<TitleDashboard title="Subscription" />
|
||||
|
||||
{SubscribtionOpen && (
|
||||
<>
|
||||
<div className="mb-8 flex items-end justify-between">
|
||||
<div className="flex items-center gap-5 rounded-lg bg-gray-50 text-[#363636]">
|
||||
<div className="w-[400px]">
|
||||
<Field>
|
||||
<Label className="mb-2 block text-sm font-medium">
|
||||
Cari User
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Cari Nama"
|
||||
className="w-full rounded-lg bg-white p-2 pr-10 pl-4 shadow focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none"
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<SearchIcon className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
<div className="mb-8 flex items-end justify-between">
|
||||
<div className="flex items-center gap-5 rounded-lg bg-gray-50 text-[#363636]">
|
||||
<div className="w-[400px]">
|
||||
<Field>
|
||||
<Label className="mb-2 block text-sm font-medium">
|
||||
Cari User
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Cari Nama"
|
||||
className="w-full rounded-lg bg-white p-2 pr-10 pl-4 shadow focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none"
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<SearchIcon className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-[235px]">
|
||||
<Field>
|
||||
<Label className="mb-2 block text-sm font-medium">
|
||||
Status
|
||||
</Label>
|
||||
<Select className="w-full rounded-lg bg-white p-2 shadow focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none">
|
||||
<option>Pilih Status</option>
|
||||
<option>Aktif</option>
|
||||
<option>Nonaktif</option>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="rounded-md"
|
||||
size="lg"
|
||||
onClick={switchView}
|
||||
>
|
||||
Subscription Settings
|
||||
</Button>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<UiTable
|
||||
data={SUBSCRIPTIONS}
|
||||
columns={colTableSubscription}
|
||||
options={{
|
||||
paging: true,
|
||||
searching: true,
|
||||
ordering: true,
|
||||
info: true,
|
||||
}}
|
||||
title="Daftar Subscription"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!SubscribtionOpen && (
|
||||
<>
|
||||
<div className="mb-8 flex items-end justify-between">
|
||||
<div className="flex items-end gap-5 rounded-lg bg-gray-50 text-[#363636]">
|
||||
<div className="w-[300px]">
|
||||
<Field>
|
||||
<Label className="mb-2 block text-sm font-medium">
|
||||
Subscription Name
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Subscription Name"
|
||||
className="w-full rounded-lg bg-white p-2 pr-10 pl-4 shadow focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="w-[300px]">
|
||||
<Field>
|
||||
<Label className="mb-2 block text-sm font-medium">
|
||||
Subscription Price
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Subscription Price"
|
||||
className="w-full rounded-lg bg-white p-2 pr-10 pl-4 shadow focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="w-[300px]">
|
||||
<Field>
|
||||
<Label className="mb-2 block text-sm font-medium">
|
||||
Subscription Length (Days)
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Subscription Length (Days)"
|
||||
className="w-full rounded-lg bg-white p-2 shadow focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none"
|
||||
></Input>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="rounded-md"
|
||||
size="lg"
|
||||
onClick={switchView}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<div className="w-[235px]">
|
||||
<Field>
|
||||
<Label className="mb-2 block text-sm font-medium">Status</Label>
|
||||
<Select className="w-full rounded-lg bg-white p-2 shadow focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none">
|
||||
<option>Pilih Status</option>
|
||||
<option>Aktif</option>
|
||||
<option>Nonaktif</option>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UiTable
|
||||
data={SUBSETTINGS}
|
||||
columns={colTableSubSetting}
|
||||
options={{
|
||||
paging: true,
|
||||
searching: true,
|
||||
ordering: true,
|
||||
info: true,
|
||||
}}
|
||||
title=" Daftar Subscription"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<UiTable
|
||||
data={SUBSCRIPTIONS}
|
||||
columns={colTableSubscription}
|
||||
options={{
|
||||
paging: true,
|
||||
searching: true,
|
||||
ordering: true,
|
||||
info: true,
|
||||
}}
|
||||
title="Daftar Subscription"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import DT from 'datatables.net-dt'
|
||||
import DataTable from 'datatables.net-react'
|
||||
import DT, { type Config, type ConfigColumns } from 'datatables.net-dt'
|
||||
import DataTable, { type DataTableSlots } from 'datatables.net-react'
|
||||
import { Link, useRouteLoaderData } from 'react-router'
|
||||
|
||||
import { Button } from '~/components/ui/button'
|
||||
@ -14,13 +14,13 @@ export const TagsPage = () => {
|
||||
const { tagsData: dataTable } = loaderData || {}
|
||||
|
||||
DataTable.use(DT)
|
||||
const dataColumns = [
|
||||
const dataColumns: ConfigColumns[] = [
|
||||
{
|
||||
title: 'No',
|
||||
render: (
|
||||
data: unknown,
|
||||
type: unknown,
|
||||
row: unknown,
|
||||
_data: unknown,
|
||||
_type: unknown,
|
||||
_row: unknown,
|
||||
meta: { row: number },
|
||||
) => {
|
||||
return meta.row + 1
|
||||
@ -39,15 +39,7 @@ export const TagsPage = () => {
|
||||
data: 'id',
|
||||
},
|
||||
]
|
||||
|
||||
const dataOptions = {
|
||||
paging: true,
|
||||
searching: true,
|
||||
ordering: true,
|
||||
info: true,
|
||||
}
|
||||
|
||||
const dataSlot = {
|
||||
const dataSlot: DataTableSlots = {
|
||||
3: (value: string) => (
|
||||
<Button
|
||||
as="a"
|
||||
@ -59,6 +51,13 @@ export const TagsPage = () => {
|
||||
</Button>
|
||||
),
|
||||
}
|
||||
const dataOptions: Config = {
|
||||
paging: true,
|
||||
searching: true,
|
||||
ordering: true,
|
||||
info: true,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<TitleDashboard title="Tags" />
|
||||
@ -75,7 +74,7 @@ export const TagsPage = () => {
|
||||
</div>
|
||||
|
||||
<UiTable
|
||||
data={dataTable}
|
||||
data={dataTable || []}
|
||||
columns={dataColumns}
|
||||
options={dataOptions}
|
||||
slots={dataSlot}
|
||||
|
||||
@ -1,56 +0,0 @@
|
||||
type TUsers = {
|
||||
id: number
|
||||
idTransaction: number
|
||||
date: string
|
||||
name: string
|
||||
email: string
|
||||
category: string
|
||||
status: string
|
||||
}
|
||||
export const USERS: TUsers[] = [
|
||||
{
|
||||
id: 1,
|
||||
idTransaction: 5_512_446_588,
|
||||
date: '24/10/2024',
|
||||
name: 'Ainun Wijaya',
|
||||
email: 'ainun@gmail.com',
|
||||
category: 'Pribadi',
|
||||
status: 'Baru',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
idTransaction: 5_512_446_588,
|
||||
date: '24/10/2024',
|
||||
name: 'Ainun Wijaya',
|
||||
email: 'ainun@gmail.com',
|
||||
category: 'Pribadi',
|
||||
status: 'Premium',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
idTransaction: 5_512_446_588,
|
||||
date: '24/10/2024',
|
||||
name: 'Ainun Wijaya',
|
||||
email: 'ainun@gmail.com',
|
||||
category: 'Pribadi',
|
||||
status: 'Pembayaran',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
idTransaction: 5_512_446_588,
|
||||
date: '24/10/2024',
|
||||
name: 'Ainun Wijaya',
|
||||
email: 'ainun@gmail.com',
|
||||
category: 'Pribadi',
|
||||
status: 'Premium',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
idTransaction: 5_512_446_588,
|
||||
date: '24/10/2024',
|
||||
name: 'Ainun Wijaya',
|
||||
email: 'ainun@gmail.com',
|
||||
category: 'Pribadi',
|
||||
status: 'Baru',
|
||||
},
|
||||
]
|
||||
@ -1,110 +1,98 @@
|
||||
import { Field, Input, Label, Select } from '@headlessui/react'
|
||||
import DT, { type ConfigColumns } from 'datatables.net-dt'
|
||||
import DataTable, { type DataTableSlots } from 'datatables.net-react'
|
||||
import { useRouteLoaderData } from 'react-router'
|
||||
|
||||
import { SearchIcon } from '~/components/icons/search'
|
||||
import { Pagination } from '~/components/ui/pagination'
|
||||
import type { TUserResponse } from '~/apis/admin/get-users'
|
||||
import { UiTable } from '~/components/ui/table'
|
||||
import { TitleDashboard } from '~/components/ui/title-dashboard'
|
||||
|
||||
import { USERS } from './data'
|
||||
import type { loader } from '~/routes/_admin.lg-admin._dashboard.users._index'
|
||||
import { formatDate } from '~/utils/formatter'
|
||||
|
||||
type TColorBadge = 'Baru' | 'Premium' | 'Pembayaran'
|
||||
|
||||
const getStatusBadge = (status: TColorBadge) => {
|
||||
const statusColors = {
|
||||
Baru: 'bg-[#DFE5FF] text-[#4C5CA0]',
|
||||
Premium: 'bg-[#FFFCAF] text-[#DBCA6E]',
|
||||
Pembayaran: 'bg-[#FEC4FF] text-[#CC6EDB]',
|
||||
}
|
||||
return statusColors[status] || 'bg-gray-200 text-gray-700'
|
||||
}
|
||||
export const UsersPage = () => {
|
||||
const getStatusBadge = (status: TColorBadge) => {
|
||||
const statusColors = {
|
||||
Baru: 'bg-[#DFE5FF] text-[#4C5CA0]',
|
||||
Premium: 'bg-[#FFFCAF] text-[#DBCA6E]',
|
||||
Pembayaran: 'bg-[#FEC4FF] text-[#CC6EDB]',
|
||||
}
|
||||
return statusColors[status] || 'bg-gray-200 text-gray-700'
|
||||
const loaderData = useRouteLoaderData<typeof loader>(
|
||||
'routes/_admin.lg-admin._dashboard.users._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: 'subscribe.start_date',
|
||||
},
|
||||
{
|
||||
title: 'Nama User',
|
||||
},
|
||||
{
|
||||
title: 'Email',
|
||||
data: 'email',
|
||||
},
|
||||
{
|
||||
title: 'Kategori',
|
||||
data: 'subscribe.status',
|
||||
},
|
||||
{
|
||||
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.phone}</div>
|
||||
<div className="text-sm text-[#7C7C7C]">ID: {data.id.slice(0, 8)}</div>
|
||||
</div>
|
||||
),
|
||||
4: (_value: string) => <span className="text-sm">Pribadi</span>,
|
||||
5: (value: TColorBadge) => (
|
||||
<span className={`rounded-lg px-2 text-sm ${getStatusBadge(value)}`}>
|
||||
{value}
|
||||
</span>
|
||||
),
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<TitleDashboard title="Users" />
|
||||
{/* filter section */}
|
||||
|
||||
<div className="mb-8 flex items-center gap-5 rounded-lg bg-gray-50 text-[#363636]">
|
||||
<div className="w-[400px]">
|
||||
<Field>
|
||||
<Label className="mb-2 block text-sm font-medium">Cari User</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Cari Nama"
|
||||
className="w-full rounded-lg bg-white p-2 pr-10 pl-4 shadow focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none"
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<SearchIcon className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="w-[235px]">
|
||||
<Field>
|
||||
<Label className="mb-2 block text-sm font-medium">Status</Label>
|
||||
<Select className="w-full rounded-lg bg-white p-2 shadow focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none">
|
||||
<option>Pilih Status</option>
|
||||
<option>Aktif</option>
|
||||
<option>Nonaktif</option>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mb-8 flex items-end justify-between gap-5">
|
||||
<div className="flex-1">{/* TODO: Filter */}</div>
|
||||
</div>
|
||||
|
||||
{/* table */}
|
||||
<div className="overflow-x-auto">
|
||||
<div className="rounded-lg bg-white px-6 py-8 shadow-sm">
|
||||
<h2 className="text-xl font-bold text-[#4C5CA0]">Daftar User</h2>
|
||||
<table className="min-w-full p-3">
|
||||
<thead className="my-5 border-b-3 border-[#C2C2C2]">
|
||||
<tr>
|
||||
<th className="p-3">No</th>
|
||||
<th>Tanggal Daftar</th>
|
||||
<th>Nama User</th>
|
||||
<th>Email</th>
|
||||
<th>Kategori</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{USERS.map((user, index) => (
|
||||
<tr
|
||||
key={user.id}
|
||||
className="border-b-1 border-gray-200 text-center"
|
||||
>
|
||||
<td className="p-4">{index + 1}</td>
|
||||
<td>{user.date}</td>
|
||||
<td>
|
||||
{user.name}
|
||||
<div className="text-sm text-gray-500">
|
||||
id: {user.idTransaction}
|
||||
</div>
|
||||
</td>
|
||||
<td>{user.email}</td>
|
||||
<td>{user.category}</td>
|
||||
<td className="">
|
||||
<span
|
||||
className={`inline-block min-w-[100px] rounded-full px-2 py-1 text-sm ${getStatusBadge(user.status as TColorBadge)}`}
|
||||
>
|
||||
{user.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* pagination */}
|
||||
<div className="float-end mt-6">
|
||||
<Pagination
|
||||
currentPage={1}
|
||||
totalPages={5}
|
||||
onPageChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<UiTable
|
||||
data={dataTable || []}
|
||||
columns={dataColumns}
|
||||
slots={dataSlot}
|
||||
title="Daftar Users"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import { TextEditor } from '~/components/text-editor'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { Combobox } from '~/components/ui/combobox'
|
||||
import { Input } from '~/components/ui/input'
|
||||
import { InputFile } from '~/components/ui/input-file'
|
||||
import { Switch } from '~/components/ui/switch'
|
||||
import { TitleDashboard } from '~/components/ui/title-dashboard'
|
||||
import type { loader } from '~/routes/_admin.lg-admin._dashboard'
|
||||
@ -133,10 +134,10 @@ export const FormContentsPage = (properties: TProperties) => {
|
||||
containerClassName="flex-1"
|
||||
disabled={!!newsData}
|
||||
/>
|
||||
<Input
|
||||
<InputFile
|
||||
id="featured_image"
|
||||
label="Gambar Unggulan"
|
||||
placeholder="Masukkan Gambar Unggulan"
|
||||
placeholder="Masukkan Url Gambar Unggulan"
|
||||
name="featured_image"
|
||||
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]"
|
||||
@ -207,7 +208,7 @@ export const FormContentsPage = (properties: TProperties) => {
|
||||
label="Konten"
|
||||
placeholder="Masukkan Konten"
|
||||
className="shadow"
|
||||
inputClassName="bg-white focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none border-0"
|
||||
inputClassName="bg-white focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none border-0 min-h-[42px]"
|
||||
labelClassName="text-sm font-medium text-[#363636]"
|
||||
category="content"
|
||||
/>
|
||||
|
||||
@ -29,9 +29,7 @@ export const NewsDetailPage = () => {
|
||||
<div className="sm-max:mx-5 relative">
|
||||
<Card>
|
||||
<div className="py-5 sm:px-30">
|
||||
<h2 className="text-xl font-extrabold text-[#2E2F7C] sm:text-4xl">
|
||||
{title}
|
||||
</h2>
|
||||
<h2 className="text-xl font-extrabold sm:text-4xl">{title}</h2>
|
||||
|
||||
<div className="my-5 w-full items-center justify-between gap-2 align-middle sm:flex">
|
||||
<NewsAuthor
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
import { getSubscriptions } from '~/apis/common/get-subscriptions'
|
||||
import { SubscribePlanPage } from '~/pages/dashboard-plan-subscribe'
|
||||
|
||||
import type { Route } from './+types/_admin.lg-admin._dashboard.contents._index'
|
||||
|
||||
export const loader = async ({}: Route.LoaderArgs) => {
|
||||
const { data: subscriptionsData } = await getSubscriptions()
|
||||
return { subscriptionsData }
|
||||
}
|
||||
const DashboardSubscriptionsSettingsLayout = () => <SubscribePlanPage />
|
||||
export default DashboardSubscriptionsSettingsLayout
|
||||
@ -2,6 +2,7 @@ import { Outlet } from 'react-router'
|
||||
|
||||
import { getCategories } from '~/apis/common/get-categories'
|
||||
import { getTags } from '~/apis/common/get-tags'
|
||||
import { AdminProvider } from '~/contexts/admin'
|
||||
import { AdminDashboardLayout } from '~/layouts/admin/dashboard'
|
||||
|
||||
import type { Route } from './+types/_admin.lg-admin._dashboard'
|
||||
@ -18,9 +19,11 @@ export const loader = async ({}: Route.LoaderArgs) => {
|
||||
|
||||
const DashboardLayout = () => {
|
||||
return (
|
||||
<AdminDashboardLayout>
|
||||
<Outlet />
|
||||
</AdminDashboardLayout>
|
||||
<AdminProvider>
|
||||
<AdminDashboardLayout>
|
||||
<Outlet />
|
||||
</AdminDashboardLayout>
|
||||
</AdminProvider>
|
||||
)
|
||||
}
|
||||
export default DashboardLayout
|
||||
|
||||
17
app/routes/_admin.lg-admin._dashboard.users._index.tsx
Normal file
17
app/routes/_admin.lg-admin._dashboard.users._index.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { getUsers } from '~/apis/admin/get-users'
|
||||
import { handleCookie } from '~/libs/cookies'
|
||||
import { UsersPage } from '~/pages/dashboard-users'
|
||||
|
||||
import type { Route } from './+types/_admin.lg-admin._dashboard.users._index'
|
||||
|
||||
export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||
const { staffToken } = await handleCookie(request)
|
||||
const { data: usersData } = await getUsers({
|
||||
accessToken: staffToken,
|
||||
})
|
||||
|
||||
return { usersData }
|
||||
}
|
||||
|
||||
const DashboardUsersLayout = () => <UsersPage />
|
||||
export default DashboardUsersLayout
|
||||
@ -1,4 +0,0 @@
|
||||
import { UsersPage } from '~/pages/dashboard-users'
|
||||
|
||||
const DashboardUsersLayout = () => <UsersPage />
|
||||
export default DashboardUsersLayout
|
||||
@ -1,9 +1,11 @@
|
||||
import { Outlet, redirect } from 'react-router'
|
||||
import { XiorError } from 'xior'
|
||||
|
||||
import { getStaff } from '~/apis/admin/get-staff'
|
||||
import { AUTH_PAGES } from '~/configs/pages'
|
||||
import { AdminDefaultLayout } from '~/layouts/admin/default'
|
||||
import { handleCookie } from '~/libs/cookies'
|
||||
import { setStaffLogoutHeaders } from '~/libs/logout-header.server'
|
||||
|
||||
import type { Route } from './+types/_admin.lg-admin'
|
||||
|
||||
@ -13,6 +15,19 @@ export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||
const isAuthPage = AUTH_PAGES.includes(pathname)
|
||||
let staffData
|
||||
|
||||
if (staffToken) {
|
||||
try {
|
||||
const { data } = await getStaff({
|
||||
accessToken: staffToken,
|
||||
})
|
||||
staffData = data
|
||||
} catch (error) {
|
||||
if (error instanceof XiorError && error.response?.status === 401) {
|
||||
setStaffLogoutHeaders()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAuthPage && !staffToken) {
|
||||
throw redirect('/lg-admin/login')
|
||||
}
|
||||
@ -21,13 +36,6 @@ export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||
throw redirect('/lg-admin')
|
||||
}
|
||||
|
||||
if (staffToken) {
|
||||
const { data } = await getStaff({
|
||||
accessToken: staffToken,
|
||||
})
|
||||
staffData = data
|
||||
}
|
||||
|
||||
return {
|
||||
staffData,
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Outlet } from 'react-router'
|
||||
import { XiorError } from 'xior'
|
||||
|
||||
import { getCategories } from '~/apis/common/get-categories'
|
||||
import { getSubscriptions } from '~/apis/common/get-subscriptions'
|
||||
@ -6,6 +7,7 @@ import { getUser } from '~/apis/news/get-user'
|
||||
import { NewsProvider } from '~/contexts/news'
|
||||
import { NewsDefaultLayout } from '~/layouts/news/default'
|
||||
import { handleCookie } from '~/libs/cookies'
|
||||
import { setUserLogoutHeaders } from '~/libs/logout-header.server'
|
||||
|
||||
import type { Route } from './+types/_news'
|
||||
|
||||
@ -13,10 +15,16 @@ export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||
const { userToken } = await handleCookie(request)
|
||||
let userData
|
||||
if (userToken) {
|
||||
const { data } = await getUser({
|
||||
accessToken: userToken,
|
||||
})
|
||||
userData = data
|
||||
try {
|
||||
const { data } = await getUser({
|
||||
accessToken: userToken,
|
||||
})
|
||||
userData = data
|
||||
} catch (error) {
|
||||
if (error instanceof XiorError && error.response?.status === 401) {
|
||||
setUserLogoutHeaders()
|
||||
}
|
||||
}
|
||||
}
|
||||
const { data: subscriptionsData } = await getSubscriptions()
|
||||
const { data: categoriesData } = await getCategories()
|
||||
|
||||
64
app/routes/actions.admin.upload.tsx
Normal file
64
app/routes/actions.admin.upload.tsx
Normal 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 { uploadFileRequest } from '~/apis/admin/upload-file'
|
||||
import { uploadSchema, type TUploadSchema } from '~/layouts/admin/form-upload'
|
||||
import { handleCookie } from '~/libs/cookies'
|
||||
|
||||
import type { Route } from './+types/actions.register'
|
||||
|
||||
export const action = async ({ request }: Route.ActionArgs) => {
|
||||
const { staffToken } = await handleCookie(request)
|
||||
try {
|
||||
const {
|
||||
errors,
|
||||
data: payload,
|
||||
receivedValues: defaultValues,
|
||||
} = await getValidatedFormData<TUploadSchema>(
|
||||
request,
|
||||
zodResolver(uploadSchema),
|
||||
false,
|
||||
)
|
||||
|
||||
if (errors) {
|
||||
return data({ success: false, errors, defaultValues }, { status: 400 })
|
||||
}
|
||||
|
||||
const { data: uploadData } = await uploadFileRequest({
|
||||
payload,
|
||||
accessToken: staffToken,
|
||||
})
|
||||
|
||||
return data(
|
||||
{
|
||||
success: true,
|
||||
uploadData,
|
||||
},
|
||||
{
|
||||
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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user