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:
@@ -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 />}
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user