admin/app/(dashboard)/users/[id]/_components/UserEditModal.tsx
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

263 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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