feat(subscription): add resource quota management for subscription plans and update related components

This commit is contained in:
hamed
2026-08-04 19:50:47 +03:30
parent 3e7028d77a
commit 2db4c3c4b0
8 changed files with 306 additions and 14 deletions
+43 -6
View File
@@ -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' }}>