- Replaced raw input fields for pricing with PriceInput component across various forms and modals to ensure consistent formatting and accessibility. - Updated tests to reflect changes in pricing input handling, ensuring values are displayed in toman with proper formatting. - Enhanced accessibility by adding aria-labels and aria-invalid attributes to PriceInput components. - Adjusted UI elements to improve layout and user experience, particularly in forms related to service items and scheduling. - Changed labels from "نمایش در نوبتدهی" to "نمایش در نوبتدهی آنلاین" for clarity.
504 lines
26 KiB
TypeScript
504 lines
26 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 Pagination from '../components/ui/Pagination';
|
|
import { numericField } from '../lib/forms';
|
|
|
|
// ── Types ─────────────────────────────────────────────────────────────────
|
|
|
|
interface ReportRow {
|
|
uuid: string;
|
|
entityType: string;
|
|
entityId: number;
|
|
isTrial: boolean;
|
|
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)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ── 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 className={`badge ${row.entityType === 'clinic' ? 'blue' : 'green'}`}>
|
|
{row.entityType === 'clinic' ? 'کلینیک' : 'دکتر'} #{row.entityId}
|
|
</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> : <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() {
|
|
const [tab, setTab] = useState<'plans' | 'report'>('plans');
|
|
|
|
return (
|
|
<>
|
|
<PageHeader title="مدیریت اشتراکها" description="تعریف پلنها، دورهها و گزارش فروش" />
|
|
|
|
<div className="seg" style={{ marginBottom: 20 }}>
|
|
<button className={tab === 'plans' ? 'on' : ''} onClick={() => setTab('plans')}>پلنها و دورهها</button>
|
|
<button className={tab === 'report' ? 'on' : ''} onClick={() => setTab('report')}>گزارش فروش</button>
|
|
</div>
|
|
|
|
{tab === 'plans' && <PlansTab />}
|
|
{tab === 'report' && <ReportTab />}
|
|
</>
|
|
);
|
|
}
|