import React, { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { PlusIcon, PencilIcon, TrashIcon } from '@heroicons/react/24/outline'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { toast } from 'sonner'; import { api } from '../lib/api'; import type { PaginatedResponse } from '../lib/api'; import type { SubscriptionPlan, SubscriptionPeriod } from '../types'; import { formatRial, formatNumber, formatDate, formatResourceLimit } from '../lib/utils'; import Modal from '../components/ui/Modal'; import ConfirmDialog from '../components/ui/ConfirmDialog'; import PageHeader from '../components/ui/PageHeader'; import PriceInput from '../components/ui/PriceInput'; import SearchableSelect from '../components/ui/SearchableSelect'; import Pagination from '../components/ui/Pagination'; import { numericField } from '../lib/forms'; import { useUrlState } from '../hooks/useUrlState'; // ── Types ───────────────────────────────────────────────────────────────── interface ReportRow { uuid: string; entityType: string; entityId: number; entityName: string | null; isTrial: boolean; isGranted: boolean; grantedBy: string | null; startsAt: number; expiresAt: number | null; createdAt: number; plan_name: string; plan_level: number; } // ── Schemas ─────────────────────────────────────────────────────────────── const planSchema = z.object({ name: z.string().min(1, 'نام الزامی است'), level: z.coerce.number().min(0), max_secretaries: z.coerce.number().min(1), // API نامحدود را با `-1` می‌فهمد، ولی فرم عددِ منفی نمی‌گیرد: `numericField` علامت را // پاک می‌کند و «۱-» هم چیزی نیست که ادمین حدس بزند. پس یک سوییچ، و نگاشت هنگام ارسال. resources_unlimited: z.boolean(), max_resources: z.coerce.number().int().min(1), features: z.record(z.string(), z.boolean()), active: z.boolean(), }); type PlanForm = z.infer; /** بدنهٔ واقعی API: سوییچِ «نامحدود» به همان `-1` قراردادی برمی‌گردد. */ type PlanPayload = Omit; const toPlanPayload = ({ resources_unlimited, max_resources, ...rest }: PlanForm): PlanPayload => ({ ...rest, max_resources: resources_unlimited ? -1 : max_resources, }); const periodSchema = z.object({ plan_uuid: z.string().min(1, 'پلن الزامی است'), label: z.string().min(1, 'عنوان دوره الزامی است'), duration_months: z.coerce.number().min(1), price_rials: z.coerce.number().min(0), is_trial: z.boolean(), sort_order: z.coerce.number().min(0), active: z.boolean(), }); type PeriodForm = z.infer; // ── Feature labels ──────────────────────────────────────────────────────── const FEATURE_LABELS: Record = { patient_records: 'پرونده بیمار', services: 'سرویس‌ها', sms_panel: 'پنل پیامک', insurance: 'بیمه و مطالبات', }; const FEATURE_KEYS = Object.keys(FEATURE_LABELS); const emptyFeatures = (): Record => Object.fromEntries(FEATURE_KEYS.map((k) => [k, false])); const PLAN_DISPLAY: Record = { free: 'رایگان', basic: 'پایه', professional: 'حرفه‌ای', }; // ── Switch toggle ───────────────────────────────────────────────────────── /** * نسخهٔ uncontrolled سوییچ برای `react-hook-form`. * * `components/ui/Switch` کنترل‌شده است (`checked` + `onChange`) و با اسپردِ * `register()` — که `ref` و `onChange` نیتیو می‌دهد — جور در نمی‌آید. ظاهر هر دو از * یک کلاس `.switch` می‌آید، پس تفاوت دیداری ندارند. */ const SwitchToggle = React.forwardRef>( function SwitchToggle(props, ref) { return ( e.stopPropagation()}> ); }, ); // ── Plans tab ───────────────────────────────────────────────────────────── function PlansTab() { const qc = useQueryClient(); const [planModal, setPlanModal] = useState<'create' | SubscriptionPlan | null>(null); const [periodModal, setPeriodModal] = useState<'create' | SubscriptionPeriod | null>(null); const [activePlan, setActivePlan] = useState(null); const [deletePeriod, setDeletePeriod] = useState(null); const { data: plansData, isLoading } = useQuery({ queryKey: ['admin-subscription-plans'], queryFn: () => api.get>('/api/v1/admin/subscription/plans'), }); const plans: SubscriptionPlan[] = (plansData as any)?.data ?? []; const planForm = useForm({ resolver: zodResolver(planSchema), defaultValues: { features: emptyFeatures(), active: true }, }); const unlimitedResources = planForm.watch('resources_unlimited'); const periodForm = useForm({ resolver: zodResolver(periodSchema), defaultValues: { is_trial: false, sort_order: 0, active: true, price_rials: 0 }, }); const createPlanMut = useMutation({ mutationFn: (body: PlanPayload) => api.post('/api/v1/admin/subscription/plan', body), onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-subscription-plans'] }); setPlanModal(null); planForm.reset(); toast.success('پلن ایجاد شد'); }, onError: (e: any) => toast.error(e.message), }); const updatePlanMut = useMutation({ mutationFn: ({ uuid, body }: { uuid: string; body: PlanPayload }) => api.patch(`/api/v1/admin/subscription/plan/${uuid}`, body), onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-subscription-plans'] }); setPlanModal(null); toast.success('پلن بروزرسانی شد'); }, onError: (e: any) => toast.error(e.message), }); const createPeriodMut = useMutation({ mutationFn: (body: PeriodForm) => api.post('/api/v1/admin/subscription/period', body), onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-subscription-plans'] }); setPeriodModal(null); periodForm.reset(); toast.success('دوره ایجاد شد'); }, onError: (e: any) => toast.error(e.message), }); const updatePeriodMut = useMutation({ mutationFn: ({ uuid, body }: { uuid: string; body: PeriodForm }) => api.patch(`/api/v1/admin/subscription/period/${uuid}`, body), onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-subscription-plans'] }); setPeriodModal(null); toast.success('دوره بروزرسانی شد'); }, onError: (e: any) => toast.error(e.message), }); const deletePeriodMut = useMutation({ mutationFn: (uuid: string) => api.delete(`/api/v1/admin/subscription/period/${uuid}`), onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-subscription-plans'] }); setDeletePeriod(null); toast.success('دوره حذف شد'); }, onError: (e: any) => toast.error(e.message), }); const openEditPlan = (plan: SubscriptionPlan) => { planForm.reset({ name: plan.name, level: plan.level, max_secretaries: plan.max_secretaries, // سقفِ نامحدود عددی برای نمایش ندارد؛ ۱ می‌نشیند تا خاموش‌کردن سوییچ یک مقدار // معتبر بدهد، نه یک فیلدِ خالی. resources_unlimited: plan.max_resources < 0, max_resources: plan.max_resources < 0 ? 1 : plan.max_resources, features: { ...emptyFeatures(), ...plan.features }, active: (plan as any).active ?? true, }); setPlanModal(plan); }; const openEditPeriod = (period: SubscriptionPeriod) => { periodForm.reset({ plan_uuid: (period as any).plan_uuid ?? '', label: period.label, duration_months: period.duration_months, price_rials: period.price_rials, is_trial: period.is_trial, sort_order: (period as any).sort_order ?? 0, active: (period as any).active ?? true, }); setPeriodModal(period); }; const openCreatePeriod = (plan: SubscriptionPlan) => { setActivePlan(plan); periodForm.reset({ plan_uuid: plan.uuid, is_trial: false, sort_order: 0, active: true, price_rials: 0, label: '', duration_months: 1 }); setPeriodModal('create'); }; return ( <>
{isLoading ? (
در حال بارگذاری...
) : (
{plans.map((plan) => { const periods: SubscriptionPeriod[] = Array.isArray(plan.periods) ? plan.periods : Object.values(plan.periods ?? {}); return (
{PLAN_DISPLAY[plan.name] ?? plan.name} {plan.active ? 'فعال' : 'غیرفعال'} سطح {plan.level} حداکثر {plan.max_secretaries} منشی حداکثر {formatResourceLimit(plan.max_resources)} منبع {Object.entries(plan.features).map(([k, v]) => ( {FEATURE_LABELS[k] ?? k} ))}
{periods.length === 0 ? (
هنوز دوره‌ای تعریف نشده
) : (
{periods.map((period, i) => ( ))}
دوره مدت قیمت نوع عملیات
{period.label} {period.duration_months} ماه {period.is_trial ? 'رایگان' : formatRial(period.price_rials)} {period.is_trial ? تریال : پولی}
)}
); })}
)} {/* Modal پنل */} setPlanModal(null)} title={planModal === 'create' ? 'پلن جدید' : 'ویرایش پلن'} >
{ const body = toPlanPayload(d); if (planModal === 'create') createPlanMut.mutate(body); else if (planModal !== null && typeof planModal === 'object') updatePlanMut.mutate({ uuid: planModal.uuid, body }); })}>
{planForm.formState.errors.name && {planForm.formState.errors.name.message}}
سقف اتاق و دستگاه و پرسنلِ هر محیط. منابعِ پزشک و پرسنل هم در این شمارش می‌آیند. {planForm.formState.errors.max_resources && ( {planForm.formState.errors.max_resources.message} )}
قابلیت‌های پلن
{FEATURE_KEYS.map((k, i) => ( ))}
{/* Modal دوره */} setPeriodModal(null)} title={periodModal === 'create' ? `دوره جدید — ${PLAN_DISPLAY[activePlan?.name ?? ''] ?? activePlan?.name ?? ''}` : 'ویرایش دوره'} >
{ if (periodModal === 'create') createPeriodMut.mutate(d); else if (periodModal !== null && typeof periodModal === 'object') updatePeriodMut.mutate({ uuid: periodModal.uuid, body: d }); })}>
{periodForm.formState.errors.label && {periodForm.formState.errors.label.message}}
periodForm.setValue('price_rials', v)} ariaLabel="قیمت دوره (ریال)" />
{/* Confirm حذف دوره */} deletePeriod && deletePeriodMut.mutate(deletePeriod.uuid)} onCancel={() => setDeletePeriod(null)} /> ); } // ── Grant tab ───────────────────────────────────────────────────────────── type EntityType = 'doctor' | 'clinic'; interface EntityRow { uuid: string; name: string; mobile?: string; owner_mobile?: string } interface ActiveSubscriptionData { subscription: { plan: { name: string; level: number }; period?: { label: string }; expires_at: number | null; is_trial: boolean; is_granted: boolean; } | null; } function GrantTab() { const qc = useQueryClient(); const [entityType, setEntityType] = useState('doctor'); const [entityUuid, setEntityUuid] = useState(null); const [periodUuid, setPeriodUuid] = useState(null); const [searchInput, setSearchInput] = useState(''); const [search, setSearch] = useState(''); const [downgrade, setDowngrade] = useState<{ from: string; to: string } | null>(null); // جستجوی سمت سرور، چون فهرست پزشکان از سقف یک صفحهٔ endpoint بیشتر است. React.useEffect(() => { const t = setTimeout(() => setSearch(searchInput), 350); return () => clearTimeout(t); }, [searchInput]); const { data: entityData, isFetching: entitiesLoading } = useQuery({ queryKey: ['admin-grant-entities', entityType, search], queryFn: () => api.get>( `/api/v1/admin/${entityType === 'doctor' ? 'doctors' : 'clinics'}?limit=25&search=${encodeURIComponent(search)}`, ), }); const entityOptions = (entityData?.data ?? []).map((e) => ({ value: e.uuid, label: e.mobile || e.owner_mobile ? `${e.name} — ${e.mobile ?? e.owner_mobile}` : e.name, })); const { data: plansData } = useQuery({ queryKey: ['admin-subscription-plans'], queryFn: () => api.get>('/api/v1/admin/subscription/plans'), }); const plans: SubscriptionPlan[] = (plansData as any)?.data ?? []; // فقط دوره‌های پولی: اعطای دورهٔ تریال، تریالِ نگرفتهٔ کاربر را می‌سوزاند. const periodOptions = plans.flatMap((plan) => { const periods: SubscriptionPeriod[] = Array.isArray(plan.periods) ? plan.periods : Object.values(plan.periods ?? {}); return periods .filter((p) => !p.is_trial) .map((p) => ({ value: p.uuid, label: `${PLAN_DISPLAY[plan.name] ?? plan.name} — ${p.label} (${formatRial(p.price_rials)})`, planName: plan.name, planLevel: plan.level, })); }); const selectedPeriod = periodOptions.find((p) => p.value === periodUuid) ?? null; const { data: activeData, isFetching: activeLoading } = useQuery({ queryKey: ['admin-grant-active', entityType, entityUuid], queryFn: () => api.get<{ data: ActiveSubscriptionData }>(`/api/v1/admin/subscription/active/${entityType}/${entityUuid}`), enabled: entityUuid !== null, }); const activeSub = (activeData as any)?.data?.subscription ?? null; const grantMut = useMutation({ mutationFn: (body: { entity_type: EntityType; entity_uuid: string; period_uuid: string }) => api.post('/api/v1/admin/subscription/grant', body), onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-subscription-report'] }); qc.invalidateQueries({ queryKey: ['admin-grant-active'] }); setDowngrade(null); setPeriodUuid(null); toast.success('اشتراک اعطا شد'); }, onError: (e: any) => { setDowngrade(null); toast.error(e.message); }, }); const submitGrant = () => { if (!entityUuid || !periodUuid) { return; } grantMut.mutate({ entity_type: entityType, entity_uuid: entityUuid, period_uuid: periodUuid }); }; const onSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!entityUuid || !periodUuid || selectedPeriod === null) { return; } // `findActive` آخرین رکورد را برمی‌دارد، نه بالاترین پلن را — پس اعطای پلن // پایین‌تر واقعاً downgrade می‌کند و باید صریح تأیید شود. if (activeSub !== null && selectedPeriod.planLevel < activeSub.plan.level) { setDowngrade({ from: PLAN_DISPLAY[activeSub.plan.name] ?? activeSub.plan.name, to: PLAN_DISPLAY[selectedPeriod.planName] ?? selectedPeriod.planName, }); return; } submitGrant(); }; const changeEntityType = (type: EntityType) => { setEntityType(type); setEntityUuid(null); setSearchInput(''); setSearch(''); }; return ( <>
setEntityUuid(v === null ? null : String(v))} onInputChange={setSearchInput} isLoading={entitiesLoading} isClearable placeholder="نام یا شماره موبایل را بنویسید..." ariaLabelledBy="grant-entity-label" /> برای یافتن مقصد، بخشی از نام یا شمارهٔ موبایل را تایپ کنید.
{entityUuid !== null && (
{activeLoading ? ( در حال بررسی اشتراک فعلی... ) : activeSub === null ? ( این مقصد اشتراک فعالی ندارد. ) : ( اشتراک فعلی: {PLAN_DISPLAY[activeSub.plan.name] ?? activeSub.plan.name} {activeSub.is_trial && تریال} {activeSub.is_granted && اعطایی} انقضا: {activeSub.expires_at ? formatDate(activeSub.expires_at) : 'بی‌نهایت'} )}
)}
setPeriodUuid(v === null ? null : String(v))} isClearable placeholder="انتخاب کنید..." ariaLabel="پلن و دوره اشتراک" /> فقط دوره‌های پولی نمایش داده می‌شوند. اگر مقصد اشتراک فعال دارد، مدت روی انقضای فعلی افزوده می‌شود، نه از امروز.
setDowngrade(null)} /> ); } // ── Report tab ──────────────────────────────────────────────────────────── function ReportTab() { const [page, setPage] = useState(1); const limit = 20; const { data, isLoading } = useQuery({ queryKey: ['admin-subscription-report', page], queryFn: () => api.get>(`/api/v1/admin/subscription/report?page=${page}&limit=${limit}`), }); const rows: ReportRow[] = data?.data ?? []; const total = data?.meta?.totalRecords ?? 0; return (
{isLoading ? (
در حال بارگذاری...
) : rows.length === 0 ? (
هیچ اشتراکی ثبت نشده
) : ( <>
{rows.map((row, i) => ( ))}
مقصد پلن نوع اشتراک شروع انقضا تاریخ ثبت
{row.entityType === 'clinic' ? 'کلینیک' : 'پزشک'} {row.entityName ?? `#${row.entityId}`} {PLAN_DISPLAY[row.plan_name] ?? row.plan_name} سطح {row.plan_level} {row.isTrial ? ( تریال ) : row.isGranted ? ( اعطایی {row.grantedBy && {row.grantedBy}} ) : ( پولی )} {formatDate(row.startsAt)} {row.expiresAt ? formatDate(row.expiresAt) : بی‌نهایت} {formatDate(row.createdAt)}
)}
); } // ── Main ────────────────────────────────────────────────────────────────── export default function AdminSubscriptionPage() { // تب در URL می‌نشیند، نه در state: بازگشت از صفحهٔ دیگر باید همان تب را برگرداند. const [urlState, setUrlState] = useUrlState({ tab: 'plans' }); const tab = urlState.tab; const setTab = (next: string) => setUrlState({ tab: next }); return ( <>
{tab === 'plans' && } {tab === 'grant' && } {tab === 'report' && } ); }