feat(subscription): implement resource quota management based on subscription plans
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { api, ApiError, type ApiResponse } from '../lib/api';
|
||||
import { useSubscription } from './useSubscription';
|
||||
import type {
|
||||
ClinicResource, ResourcePool, ResourcePayload, ResourceServiceOffering, ResourceType, Skill,
|
||||
} from '../types';
|
||||
@@ -99,6 +100,36 @@ export function useResources(filters: ResourceFilters = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* سهمیهٔ منابع محیط — سقفش از پلن اشتراک میآید و مصرفش از فهرست منابع.
|
||||
*
|
||||
* فهرست عمداً بیفیلتر خوانده میشود: سهمیه به کل محیط مربوط است و شمردنِ ردیفهای
|
||||
* فیلترشدهٔ صفحه، با هر فیلتر یک عدد متفاوت میداد. کوئری با کلیدِ `[resources, {}]`
|
||||
* همان کوئریِ حالتِ بیفیلترِ صفحه است، پس معمولاً رفتوبرگشت اضافهای نمیسازد.
|
||||
*
|
||||
* تصمیم نهایی با سرور است؛ این فقط دکمه را پیش از رفتن به فرم میبندد.
|
||||
*/
|
||||
export function useResourceQuota() {
|
||||
const { maxResources, planLoaded } = useSubscription();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: [RESOURCES_KEY, {}],
|
||||
queryFn: () => api.get<ApiResponse<ClinicResource[]>>('/api/v1/resources'),
|
||||
});
|
||||
|
||||
const used = data?.data?.length ?? 0;
|
||||
// سقفِ ناشناخته (نقش بیاشتراک، یا پاسخِ هنوز نرسیده) گیت نمیشود؛ سرور خودش
|
||||
// ۴۲۲ میدهد و بستنِ دکمه بر اساس حدس، بدتر از بستنش دیرتر است.
|
||||
const unlimited = !planLoaded || maxResources < 0;
|
||||
|
||||
return {
|
||||
used,
|
||||
limit: maxResources,
|
||||
unlimited,
|
||||
atLimit: !unlimited && used >= maxResources,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* سرویسهایی که یک منبع ارائه میدهد.
|
||||
*
|
||||
|
||||
@@ -19,12 +19,19 @@ export function useSubscription() {
|
||||
const effectivePlan = data?.data?.effective_plan ?? sub?.plan ?? null;
|
||||
const features: Record<string, boolean> = effectivePlan?.features ?? {};
|
||||
const maxSecretaries: number = effectivePlan?.max_secretaries ?? 1;
|
||||
// `-1` یعنی بینهایت.
|
||||
const maxResources: number = effectivePlan?.max_resources ?? 1;
|
||||
// نقشهایی که اشتراک ندارند (ادمین) و لحظهٔ پیش از رسیدن پاسخ: سقف ناشناخته است و
|
||||
// نباید با پیشفرضِ ۱ بهجای کاربر تصمیم گرفت — گیتکردن کارِ سرور است.
|
||||
const planLoaded = effectivePlan !== null;
|
||||
const hasPlan = sub !== null;
|
||||
|
||||
return {
|
||||
subscription: sub,
|
||||
hasFeature: (key: string) => features[key] ?? false,
|
||||
maxSecretaries,
|
||||
maxResources,
|
||||
planLoaded,
|
||||
hasPlan,
|
||||
isExpiringSoon: (sub?.days_remaining ?? 0) > 0 && (sub?.days_remaining ?? 0) <= 7,
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock('../hooks/usePermissions', () => ({
|
||||
}));
|
||||
|
||||
import { api } from '../lib/api';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import ResourcesPage from './ResourcesPage';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
@@ -126,4 +127,61 @@ describe('ResourcesPage', () => {
|
||||
expect(screen.queryByRole('button', { name: gone })).not.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* سقف منابع از پلن اشتراک میآید. رسیدن به سقف باید همانجا گفته شود، نه با ۴۲۲
|
||||
* بعد از پر کردن فرم.
|
||||
*/
|
||||
describe('سقف منابع پلن', () => {
|
||||
function mockWithPlan(maxResources: number, resources: unknown[]) {
|
||||
// اشتراک فقط برای نقشهای صاحب محیط خوانده میشود.
|
||||
useAuthStore.setState({ primaryRole: 'clinic' });
|
||||
|
||||
get.mockImplementation((path: string) => {
|
||||
if (path.startsWith('/api/v1/subscription/my')) {
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
data: {
|
||||
subscription: null,
|
||||
used_trial: false,
|
||||
effective_plan: { name: 'free', level: 0, max_secretaries: 1, max_resources: maxResources, features: {} },
|
||||
},
|
||||
});
|
||||
}
|
||||
if (path.startsWith('/api/v1/resource-types')) return Promise.resolve({ success: true, data: [laserType] });
|
||||
if (path.startsWith('/api/v1/skills')) return Promise.resolve({ success: true, data: [skill] });
|
||||
if (path.startsWith('/api/v1/resources')) return Promise.resolve({ success: true, data: resources });
|
||||
return Promise.resolve({ success: true, data: [] });
|
||||
});
|
||||
}
|
||||
|
||||
it('در سقف، دکمهٔ افزودن جای خود را به ارتقای پنل میدهد', async () => {
|
||||
mockWithPlan(1, [resource]);
|
||||
renderWithProviders(<ResourcesPage />, { route: '/admin/resources' });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('link', { name: 'ارتقای پنل' })).toHaveAttribute('href', '/admin/subscription'),
|
||||
);
|
||||
expect(screen.queryByText('افزودن منبع')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('۱ از ۱ منبع پلن فعلی')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('زیر سقف، دکمهٔ افزودن و شمارندهٔ مصرف را نشان میدهد', async () => {
|
||||
mockWithPlan(3, [resource]);
|
||||
renderWithProviders(<ResourcesPage />, { route: '/admin/resources' });
|
||||
|
||||
await waitFor(() => expect(screen.getByText('۱ از ۳ منبع پلن فعلی')).toBeInTheDocument());
|
||||
expect(screen.getByText('افزودن منبع')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('پلن نامحدود نه شمارنده دارد نه سقف', async () => {
|
||||
mockWithPlan(-1, [resource, { ...resource, uuid: 'r2', name: 'لیزر ۲' }]);
|
||||
renderWithProviders(<ResourcesPage />, { route: '/admin/resources' });
|
||||
|
||||
// تا فهرست و پلن هر دو ننشستهاند، «نبودِ» شمارنده چیزی ثابت نمیکند.
|
||||
await waitFor(() => expect(screen.getByText('لیزر ۲')).toBeInTheDocument());
|
||||
expect(screen.getByText('افزودن منبع')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/منبع پلن فعلی/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,9 +5,10 @@ import PageHeader from '../components/ui/PageHeader';
|
||||
import DataTable, { type Column } from '../components/ui/DataTable';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
import { ActiveBadge } from '../components/ui/StatusBadge';
|
||||
import { formatNumber } from '../lib/utils';
|
||||
import { useUrlState } from '../hooks/useUrlState';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { useResources, useResourceTypes, useSkills } from '../hooks/useResources';
|
||||
import { useResourceQuota, useResources, useResourceTypes, useSkills } from '../hooks/useResources';
|
||||
import ResourceFormModal from '../components/resources/ResourceFormModal';
|
||||
import type { ClinicResource } from '../types';
|
||||
import ResourcesSubNav from '../components/resources/ResourcesSubNav';
|
||||
@@ -45,6 +46,8 @@ export default function ResourcesPage() {
|
||||
// ساخت تنها کاری است که به منبعِ موجود گره نمیخورد، پس تنها مودالی است که میماند.
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
const quota = useResourceQuota();
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const q = urlState.search.trim();
|
||||
return q === '' ? resources : resources.filter((r) => `${r.name} ${r.type_name}`.includes(q));
|
||||
@@ -102,11 +105,26 @@ export default function ResourcesPage() {
|
||||
<PageHeader
|
||||
title="منابع"
|
||||
description="هر چیزی که ممکن است اشغال باشد: پزشک، اپراتور، اتاق، دستگاه. ظرفیت یعنی تعداد بیمار همزمان."
|
||||
/* سقف پلن پیش از باز شدن فرم گفته میشود، نه بعد از پر کردنش: خطای ۴۲۲ ته کار
|
||||
همان اطلاعات را دیرتر و گرانتر میداد. تصمیم نهایی همچنان با سرور است. */
|
||||
action={
|
||||
canUpdate ? (
|
||||
<button type="button" className="btn primary" onClick={() => setCreateOpen(true)}>
|
||||
<PlusIcon style={{ width: 16 }} /> افزودن منبع
|
||||
</button>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
{!quota.unlimited && (
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
|
||||
{formatNumber(quota.used)} از {formatNumber(quota.limit)} منبع پلن فعلی
|
||||
</span>
|
||||
)}
|
||||
{quota.atLimit ? (
|
||||
<Link to="/admin/subscription" className="btn primary" style={{ textDecoration: 'none' }}>
|
||||
ارتقای پنل
|
||||
</Link>
|
||||
) : (
|
||||
<button type="button" className="btn primary" onClick={() => setCreateOpen(true)}>
|
||||
<PlusIcon style={{ width: 16 }} /> افزودن منبع
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -634,6 +634,8 @@ export interface SubscriptionPlan {
|
||||
name: string;
|
||||
level: number;
|
||||
max_secretaries: number;
|
||||
/** سقف منابع؛ `-1` یعنی بینهایت. */
|
||||
max_resources: number;
|
||||
features: Record<string, boolean>;
|
||||
active: boolean;
|
||||
periods: SubscriptionPeriod[];
|
||||
@@ -653,6 +655,7 @@ export interface MySubscriptionData {
|
||||
name: string;
|
||||
level: number;
|
||||
max_secretaries: number;
|
||||
max_resources: number;
|
||||
features: Record<string, boolean>;
|
||||
};
|
||||
period?: { label: string; duration_months: number };
|
||||
@@ -667,6 +670,7 @@ export interface MySubscriptionData {
|
||||
name: string;
|
||||
level: number;
|
||||
max_secretaries: number;
|
||||
max_resources: number;
|
||||
features: Record<string, boolean>;
|
||||
} | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user