feat(subscription): add resource quota management for subscription plans and update related components
This commit is contained in:
@@ -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' }}>
|
||||
|
||||
Reference in New Issue
Block a user