Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
263 lines
8.8 KiB
TypeScript
263 lines
8.8 KiB
TypeScript
'use client'
|
||
|
||
import { zodResolver } from '@hookform/resolvers/zod'
|
||
import { useEffect, useState } from 'react'
|
||
import { FormProvider, useForm } from 'react-hook-form'
|
||
|
||
import { addToast } from '@/lib/toast'
|
||
import type { AdminUserDetail } from '@/services/adminUserDetail'
|
||
import Modal from '@/components/modals/Modal'
|
||
import Input from '@/components/formElements/Input'
|
||
import { AdminFormSection } from '@/components/forms/AdminFormLayout'
|
||
import UnsavedChangesIndicator from '@/components/forms/UnsavedChangesIndicator'
|
||
import { ADMIN_UPDATE_USER, type AdminUpdateUserPayload } from '@/services/adminUsers'
|
||
import { API_ROUTES } from '@/services/config'
|
||
import { AdminUserEditValidation, type AdminUserEditValues } from '@/validation/adminUsers'
|
||
import useAlertModal from '@/hooks/useAlertModal'
|
||
import axiosInstance from '@/config/axios'
|
||
|
||
const STATUS_OPTIONS = [
|
||
{ id: 'active', name: 'فعال' },
|
||
{ id: 'suspended', name: 'معلق' },
|
||
]
|
||
|
||
const HOST_PLAN_OPTIONS = [
|
||
{ id: 'free', name: 'رایگان (حداکثر ۱۰ ایونت)' },
|
||
{ id: 'unlimited', name: 'نامحدود' },
|
||
]
|
||
|
||
const GENDER_OPTIONS = [
|
||
{ id: 'male', name: 'مرد' },
|
||
{ id: 'female', name: 'زن' },
|
||
{ id: 'other', name: 'سایر' },
|
||
]
|
||
|
||
interface CityOption {
|
||
id: number
|
||
name: string
|
||
}
|
||
|
||
const toFormValues = (user: AdminUserDetail): AdminUserEditValues => ({
|
||
firstName: user.firstName ?? '',
|
||
lastName: user.lastName ?? '',
|
||
gender: user.gender ?? '',
|
||
cityId: user.cityId ? String(user.cityId) : '',
|
||
avatarUrl: user.avatarUrl ?? '',
|
||
bio: user.bio ?? '',
|
||
status: user.status === 'suspended' ? 'suspended' : 'active',
|
||
hostPlan: user.hostPlan ?? 'free',
|
||
})
|
||
|
||
interface UserEditModalProps {
|
||
isOpen: boolean
|
||
onOpenChange: (isOpen: boolean) => void
|
||
user: AdminUserDetail
|
||
/** Current admin's own id — status can't be self-edited. */
|
||
currentAdminId?: string
|
||
onSuccess: () => void
|
||
}
|
||
|
||
const UserEditModal = ({ isOpen, onOpenChange, user, currentAdminId, onSuccess }: UserEditModalProps) => {
|
||
const { showAlert } = useAlertModal()
|
||
const [submitting, setSubmitting] = useState(false)
|
||
const [cities, setCities] = useState<CityOption[]>([])
|
||
|
||
const isSelfEdit = Boolean(currentAdminId) && currentAdminId === user.id
|
||
|
||
const form = useForm<AdminUserEditValues>({
|
||
resolver: zodResolver(AdminUserEditValidation),
|
||
defaultValues: toFormValues(user),
|
||
})
|
||
const { reset } = form
|
||
|
||
// Modal stays mounted across opens, so reset explicitly whenever it opens
|
||
// (and the target user's data may have refreshed since the last edit).
|
||
useEffect(() => {
|
||
if (isOpen) {
|
||
reset(toFormValues(user))
|
||
}
|
||
}, [isOpen, reset, user])
|
||
|
||
useEffect(() => {
|
||
if (!isOpen || cities.length > 0) return
|
||
|
||
axiosInstance
|
||
.get(API_ROUTES.GEOGRAPHY.ALL_CITIES)
|
||
.then((res) => {
|
||
const payload: unknown = res.data
|
||
let list: unknown = []
|
||
|
||
if (Array.isArray(payload)) {
|
||
list = payload
|
||
} else if (typeof payload === 'object' && payload !== null) {
|
||
const record = payload as { data?: unknown; body?: unknown }
|
||
|
||
list = record.data ?? record.body ?? []
|
||
}
|
||
|
||
setCities(Array.isArray(list) ? (list as { id: number; name: string }[]) : [])
|
||
})
|
||
.catch(() => {
|
||
setCities([])
|
||
})
|
||
}, [isOpen, cities.length])
|
||
|
||
const buildDiffPayload = (values: AdminUserEditValues): AdminUpdateUserPayload => {
|
||
const initial = toFormValues(user)
|
||
const payload: AdminUpdateUserPayload = {}
|
||
|
||
if (values.firstName && values.firstName !== initial.firstName) payload.firstName = values.firstName
|
||
if (values.lastName && values.lastName !== initial.lastName) payload.lastName = values.lastName
|
||
if (values.gender && values.gender !== initial.gender) payload.gender = values.gender
|
||
if (values.cityId && values.cityId !== initial.cityId) payload.cityId = Number(values.cityId)
|
||
if (values.avatarUrl && values.avatarUrl !== initial.avatarUrl) payload.avatarUrl = values.avatarUrl
|
||
if (values.bio && values.bio !== initial.bio) payload.bio = values.bio
|
||
if (!isSelfEdit && values.status !== initial.status) payload.status = values.status
|
||
if (values.hostPlan !== initial.hostPlan) payload.hostPlan = values.hostPlan
|
||
|
||
return payload
|
||
}
|
||
|
||
const submitUpdate = async (values: AdminUserEditValues) => {
|
||
const payload = buildDiffPayload(values)
|
||
|
||
if (Object.keys(payload).length === 0) {
|
||
onOpenChange(false)
|
||
|
||
return
|
||
}
|
||
|
||
setSubmitting(true)
|
||
const result = await ADMIN_UPDATE_USER(user.id, payload)
|
||
|
||
setSubmitting(false)
|
||
|
||
if (!result.ok) return
|
||
|
||
addToast({ title: 'اطلاعات کاربر ذخیره شد', color: 'success' })
|
||
onOpenChange(false)
|
||
onSuccess()
|
||
}
|
||
|
||
const handleSubmit = (values: AdminUserEditValues) => {
|
||
// Suspending an account is destructive-ish for the user, so confirm first.
|
||
const initial = toFormValues(user)
|
||
|
||
if (!isSelfEdit && values.status === 'suspended' && initial.status !== 'suspended') {
|
||
showAlert('این کاربر معلق شود؟ کاربر تا فعالسازی مجدد امکان استفاده از حساب را نخواهد داشت.', () => submitUpdate(values))
|
||
|
||
return
|
||
}
|
||
|
||
void submitUpdate(values)
|
||
}
|
||
|
||
return (
|
||
<Modal
|
||
acceptBtnText="ذخیره تغییرات"
|
||
isLoading={submitting}
|
||
isOpen={isOpen}
|
||
title={`ویرایش «${[user.firstName, user.lastName].filter(Boolean).join(' ') || user.mobile}»`}
|
||
onAccept={form.handleSubmit(handleSubmit)}
|
||
onOpenChange={onOpenChange}
|
||
>
|
||
<FormProvider {...form}>
|
||
<form
|
||
className="flex flex-col gap-4"
|
||
onSubmit={form.handleSubmit(handleSubmit)}
|
||
>
|
||
<UnsavedChangesIndicator isDirty={form.formState.isDirty} />
|
||
<AdminFormSection
|
||
contained={false}
|
||
description="اطلاعات عمومی نمایشدادهشده در پروفایل کاربر"
|
||
title="اطلاعات پروفایل"
|
||
>
|
||
<div className="flex flex-col gap-4">
|
||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||
<Input
|
||
generalType="input"
|
||
label="نام"
|
||
name="firstName"
|
||
placeholder="نام"
|
||
variant="flat"
|
||
/>
|
||
<Input
|
||
generalType="input"
|
||
label="نام خانوادگی"
|
||
name="lastName"
|
||
placeholder="نام خانوادگی"
|
||
variant="flat"
|
||
/>
|
||
</div>
|
||
<Input
|
||
generalType="select"
|
||
label="پلن میزبانی"
|
||
name="hostPlan"
|
||
selectKey="id"
|
||
selectOptions={HOST_PLAN_OPTIONS}
|
||
selectValue="name"
|
||
/>
|
||
<Input
|
||
generalType="select"
|
||
label="جنسیت"
|
||
name="gender"
|
||
selectKey="id"
|
||
selectOptions={GENDER_OPTIONS}
|
||
selectValue="name"
|
||
/>
|
||
<Input
|
||
generalType="select"
|
||
label="شهر"
|
||
name="cityId"
|
||
selectKey="id"
|
||
selectOptions={cities}
|
||
selectValue="name"
|
||
/>
|
||
<Input
|
||
direction="ltr"
|
||
generalType="input"
|
||
label="آدرس تصویر پروفایل"
|
||
name="avatarUrl"
|
||
placeholder="https://..."
|
||
variant="flat"
|
||
/>
|
||
<Input
|
||
generalType="textarea"
|
||
label="بیوگرافی"
|
||
name="bio"
|
||
placeholder="بیوگرافی کوتاه کاربر"
|
||
variant="flat"
|
||
/>
|
||
</div>
|
||
</AdminFormSection>
|
||
|
||
<AdminFormSection
|
||
contained={false}
|
||
description="سطح دسترسی و امکان استفاده از حساب"
|
||
title="تنظیمات مدیریتی"
|
||
>
|
||
<div className="flex flex-col gap-4">
|
||
{isSelfEdit && (
|
||
<div className="rounded-xl border border-fourth-100 bg-fourth-100 px-3 py-2 text-xs leading-5 text-fourth-900">
|
||
وضعیت حساب خودتان از اینجا قابل ویرایش نیست.
|
||
</div>
|
||
)}
|
||
<Input
|
||
disabled={isSelfEdit}
|
||
generalType="select"
|
||
label="وضعیت حساب"
|
||
name="status"
|
||
selectKey="id"
|
||
selectOptions={STATUS_OPTIONS}
|
||
selectValue="name"
|
||
/>
|
||
</div>
|
||
</AdminFormSection>
|
||
</form>
|
||
</FormProvider>
|
||
</Modal>
|
||
)
|
||
}
|
||
|
||
export default UserEditModal
|