feat(subscription): add resource quota management for subscription plans and update related components
This commit is contained in:
@@ -14,6 +14,16 @@ export function formatNumber(n: number): string {
|
||||
return new Intl.NumberFormat('fa-IR').format(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* سقف منابعِ یک پلن اشتراک به زبان آدم — `-1` یعنی نامحدود.
|
||||
*
|
||||
* سه صفحه همین عدد را نشان میدهند (منابع، اشتراک، اشتراکِ ادمین)؛ یک جا نوشته میشود
|
||||
* تا «نامحدود» در یکی از آنها به «-۱» تبدیل نشود.
|
||||
*/
|
||||
export function formatResourceLimit(max: number): string {
|
||||
return max < 0 ? 'نامحدود' : formatNumber(max);
|
||||
}
|
||||
|
||||
// سال شمسی: رقم فارسی بدون جداکنندهی هزارگان («۱۴۰۴» نه «۱٬۴۰۴»).
|
||||
export function formatYear(year: number): string {
|
||||
return new Intl.NumberFormat('fa-IR', { useGrouping: false }).format(year);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, fireEvent, waitFor, within } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../test/utils';
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock('../lib/api', () => ({
|
||||
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||
ApiError: class extends Error {},
|
||||
}));
|
||||
|
||||
import { api } from '../lib/api';
|
||||
import AdminSubscriptionPage from './AdminSubscriptionPage';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
const patch = api.patch as ReturnType<typeof vi.fn>;
|
||||
|
||||
const PLANS = [
|
||||
{
|
||||
uuid: 'p-basic', name: 'basic', level: 1, max_secretaries: 3, max_resources: 3,
|
||||
features: { patient_records: true, services: true, sms_panel: false }, active: true, periods: [],
|
||||
},
|
||||
{
|
||||
uuid: 'p-pro', name: 'professional', level: 2, max_secretaries: 10, max_resources: -1,
|
||||
features: { patient_records: true, services: true, sms_panel: true }, active: true, periods: [],
|
||||
},
|
||||
];
|
||||
|
||||
function mockApi() {
|
||||
get.mockImplementation((url: string) => {
|
||||
if (url.includes('/admin/subscription/plans')) return Promise.resolve({ success: true, data: PLANS });
|
||||
return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0 } });
|
||||
});
|
||||
patch.mockResolvedValue({ success: true, data: PLANS[0] });
|
||||
}
|
||||
|
||||
/** مودالِ ویرایشِ پلنِ دادهشده را باز میکند. هر کارت یک دکمهٔ مداد دارد. */
|
||||
async function openEdit(planLabel: string) {
|
||||
const card = (await screen.findByText(planLabel)).closest('.card') as HTMLElement;
|
||||
const buttons = within(card).getAllByRole('button');
|
||||
fireEvent.click(buttons[buttons.length - 1]);
|
||||
}
|
||||
|
||||
describe('AdminSubscriptionPage — سقف منابع پلن', () => {
|
||||
beforeEach(() => { get.mockReset(); patch.mockReset(); mockApi(); });
|
||||
|
||||
it('سقف منابع هر پلن را روی کارتش نشان میدهد', async () => {
|
||||
renderWithProviders(<AdminSubscriptionPage />, { route: '/admin/admin-subscription' });
|
||||
|
||||
expect(await screen.findByText('حداکثر ۳ منبع')).toBeInTheDocument();
|
||||
expect(screen.getByText('حداکثر نامحدود منبع')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('فرم ویرایش با مقدار فعلی پر میشود و همان را میفرستد', async () => {
|
||||
renderWithProviders(<AdminSubscriptionPage />, { route: '/admin/admin-subscription' });
|
||||
await openEdit('پایه');
|
||||
|
||||
const input = await screen.findByLabelText('حداکثر منبع *');
|
||||
expect(input).toHaveValue('3');
|
||||
|
||||
fireEvent.change(input, { target: { value: '5' } });
|
||||
fireEvent.click(screen.getByText('ذخیره'));
|
||||
|
||||
await waitFor(() => expect(patch).toHaveBeenCalled());
|
||||
expect(patch.mock.calls[0][0]).toBe('/api/v1/admin/subscription/plan/p-basic');
|
||||
expect(patch.mock.calls[0][1]).toMatchObject({ max_resources: 5 });
|
||||
});
|
||||
|
||||
/** ادمین «۱-» تایپ نمیکند؛ سوییچ همان قرارداد را میسازد. */
|
||||
it('سوییچ «منابع نامحدود» مقدار ۱- میفرستد', async () => {
|
||||
renderWithProviders(<AdminSubscriptionPage />, { route: '/admin/admin-subscription' });
|
||||
await openEdit('پایه');
|
||||
|
||||
await screen.findByLabelText('حداکثر منبع *');
|
||||
fireEvent.click(screen.getByText('منابع نامحدود'));
|
||||
fireEvent.click(screen.getByText('ذخیره'));
|
||||
|
||||
await waitFor(() => expect(patch).toHaveBeenCalled());
|
||||
expect(patch.mock.calls[0][1]).toMatchObject({ max_resources: -1 });
|
||||
});
|
||||
|
||||
it('پلن نامحدود با سوییچِ روشن باز میشود و فیلد عددیاش قفل است', async () => {
|
||||
renderWithProviders(<AdminSubscriptionPage />, { route: '/admin/admin-subscription' });
|
||||
await openEdit('حرفهای');
|
||||
|
||||
expect(await screen.findByLabelText('حداکثر منبع *')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ 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 } from '../lib/utils';
|
||||
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';
|
||||
@@ -35,11 +35,23 @@ 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, 'عنوان دوره الزامی است'),
|
||||
@@ -105,19 +117,21 @@ function PlansTab() {
|
||||
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: PlanForm) => api.post('/api/v1/admin/subscription/plan', body),
|
||||
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: PlanForm }) => api.patch(`/api/v1/admin/subscription/plan/${uuid}`, body),
|
||||
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),
|
||||
});
|
||||
@@ -145,6 +159,10 @@ function PlansTab() {
|
||||
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,
|
||||
});
|
||||
@@ -173,7 +191,7 @@ function PlansTab() {
|
||||
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 }); setPlanModal('create'); }}>
|
||||
<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>
|
||||
@@ -194,6 +212,7 @@ function PlansTab() {
|
||||
<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>
|
||||
@@ -260,8 +279,9 @@ function PlansTab() {
|
||||
title={planModal === 'create' ? 'پلن جدید' : 'ویرایش پلن'}
|
||||
>
|
||||
<form onSubmit={planForm.handleSubmit((d) => {
|
||||
if (planModal === 'create') createPlanMut.mutate(d);
|
||||
else if (planModal !== null && typeof planModal === 'object') updatePlanMut.mutate({ uuid: planModal.uuid, body: 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">
|
||||
@@ -279,6 +299,23 @@ function PlansTab() {
|
||||
<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' }}>
|
||||
|
||||
@@ -15,20 +15,20 @@ import SubscriptionPage from './SubscriptionPage';
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
|
||||
const PLANS = [
|
||||
{ uuid: 'p-free', name: 'free', level: 0, max_secretaries: 1,
|
||||
{ uuid: 'p-free', name: 'free', level: 0, max_secretaries: 1, max_resources: 1,
|
||||
features: { patient_records: false, services: false, sms_panel: false }, active: true, periods: [] },
|
||||
{ uuid: 'p-basic', name: 'basic', level: 1, max_secretaries: 3,
|
||||
{ uuid: 'p-basic', name: 'basic', level: 1, max_secretaries: 3, max_resources: 3,
|
||||
features: { patient_records: true, services: true, sms_panel: false }, active: true,
|
||||
periods: [
|
||||
{ uuid: 'per-1m', label: 'یک ماهه', duration_months: 1, price_rials: 1000000, is_trial: false },
|
||||
{ uuid: 'per-12m', label: 'یک ساله', duration_months: 12, price_rials: 9000000, is_trial: false },
|
||||
] },
|
||||
{ uuid: 'p-pro', name: 'professional', level: 2, max_secretaries: 10,
|
||||
{ uuid: 'p-pro', name: 'professional', level: 2, max_secretaries: 10, max_resources: -1,
|
||||
features: { patient_records: true, services: true, sms_panel: true }, active: true, periods: [] },
|
||||
];
|
||||
|
||||
const DEFAULT_MY = {
|
||||
subscription: { plan: { name: 'basic', level: 1, max_secretaries: 3, features: {} }, is_trial: false, days_remaining: 25 },
|
||||
subscription: { plan: { name: 'basic', level: 1, max_secretaries: 3, max_resources: 3, features: {} }, is_trial: false, days_remaining: 25 },
|
||||
used_trial: true, effective_plan: null,
|
||||
};
|
||||
|
||||
@@ -54,6 +54,16 @@ describe('SubscriptionPage', () => {
|
||||
expect(proCard.getByText('محبوبترین')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
/** سقف منابع کنار سقف منشی روی هر کارت پلن مینشیند؛ `-1` یعنی «نامحدود». */
|
||||
it('shows the resource quota on every plan card', async () => {
|
||||
renderWithProviders(<SubscriptionPage />, { route: '/admin/subscription' });
|
||||
|
||||
await screen.findByText('پلن پایه');
|
||||
expect(within(screen.getByTestId('plan-card-free')).getByText('۱ منبع')).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId('plan-card-basic')).getByText('۳ منبع')).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId('plan-card-professional')).getByText('نامحدود منبع')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the current-plan card without an expiry warning when the plan is healthy', async () => {
|
||||
renderWithProviders(<SubscriptionPage />, { route: '/admin/subscription' });
|
||||
expect(await screen.findByText('پلن فعلی شما:')).toBeInTheDocument();
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { CreditCardIcon } from '@heroicons/react/24/outline';
|
||||
import { CreditCardIcon, CubeIcon } from '@heroicons/react/24/outline';
|
||||
import { CheckCircleIcon } from '@heroicons/react/24/solid';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import type { SubscriptionPlan, MySubscriptionData, SubscriptionPeriod } from '../types';
|
||||
import { usePaymentConfig } from '../hooks/usePaymentConfig';
|
||||
import { formatRial, formatNumber, formatDate } from '../lib/utils';
|
||||
import { formatRial, formatNumber, formatDate, formatResourceLimit } from '../lib/utils';
|
||||
import Modal from '../components/ui/Modal';
|
||||
import SettingsLayout from '../components/layout/SettingsLayout';
|
||||
import {
|
||||
@@ -413,8 +413,8 @@ function PlanCard({
|
||||
{meta.desc}
|
||||
</div>
|
||||
|
||||
{/* Secretaries pill */}
|
||||
<div style={{ width: '100%', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
{/* Secretaries + resources pills — دو سقفِ عددیِ پلن، کنار هم */}
|
||||
<div style={{ width: '100%', display: 'flex', justifyContent: 'flex-end', gap: 6, flexWrap: 'wrap' }}>
|
||||
<div dir="rtl" style={{
|
||||
minWidth: 86, height: 32, borderRadius: 29, background: 'var(--surface-3)',
|
||||
color: 'var(--text)', display: 'flex', gap: 6, alignItems: 'center', justifyContent: 'center',
|
||||
@@ -425,6 +425,16 @@ function PlanCard({
|
||||
</span>
|
||||
<PlanCardPerson size={18} />
|
||||
</div>
|
||||
<div dir="rtl" style={{
|
||||
minWidth: 86, height: 32, borderRadius: 29, background: 'var(--surface-3)',
|
||||
color: 'var(--text)', display: 'flex', gap: 6, alignItems: 'center', justifyContent: 'center',
|
||||
padding: '0 12px',
|
||||
}}>
|
||||
<span style={{ fontSize: 14, fontWeight: 500, whiteSpace: 'nowrap' }}>
|
||||
{formatResourceLimit(plan.max_resources)} منبع
|
||||
</span>
|
||||
<CubeIcon style={{ width: 17 }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Period toggle */}
|
||||
|
||||
Reference in New Issue
Block a user