feat: add Staff, Subscription, ClinicServices, SmsWallet pages (TASK-10,11,13,14)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
7ea3523830
commit
ed18c260ca
@@ -0,0 +1,238 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { CheckIcon, SparklesIcon } from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import type { SubscriptionPlan, MySubscription, SubscriptionPeriod } from '../types';
|
||||
import { formatDate, formatRial, formatNumber } from '../lib/utils';
|
||||
import Modal from '../components/ui/Modal';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
|
||||
const PLAN_FEATURE_LABELS: Record<string, string> = {
|
||||
patient_records: 'پرونده بیمار',
|
||||
services: 'مدیریت سرویسها',
|
||||
sms_panel: 'پنل پیامک',
|
||||
};
|
||||
|
||||
const PLAN_DISPLAY: Record<string, { label: string; color: string }> = {
|
||||
free: { label: 'رایگان', color: '#64748b' },
|
||||
basic: { label: 'پایه', color: '#3b82f6' },
|
||||
professional: { label: 'حرفهای', color: '#8b5cf6' },
|
||||
};
|
||||
|
||||
const GATEWAY_LABELS: Record<string, string> = { mellat: 'ملت', sep: 'سپ' };
|
||||
|
||||
export default function SubscriptionPage() {
|
||||
const qc = useQueryClient();
|
||||
const [purchaseTarget, setPurchaseTarget] = useState<SubscriptionPeriod | null>(null);
|
||||
const [selectedGateway, setSelectedGateway] = useState<'mellat' | 'sep'>('mellat');
|
||||
|
||||
const { data: plansData, isLoading: plansLoading } = useQuery<ApiResponse<SubscriptionPlan[]>>({
|
||||
queryKey: ['subscription-plans'],
|
||||
queryFn: () => api.get('/api/v1/subscription/plans'),
|
||||
});
|
||||
|
||||
const { data: myData } = useQuery<ApiResponse<MySubscription>>({
|
||||
queryKey: ['subscription-my'],
|
||||
queryFn: () => api.get('/api/v1/subscription/my'),
|
||||
});
|
||||
|
||||
const plans = plansData?.data ?? [];
|
||||
const my = myData?.data;
|
||||
|
||||
const trialMutation = useMutation({
|
||||
mutationFn: () => api.post('/api/v1/subscription/trial', {}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['subscription-my'] });
|
||||
toast.success('تریال رایگان با موفقیت فعال شد');
|
||||
},
|
||||
onError: (err: any) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const purchaseMutation = useMutation({
|
||||
mutationFn: ({ period_uuid, gateway }: { period_uuid: string; gateway: string }) =>
|
||||
api.post<{ data: { payment_url: string } }>('/api/v1/subscription-payment', { period_uuid, gateway }),
|
||||
onSuccess: (res: any) => {
|
||||
const url = res?.data?.payment_url;
|
||||
if (url) window.location.href = url;
|
||||
else toast.error('خطا در دریافت لینک پرداخت');
|
||||
},
|
||||
onError: (err: any) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const daysTotal = my?.expires_at && my?.starts_at
|
||||
? Math.round((my.expires_at - my.starts_at) / 86400)
|
||||
: null;
|
||||
const daysProgress = daysTotal && my?.days_remaining != null
|
||||
? Math.max(0, Math.min(100, ((daysTotal - my.days_remaining) / daysTotal) * 100))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="پنل اشتراکی" description="مدیریت اشتراک و ارتقای پنل" />
|
||||
|
||||
{/* وضعیت اشتراک فعلی */}
|
||||
{my && (
|
||||
<div className="card" style={{ marginBottom: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
||||
<SparklesIcon style={{ width: 22, color: PLAN_DISPLAY[my.plan.name]?.color ?? '#64748b' }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: 16 }}>
|
||||
پنل {PLAN_DISPLAY[my.plan.name]?.label ?? my.plan.name}
|
||||
{my.is_trial && <span className="badge amber" style={{ marginRight: 8, fontSize: 11 }}>تریال</span>}
|
||||
</div>
|
||||
{my.expires_at
|
||||
? <div style={{ fontSize: 13, color: 'var(--text-3)' }}>
|
||||
انقضا: {formatDate(my.expires_at)} — {formatNumber(my.days_remaining ?? 0)} روز باقیمانده
|
||||
</div>
|
||||
: <div style={{ fontSize: 13, color: 'var(--text-3)' }}>بدون تاریخ انقضا</div>
|
||||
}
|
||||
</div>
|
||||
{!my.used_trial && (
|
||||
<button
|
||||
className="btn primary sm"
|
||||
style={{ marginRight: 'auto' }}
|
||||
onClick={() => trialMutation.mutate()}
|
||||
disabled={trialMutation.isPending}
|
||||
>
|
||||
{trialMutation.isPending ? 'در حال فعالسازی...' : 'فعالسازی تریال رایگان'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{daysProgress !== null && (
|
||||
<div style={{ background: 'var(--surface-2)', borderRadius: 6, height: 6, overflow: 'hidden' }}>
|
||||
<div style={{ width: `${daysProgress}%`, height: '100%', background: PLAN_DISPLAY[my.plan.name]?.color ?? '#3b82f6', transition: 'width .3s' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* کارتهای پلن */}
|
||||
{plansLoading ? (
|
||||
<div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 16 }}>
|
||||
{plans.map((plan) => (
|
||||
<PlanCard
|
||||
key={plan.uuid}
|
||||
plan={plan}
|
||||
currentPlanLevel={my?.plan.level ?? 0}
|
||||
usedTrial={my?.used_trial ?? false}
|
||||
onPurchase={(period) => setPurchaseTarget(period)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal پرداخت */}
|
||||
<Modal
|
||||
open={!!purchaseTarget}
|
||||
onClose={() => setPurchaseTarget(null)}
|
||||
title={`خرید ${purchaseTarget?.label ?? ''}`}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{(['mellat', 'sep'] as const).map((gw) => (
|
||||
<button
|
||||
key={gw}
|
||||
className={`btn ${selectedGateway === gw ? 'primary' : ''}`}
|
||||
onClick={() => setSelectedGateway(gw)}
|
||||
>
|
||||
{GATEWAY_LABELS[gw]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p style={{ margin: 0, color: 'var(--text-3)', fontSize: 14 }}>
|
||||
مبلغ: <b style={{ color: 'var(--text-1)' }}>{purchaseTarget ? formatRial(purchaseTarget.price_rials) : ''}</b>
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
className="btn primary"
|
||||
disabled={purchaseMutation.isPending}
|
||||
onClick={() =>
|
||||
purchaseTarget &&
|
||||
purchaseMutation.mutate({ period_uuid: purchaseTarget.uuid, gateway: selectedGateway })
|
||||
}
|
||||
>
|
||||
{purchaseMutation.isPending ? 'در حال انتقال...' : 'پرداخت'}
|
||||
</button>
|
||||
<button className="btn" onClick={() => setPurchaseTarget(null)}>انصراف</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanCard({
|
||||
plan,
|
||||
currentPlanLevel,
|
||||
usedTrial,
|
||||
onPurchase,
|
||||
}: {
|
||||
plan: SubscriptionPlan;
|
||||
currentPlanLevel: number;
|
||||
usedTrial: boolean;
|
||||
onPurchase: (period: SubscriptionPeriod) => void;
|
||||
}) {
|
||||
const display = PLAN_DISPLAY[plan.name] ?? { label: plan.name, color: '#64748b' };
|
||||
const isCurrent = plan.level === currentPlanLevel;
|
||||
const paidPeriods = plan.periods.filter((p) => !p.is_trial);
|
||||
const trialPeriod = plan.periods.find((p) => p.is_trial);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="card"
|
||||
style={{ border: isCurrent ? `2px solid ${display.color}` : undefined, position: 'relative' }}
|
||||
>
|
||||
{isCurrent && (
|
||||
<span className="badge green" style={{ position: 'absolute', top: 12, left: 12, fontSize: 11 }}>
|
||||
پنل فعلی
|
||||
</span>
|
||||
)}
|
||||
<div style={{ fontWeight: 700, fontSize: 18, color: display.color, marginBottom: 8 }}>
|
||||
{display.label}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-3)', marginBottom: 12 }}>
|
||||
حداکثر {plan.max_secretaries} منشی
|
||||
</div>
|
||||
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 16px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{Object.entries(plan.features).map(([key, enabled]) => (
|
||||
<li key={key} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
|
||||
<CheckIcon style={{ width: 14, color: enabled ? '#22c55e' : '#94a3b8' }} />
|
||||
<span style={{ color: enabled ? 'var(--text-1)' : 'var(--text-3)' }}>
|
||||
{PLAN_FEATURE_LABELS[key] ?? key}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{paidPeriods.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{paidPeriods.map((period) => (
|
||||
<div key={period.uuid} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||
<span style={{ fontSize: 13 }}>{period.label}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 13 }}>{formatRial(period.price_rials)}</span>
|
||||
<button
|
||||
className="btn primary sm"
|
||||
onClick={() => onPurchase(period)}
|
||||
>
|
||||
{isCurrent ? 'تمدید' : 'خرید'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{trialPeriod && !usedTrial && plan.level > 0 && (
|
||||
<div style={{ marginTop: 12, fontSize: 12, color: 'var(--text-3)', borderTop: '1px solid var(--border)', paddingTop: 10 }}>
|
||||
تریال {trialPeriod.duration_months} ماهه رایگان — از دکمه بالای صفحه فعال کنید
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user