feat(subscription): implement resource quota management based on subscription plans

This commit is contained in:
hamed
2026-08-04 19:38:49 +03:30
parent 0dca245246
commit 3e7028d77a
16 changed files with 408 additions and 18 deletions
+58
View File
@@ -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();
});
});
});
+22 -4
View File
@@ -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
}
/>