Files
clinicpro/assets/admin/pages/AdminSubscriptionPage.tsx
T

492 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 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 ─────────────────────────────────────────────────────────
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>
<input {...numericField(periodForm.register('price_rials'))} />
</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 />}
</>
);
}