Files
clinicpro/assets/admin/pages/AdminSubscriptionPage.tsx
hamed a6a965a2aa feat: add admin subscription granting feature
- Implemented the ability for admins to grant subscriptions to doctors and clinics without payment.
- Added new API endpoint `/api/v1/admin/subscription/grant` for granting subscriptions.
- Updated the subscription model to track the admin who granted the subscription.
- Enhanced the subscription report to include details about granted subscriptions.
- Introduced a new `is_granted` field to indicate if a subscription was granted by an admin.
- Updated the database schema to support the new functionality with a migration.
- Added tests to ensure the correct behavior of the subscription granting process.
2026-08-09 13:43:30 +03:30

729 lines
36 KiB
TypeScript

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<typeof planSchema>;
/** بدنهٔ واقعی API: سوییچِ «نامحدود» به همان `-1` قراردادی برمی‌گردد. */
type PlanPayload = Omit<PlanForm, 'resources_unlimited'>;
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<typeof periodSchema>;
// ── Feature labels ────────────────────────────────────────────────────────
const FEATURE_LABELS: Record<string, string> = {
patient_records: 'پرونده بیمار',
services: 'سرویس‌ها',
sms_panel: 'پنل پیامک',
insurance: 'بیمه و مطالبات',
};
const FEATURE_KEYS = Object.keys(FEATURE_LABELS);
const emptyFeatures = (): Record<string, boolean> =>
Object.fromEntries(FEATURE_KEYS.map((k) => [k, false]));
const PLAN_DISPLAY: Record<string, string> = {
free: 'رایگان',
basic: 'پایه',
professional: 'حرفه‌ای',
};
// ── Switch toggle ─────────────────────────────────────────────────────────
/**
* نسخهٔ uncontrolled سوییچ برای `react-hook-form`.
*
* `components/ui/Switch` کنترل‌شده است (`checked` + `onChange`) و با اسپردِ
* `register()` — که `ref` و `onChange` نیتیو می‌دهد — جور در نمی‌آید. ظاهر هر دو از
* یک کلاس `.switch` می‌آید، پس تفاوت دیداری ندارند.
*/
const SwitchToggle = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
function SwitchToggle(props, ref) {
return (
<span className="switch" onClick={(e) => e.stopPropagation()}>
<input type="checkbox" ref={ref} {...props} />
<span className="switch-track"><span className="switch-thumb" /></span>
</span>
);
},
);
// ── 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<SubscriptionPlan | null>(null);
const [deletePeriod, setDeletePeriod] = useState<SubscriptionPeriod | null>(null);
const { data: plansData, isLoading } = useQuery({
queryKey: ['admin-subscription-plans'],
queryFn: () => api.get<PaginatedResponse<SubscriptionPlan>>('/api/v1/admin/subscription/plans'),
});
const plans: SubscriptionPlan[] = (plansData as any)?.data ?? [];
const planForm = useForm<PlanForm>({
resolver: zodResolver(planSchema),
defaultValues: { features: emptyFeatures(), active: true },
});
const unlimitedResources = planForm.watch('resources_unlimited');
const periodForm = useForm<PeriodForm>({
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 (
<>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
<button className="btn primary sm" onClick={() => { planForm.reset({ features: emptyFeatures(), active: true, level: 0, max_secretaries: 1, max_resources: 1, resources_unlimited: false }); setPlanModal('create'); }}>
<PlusIcon style={{ width: 16 }} /> پلن جدید
</button>
</div>
{isLoading ? (
<div className="card card-pad" style={{ color: 'var(--text-3)' }}>در حال بارگذاری...</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{plans.map((plan) => {
const periods: SubscriptionPeriod[] = Array.isArray(plan.periods)
? plan.periods
: Object.values(plan.periods ?? {});
return (
<div key={plan.uuid} className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', borderBottom: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<b style={{ fontSize: 15 }}>{PLAN_DISPLAY[plan.name] ?? plan.name}</b>
<span className={`badge ${plan.active ? 'green' : 'gray'}`} style={{ fontSize: 11 }}>{plan.active ? 'فعال' : 'غیرفعال'}</span>
<span className="badge blue" style={{ fontSize: 11 }}>سطح {plan.level}</span>
<span className="muted" style={{ fontSize: 12 }}>حداکثر {plan.max_secretaries} منشی</span>
<span className="muted" style={{ fontSize: 12 }}>حداکثر {formatResourceLimit(plan.max_resources)} منبع</span>
<span style={{ fontSize: 12, display: 'flex', gap: 6 }}>
{Object.entries(plan.features).map(([k, v]) => (
<span key={k} className={`badge ${v ? 'green' : 'gray'}`} style={{ fontSize: 10 }}>{FEATURE_LABELS[k] ?? k}</span>
))}
</span>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button className="btn sm" onClick={() => openCreatePeriod(plan)}>
<PlusIcon style={{ width: 13 }} /> دوره جدید
</button>
<button className="btn sm" onClick={() => openEditPlan(plan)}>
<PencilIcon style={{ width: 13 }} />
</button>
</div>
</div>
{periods.length === 0 ? (
<div style={{ padding: '20px 16px', color: 'var(--text-3)', fontSize: 13 }}>هنوز دوره‌ای تعریف نشده</div>
) : (
<div className="table-wrap"><table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<th style={{ textAlign: 'right', padding: '8px 16px', color: 'var(--text-3)', fontWeight: 500 }}>دوره</th>
<th style={{ textAlign: 'right', padding: '8px 16px', color: 'var(--text-3)', fontWeight: 500 }}>مدت</th>
<th style={{ textAlign: 'right', padding: '8px 16px', color: 'var(--text-3)', fontWeight: 500 }}>قیمت</th>
<th style={{ textAlign: 'right', padding: '8px 16px', color: 'var(--text-3)', fontWeight: 500 }}>نوع</th>
<th style={{ textAlign: 'right', padding: '8px 16px', color: 'var(--text-3)', fontWeight: 500 }}>عملیات</th>
</tr>
</thead>
<tbody>
{periods.map((period, i) => (
<tr key={period.uuid} style={{ borderBottom: i < periods.length - 1 ? '1px solid var(--border)' : 'none' }}>
<td style={{ padding: '8px 16px' }}><b>{period.label}</b></td>
<td style={{ padding: '8px 16px' }}>{period.duration_months} ماه</td>
<td style={{ padding: '8px 16px' }}>{period.is_trial ? 'رایگان' : formatRial(period.price_rials)}</td>
<td style={{ padding: '8px 16px' }}>
{period.is_trial ? <span className="badge amber">تریال</span> : <span className="badge blue">پولی</span>}
</td>
<td style={{ padding: '8px 16px' }}>
<div style={{ display: 'flex', gap: 4 }}>
<button className="btn sm" onClick={() => openEditPeriod(period)}>
<PencilIcon style={{ width: 13 }} />
</button>
<button className="btn sm" onClick={() => setDeletePeriod(period)}>
<TrashIcon style={{ width: 13 }} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table></div>
)}
</div>
);
})}
</div>
)}
{/* Modal پنل */}
<Modal
open={planModal !== null}
onClose={() => setPlanModal(null)}
title={planModal === 'create' ? 'پلن جدید' : 'ویرایش پلن'}
>
<form onSubmit={planForm.handleSubmit((d) => {
const body = toPlanPayload(d);
if (planModal === 'create') createPlanMut.mutate(body);
else if (planModal !== null && typeof planModal === 'object') updatePlanMut.mutate({ uuid: planModal.uuid, body });
})}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div className="field">
<label>نام (slug) *</label>
<input {...planForm.register('name')} placeholder="basic" dir="ltr" />
{planForm.formState.errors.name && <span className="field-error">{planForm.formState.errors.name.message}</span>}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="field">
<label>سطح *</label>
<input {...numericField(planForm.register('level'))} />
</div>
<div className="field">
<label>حداکثر منشی *</label>
<input {...numericField(planForm.register('max_secretaries'))} />
</div>
</div>
<div className="field">
<label htmlFor="plan-max-resources">حداکثر منبع *</label>
<input
id="plan-max-resources"
{...numericField(planForm.register('max_resources'))}
disabled={unlimitedResources}
style={unlimitedResources ? { opacity: 0.5 } : undefined}
/>
<label style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, fontSize: 13.5, cursor: 'pointer', marginTop: 8 }}>
<span style={{ color: 'var(--text)' }}>منابع نامحدود</span>
<SwitchToggle {...planForm.register('resources_unlimited')} />
</label>
<span className="field-hint">سقف اتاق و دستگاه و پرسنلِ هر محیط. منابعِ پزشک و پرسنل هم در این شمارش می‌آیند.</span>
{planForm.formState.errors.max_resources && (
<span className="field-error">{planForm.formState.errors.max_resources.message}</span>
)}
</div>
<div>
<span className="field-label">قابلیت‌های پلن</span>
<div style={{ display: 'flex', flexDirection: 'column', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', background: 'var(--surface)', overflow: 'hidden' }}>
{FEATURE_KEYS.map((k, i) => (
<label key={k} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, fontSize: 13.5, cursor: 'pointer', padding: '11px 14px', borderTop: i ? '1px solid var(--border)' : 'none' }}>
<span style={{ color: 'var(--text)' }}>{FEATURE_LABELS[k]}</span>
<SwitchToggle {...planForm.register(`features.${k}` as any)} />
</label>
))}
</div>
</div>
<label style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, fontSize: 13.5, cursor: 'pointer', padding: '11px 14px', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', background: 'var(--surface)' }}>
<span style={{ color: 'var(--text)', fontWeight: 600 }}>پلن فعال است</span>
<SwitchToggle {...planForm.register('active')} />
</label>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
<button type="submit" className="btn primary" disabled={createPlanMut.isPending || updatePlanMut.isPending}>ذخیره</button>
<button type="button" className="btn" onClick={() => setPlanModal(null)}>انصراف</button>
</div>
</form>
</Modal>
{/* Modal دوره */}
<Modal
open={periodModal !== null}
onClose={() => setPeriodModal(null)}
title={periodModal === 'create' ? `دوره جدید — ${PLAN_DISPLAY[activePlan?.name ?? ''] ?? activePlan?.name ?? ''}` : 'ویرایش دوره'}
>
<form onSubmit={periodForm.handleSubmit((d) => {
if (periodModal === 'create') createPeriodMut.mutate(d);
else if (periodModal !== null && typeof periodModal === 'object') updatePeriodMut.mutate({ uuid: periodModal.uuid, body: d });
})}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div className="field">
<label>عنوان دوره *</label>
<input {...periodForm.register('label')} placeholder="مثلاً: یک ماهه" />
{periodForm.formState.errors.label && <span className="field-error">{periodForm.formState.errors.label.message}</span>}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="field">
<label>مدت (ماه) *</label>
<input {...numericField(periodForm.register('duration_months'))} />
</div>
<div className="field">
<label>قیمت (ریال) *</label>
<PriceInput
value={periodForm.watch('price_rials') ?? ''}
onChange={(v) => periodForm.setValue('price_rials', v)}
ariaLabel="قیمت دوره (ریال)"
/>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="field">
<label>ترتیب نمایش</label>
<input {...numericField(periodForm.register('sort_order'))} />
</div>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13.5, cursor: 'pointer' }}>
<input type="checkbox" {...periodForm.register('is_trial')} style={{ accentColor: 'var(--primary)' }} />
تریال (رایگان)
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13.5, cursor: 'pointer' }}>
<input type="checkbox" {...periodForm.register('active')} style={{ accentColor: 'var(--primary)' }} />
فعال
</label>
</div>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
<button type="submit" className="btn primary" disabled={createPeriodMut.isPending || updatePeriodMut.isPending}>ذخیره</button>
<button type="button" className="btn" onClick={() => setPeriodModal(null)}>انصراف</button>
</div>
</form>
</Modal>
{/* Confirm حذف دوره */}
<ConfirmDialog
open={!!deletePeriod}
title="حذف دوره"
message={`آیا مطمئن هستید که می‌خواهید دوره «${deletePeriod?.label}» را حذف کنید؟`}
confirmLabel="حذف"
danger
loading={deletePeriodMut.isPending}
onConfirm={() => 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<EntityType>('doctor');
const [entityUuid, setEntityUuid] = useState<string | null>(null);
const [periodUuid, setPeriodUuid] = useState<string | null>(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<PaginatedResponse<EntityRow>>(
`/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<PaginatedResponse<SubscriptionPlan>>('/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 (
<>
<div className="card card-pad" style={{ maxWidth: 560 }}>
<form onSubmit={onSubmit}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="field-block">
<label>نوع مقصد</label>
<div className="seg">
<button type="button" className={entityType === 'doctor' ? 'on' : ''} onClick={() => changeEntityType('doctor')}>پزشک</button>
<button type="button" className={entityType === 'clinic' ? 'on' : ''} onClick={() => changeEntityType('clinic')}>کلینیک</button>
</div>
</div>
<div className="field-block">
<label htmlFor="grant-entity">{entityType === 'doctor' ? 'پزشک' : 'کلینیک'} <span className="req">*</span></label>
<SearchableSelect
inputId="grant-entity"
options={entityOptions}
value={entityUuid}
onChange={(v) => setEntityUuid(v === null ? null : String(v))}
onInputChange={setSearchInput}
isLoading={entitiesLoading}
isClearable
placeholder="نام یا شماره موبایل را بنویسید..."
ariaLabelledBy="grant-entity-label"
/>
<span className="field-hint">برای یافتن مقصد، بخشی از نام یا شمارهٔ موبایل را تایپ کنید.</span>
</div>
{entityUuid !== null && (
<div style={{ background: 'var(--surface-2)', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', padding: '11px 14px', fontSize: 13 }}>
{activeLoading ? (
<span style={{ color: 'var(--text-3)' }}>در حال بررسی اشتراک فعلی...</span>
) : activeSub === null ? (
<span style={{ color: 'var(--text-3)' }}>این مقصد اشتراک فعالی ندارد.</span>
) : (
<span style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ color: 'var(--text-2)' }}>اشتراک فعلی:</span>
<b>{PLAN_DISPLAY[activeSub.plan.name] ?? activeSub.plan.name}</b>
{activeSub.is_trial && <span className="badge amber">تریال</span>}
{activeSub.is_granted && <span className="badge violet">اعطایی</span>}
<span style={{ color: 'var(--text-2)' }}>
انقضا: {activeSub.expires_at ? formatDate(activeSub.expires_at) : 'بی‌نهایت'}
</span>
</span>
)}
</div>
)}
<div className="field-block">
<label htmlFor="grant-period">پلن و دوره <span className="req">*</span></label>
<SearchableSelect
inputId="grant-period"
options={periodOptions}
value={periodUuid}
onChange={(v) => setPeriodUuid(v === null ? null : String(v))}
isClearable
placeholder="انتخاب کنید..."
ariaLabel="پلن و دوره اشتراک"
/>
<span className="field-hint">
فقط دوره‌های پولی نمایش داده می‌شوند. اگر مقصد اشتراک فعال دارد، مدت روی انقضای فعلی افزوده می‌شود، نه از امروز.
</span>
</div>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 20 }}>
<button type="submit" className="btn primary" disabled={!entityUuid || !periodUuid || grantMut.isPending}>
اعطای اشتراک
</button>
</div>
</form>
</div>
<ConfirmDialog
open={downgrade !== null}
title="کاهش سطح پلن"
message={`پلن این ${entityType === 'doctor' ? 'پزشک' : 'کلینیک'} از «${downgrade?.from}» به «${downgrade?.to}» کاهش می‌یابد. ادامه می‌دهید؟`}
confirmLabel="اعطا کن"
danger
loading={grantMut.isPending}
onConfirm={submitGrant}
onCancel={() => 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<PaginatedResponse<ReportRow>>(`/api/v1/admin/subscription/report?page=${page}&limit=${limit}`),
});
const rows: ReportRow[] = data?.data ?? [];
const total = data?.meta?.totalRecords ?? 0;
return (
<div className="card">
{isLoading ? (
<div className="card-pad" style={{ color: 'var(--text-3)' }}>در حال بارگذاری...</div>
) : rows.length === 0 ? (
<div className="card-pad" style={{ color: 'var(--text-3)', textAlign: 'center', padding: '40px 0' }}>هیچ اشتراکی ثبت نشده</div>
) : (
<>
<div className="table-wrap"><table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>مقصد</th>
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>پلن</th>
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>نوع اشتراک</th>
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>شروع</th>
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>انقضا</th>
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>تاریخ ثبت</th>
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={row.uuid} style={{ borderBottom: i < rows.length - 1 ? '1px solid var(--border)' : 'none' }}>
<td style={{ padding: '10px 16px' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className={`badge ${row.entityType === 'clinic' ? 'blue' : 'green'}`}>
{row.entityType === 'clinic' ? 'کلینیک' : 'پزشک'}
</span>
<b>{row.entityName ?? `#${row.entityId}`}</b>
</span>
</td>
<td style={{ padding: '10px 16px' }}>
<b>{PLAN_DISPLAY[row.plan_name] ?? row.plan_name}</b>
<span className="muted" style={{ fontSize: 11, marginRight: 6 }}>سطح {row.plan_level}</span>
</td>
<td style={{ padding: '10px 16px' }}>
{row.isTrial ? (
<span className="badge amber">تریال</span>
) : row.isGranted ? (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className="badge violet">اعطایی</span>
{row.grantedBy && <span className="muted" style={{ fontSize: 11 }}>{row.grantedBy}</span>}
</span>
) : (
<span className="badge blue">پولی</span>
)}
</td>
<td style={{ padding: '10px 16px', color: 'var(--text-2)' }}>{formatDate(row.startsAt)}</td>
<td style={{ padding: '10px 16px', color: 'var(--text-2)' }}>
{row.expiresAt ? formatDate(row.expiresAt) : <span className="muted">بی‌نهایت</span>}
</td>
<td style={{ padding: '10px 16px', color: 'var(--text-3)' }}>{formatDate(row.createdAt)}</td>
</tr>
))}
</tbody>
</table></div>
<div style={{ padding: '12px 16px' }}>
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
</div>
</>
)}
</div>
);
}
// ── 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 (
<>
<PageHeader title="مدیریت اشتراک‌ها" description="تعریف پلن‌ها و دوره‌ها، اعطای اشتراک و گزارش فروش" />
<div className="seg" style={{ marginBottom: 20 }}>
<button className={tab === 'plans' ? 'on' : ''} onClick={() => setTab('plans')}>پلن‌ها و دوره‌ها</button>
<button className={tab === 'grant' ? 'on' : ''} onClick={() => setTab('grant')}>اعطای اشتراک</button>
<button className={tab === 'report' ? 'on' : ''} onClick={() => setTab('report')}>گزارش فروش</button>
</div>
{tab === 'plans' && <PlansTab />}
{tab === 'grant' && <GrantTab />}
{tab === 'report' && <ReportTab />}
</>
);
}