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.
This commit is contained in:
hamed
2026-08-09 13:43:30 +03:30
parent 60ccd5cc1d
commit a6a965a2aa
10 changed files with 874 additions and 29 deletions
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { render, screen, fireEvent } from '@testing-library/react';
import SearchableSelect from './SearchableSelect';
vi.mock('../../stores/uiStore', () => ({ useUiStore: () => false }));
@@ -53,3 +53,44 @@ describe('SearchableSelect — نام دسترس‌پذیر', () => {
expect(screen.getByRole('combobox', { name: 'شهر' })).toBeInTheDocument();
});
});
describe('SearchableSelect — جستجوی سمت سرور', () => {
it('با تایپ، onInputChange صدا زده می‌شود', () => {
const onInputChange = vi.fn();
render(<SearchableSelect options={[]} onChange={() => {}} inputId="srv" onInputChange={onInputChange} />);
fireEvent.change(document.getElementById('srv') as HTMLInputElement, { target: { value: 'رضا' } });
expect(onInputChange).toHaveBeenCalledWith('رضا');
});
/** نتیجهٔ سرور نباید دوباره روی متنِ تایپ‌شده فیلتر شود. */
it('در حالت سرور، گزینه‌ای که با متن تایپ‌شده نمی‌خواند هم می‌ماند', () => {
render(
<SearchableSelect
options={[{ value: 'd1', label: 'دکتر رضایی' }]}
onChange={() => {}}
inputId="srv2"
onInputChange={() => {}}
/>,
);
const input = document.getElementById('srv2') as HTMLInputElement;
fireEvent.focus(input);
fireEvent.keyDown(input, { key: 'ArrowDown' });
fireEvent.change(input, { target: { value: '0912' } });
expect(screen.getByText('دکتر رضایی')).toBeInTheDocument();
});
it('بدون onInputChange، فیلتر داخلی سر جایش می‌ماند', () => {
render(<SearchableSelect options={[{ value: 'd1', label: 'دکتر رضایی' }]} onChange={() => {}} inputId="srv3" />);
const input = document.getElementById('srv3') as HTMLInputElement;
fireEvent.focus(input);
fireEvent.keyDown(input, { key: 'ArrowDown' });
fireEvent.change(input, { target: { value: '0912' } });
expect(screen.queryByText('دکتر رضایی')).not.toBeInTheDocument();
});
});
@@ -26,6 +26,14 @@ interface Props {
ariaLabel?: string;
/** اگر label قابل‌مشاهده‌ای وجود دارد، id آن را بده (بر ariaLabel اولویت دارد). */
ariaLabelledBy?: string;
/**
* جستجوی سمت سرور: با هر تایپ صدا زده می‌شود تا مصرف‌کننده `options` تازه بدهد.
*
* وقتی داده می‌شود، فیلترِ داخلی react-select خاموش می‌شود؛ وگرنه نتیجهٔ سرور
* دوباره روی متنِ تایپ‌شده فیلتر می‌شد و گزینه‌هایی که سرور با معیارِ دیگری
* (مثلاً شمارهٔ موبایل) پیدا کرده بود ناپدید می‌شدند.
*/
onInputChange?: (input: string) => void;
}
export default function SearchableSelect({
@@ -41,6 +49,7 @@ export default function SearchableSelect({
height = 42,
ariaLabel,
ariaLabelledBy,
onInputChange,
}: Props) {
const darkMode = useUiStore((s) => s.darkMode);
@@ -121,6 +130,12 @@ export default function SearchableSelect({
isLoading={isLoading}
isDisabled={isDisabled}
isClearable={isClearable}
onInputChange={onInputChange ? (input, meta) => {
// react-select ورودی را هنگام بستن منو و blur هم «تغییر» می‌داند؛ آن دو را
// رد نکنیم، هر بار بستنِ منو لیست را به حالت خالی برمی‌گرداند.
if (meta.action === 'input-change') { onInputChange(input); }
} : undefined}
filterOption={onInputChange ? null : undefined}
styles={styles}
isRtl
menuPortalTarget={typeof document !== 'undefined' ? document.body : undefined}
@@ -13,6 +13,7 @@ import AdminSubscriptionPage from './AdminSubscriptionPage';
const get = api.get as ReturnType<typeof vi.fn>;
const patch = api.patch as ReturnType<typeof vi.fn>;
const post = api.post as ReturnType<typeof vi.fn>;
const PLANS = [
{
@@ -25,6 +26,23 @@ const PLANS = [
},
];
/** همان پلن‌ها، با دوره — تب «اعطای اشتراک» فهرست دوره‌ها را از همین‌جا می‌سازد. */
const PLANS_WITH_PERIODS = [
{
...PLANS[0],
periods: [
{ uuid: 'per-basic-1', label: 'یک ماهه', duration_months: 1, price_rials: 1000000, is_trial: false },
{ uuid: 'per-basic-trial', label: 'آزمایشی', duration_months: 1, price_rials: 0, is_trial: true },
],
},
{
...PLANS[1],
periods: [
{ uuid: 'per-pro-1', label: 'یک ماهه', duration_months: 1, price_rials: 3000000, is_trial: false },
],
},
];
function mockApi() {
get.mockImplementation((url: string) => {
if (url.includes('/admin/subscription/plans')) return Promise.resolve({ success: true, data: PLANS });
@@ -85,3 +103,142 @@ describe('AdminSubscriptionPage — سقف منابع پلن', () => {
expect(await screen.findByLabelText('حداکثر منبع *')).toBeDisabled();
});
});
// ── اعطای اشتراک ──────────────────────────────────────────────────────────
const DOCTORS = [{ uuid: 'doc-1', name: 'دکتر رضایی', mobile: '09120000001' }];
/** پاسخ `admin/subscription/active` — `null` یعنی مقصد اشتراک فعالی ندارد. */
function mockGrantApi(activeSubscription: unknown = null) {
get.mockImplementation((url: string) => {
if (url.includes('/admin/subscription/plans')) return Promise.resolve({ success: true, data: PLANS_WITH_PERIODS });
if (url.includes('/admin/subscription/active')) return Promise.resolve({ success: true, data: { subscription: activeSubscription } });
if (url.includes('/admin/doctors')) return Promise.resolve({ success: true, data: DOCTORS, meta: { totalRecords: 1 } });
if (url.includes('/admin/clinics')) return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0 } });
return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0 } });
});
post.mockResolvedValue({ success: true, data: {} });
}
/** انتخاب گزینه از SearchableSelect با inputId */
async function pickOption(inputId: string, option: string) {
const input = document.getElementById(inputId) as HTMLInputElement;
fireEvent.focus(input);
fireEvent.keyDown(input, { key: 'ArrowDown' });
fireEvent.click(await screen.findByText(option));
}
async function openGrantTab() {
renderWithProviders(<AdminSubscriptionPage />, { route: '/admin/admin-subscription' });
// تبِ «اعطای اشتراک» و دکمهٔ ثبتِ فرم هم‌نام‌اند؛ تب همیشه اولی است.
fireEvent.click((await screen.findAllByText('اعطای اشتراک'))[0]);
}
/** دکمهٔ ثبتِ فرم اعطا — با نقش تنها قابل تفکیک نیست، چون تب هم‌نام است. */
function submitGrantForm() {
fireEvent.click(document.querySelector('button[type="submit"]') as HTMLButtonElement);
}
describe('AdminSubscriptionPage — اعطای اشتراک', () => {
beforeEach(() => { get.mockReset(); post.mockReset(); });
it('برای مقصد بدون اشتراک، دوره را می‌فرستد و پرداختی در کار نیست', async () => {
mockGrantApi(null);
await openGrantTab();
await pickOption('grant-entity', 'دکتر رضایی — 09120000001');
expect(await screen.findByText('این مقصد اشتراک فعالی ندارد.')).toBeInTheDocument();
await pickOption('grant-period', 'حرفه‌ای — یک ماهه (۳۰۰٬۰۰۰ تومان)');
submitGrantForm();
await waitFor(() => expect(post).toHaveBeenCalled());
expect(post.mock.calls[0][0]).toBe('/api/v1/admin/subscription/grant');
expect(post.mock.calls[0][1]).toEqual({ entity_type: 'doctor', entity_uuid: 'doc-1', period_uuid: 'per-pro-1' });
});
/** دورهٔ تریال، تریالِ نگرفتهٔ کاربر را می‌سوزاند؛ نباید در فهرست باشد. */
it('دوره‌های تریال در فهرست اعطا نمی‌آیند', async () => {
mockGrantApi(null);
await openGrantTab();
const input = document.getElementById('grant-period') as HTMLInputElement;
fireEvent.focus(input);
fireEvent.keyDown(input, { key: 'ArrowDown' });
expect(await screen.findByText('پایه — یک ماهه (۱۰۰٬۰۰۰ تومان)')).toBeInTheDocument();
expect(screen.queryByText(/آزمایشی/)).not.toBeInTheDocument();
});
it('اشتراک فعالِ مقصد را قبل از اعطا نشان می‌دهد', async () => {
mockGrantApi({ plan: { name: 'professional', level: 2 }, expires_at: 1800000000, is_trial: false, is_granted: true });
await openGrantTab();
await pickOption('grant-entity', 'دکتر رضایی — 09120000001');
expect(await screen.findByText('حرفه‌ای')).toBeInTheDocument();
expect(screen.getByText('اعطایی')).toBeInTheDocument();
});
it('پلن پایین‌تر از پلن فعال، اول تأیید می‌خواهد', async () => {
mockGrantApi({ plan: { name: 'professional', level: 2 }, expires_at: 1800000000, is_trial: false, is_granted: false });
await openGrantTab();
await pickOption('grant-entity', 'دکتر رضایی — 09120000001');
await screen.findByText(/اشتراک فعلی/);
await pickOption('grant-period', 'پایه — یک ماهه (۱۰۰٬۰۰۰ تومان)');
submitGrantForm();
expect(await screen.findByText('کاهش سطح پلن')).toBeInTheDocument();
expect(post).not.toHaveBeenCalled();
fireEvent.click(screen.getByText('اعطا کن'));
await waitFor(() => expect(post).toHaveBeenCalled());
expect(post.mock.calls[0][1]).toMatchObject({ period_uuid: 'per-basic-1' });
});
it('ارتقا به پلن بالاتر بدون تأیید اضافه ثبت می‌شود', async () => {
mockGrantApi({ plan: { name: 'basic', level: 1 }, expires_at: 1800000000, is_trial: false, is_granted: false });
await openGrantTab();
await pickOption('grant-entity', 'دکتر رضایی — 09120000001');
await screen.findByText(/اشتراک فعلی/);
await pickOption('grant-period', 'حرفه‌ای — یک ماهه (۳۰۰٬۰۰۰ تومان)');
submitGrantForm();
await waitFor(() => expect(post).toHaveBeenCalled());
expect(screen.queryByText('کاهش سطح پلن')).not.toBeInTheDocument();
});
});
describe('AdminSubscriptionPage — گزارش', () => {
beforeEach(() => { get.mockReset(); post.mockReset(); });
it('اشتراک اعطایی را «اعطایی» نشان می‌دهد، نه «پولی»', async () => {
get.mockImplementation((url: string) => {
if (url.includes('/admin/subscription/plans')) return Promise.resolve({ success: true, data: PLANS });
if (url.includes('/admin/subscription/report')) {
return Promise.resolve({
success: true,
meta: { totalRecords: 1 },
data: [{
uuid: 's-1', entityType: 'doctor', entityId: 4, entityName: 'دکتر رضایی',
isTrial: false, isGranted: true, grantedBy: 'ادمین',
startsAt: 1700000000, expiresAt: 1800000000, createdAt: 1700000000,
plan_name: 'professional', plan_level: 2,
}],
});
}
return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0 } });
});
renderWithProviders(<AdminSubscriptionPage />, { route: '/admin/admin-subscription' });
fireEvent.click(await screen.findByText('گزارش فروش'));
expect(await screen.findByText('اعطایی')).toBeInTheDocument();
expect(screen.getByText('دکتر رضایی')).toBeInTheDocument();
expect(screen.getByText('ادمین')).toBeInTheDocument();
expect(screen.queryByText('پولی')).not.toBeInTheDocument();
});
});
+231 -6
View File
@@ -13,8 +13,10 @@ 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 ─────────────────────────────────────────────────────────────────
@@ -22,7 +24,10 @@ 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;
@@ -416,6 +421,209 @@ function PlansTab() {
);
}
// ── 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() {
@@ -441,7 +649,7 @@ function ReportTab() {
<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>
@@ -453,8 +661,11 @@ function ReportTab() {
{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 className={`badge ${row.entityType === 'clinic' ? 'blue' : 'green'}`}>
{row.entityType === 'clinic' ? 'کلینیک' : 'دکتر'} #{row.entityId}
<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' }}>
@@ -462,7 +673,16 @@ function ReportTab() {
<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> : <span className="badge blue">پولی</span>}
{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)' }}>
@@ -485,18 +705,23 @@ function ReportTab() {
// ── Main ──────────────────────────────────────────────────────────────────
export default function AdminSubscriptionPage() {
const [tab, setTab] = useState<'plans' | 'report'>('plans');
// تب در URL می‌نشیند، نه در state: بازگشت از صفحهٔ دیگر باید همان تب را برگرداند.
const [urlState, setUrlState] = useUrlState({ tab: 'plans' });
const tab = urlState.tab;
const setTab = (next: string) => setUrlState({ tab: next });
return (
<>
<PageHeader title="مدیریت اشتراک‌ها" description="تعریف پلن‌ها، دوره‌ها و گزارش فروش" />
<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 />}
</>
);