TorbatYar/frontend/app/admin/plans/page.tsx
Mortezakoohjani 800b0ba2c5 Deploy TorbatYar for torbatyar.ir with nginx multi-tenant routing.
Wire production domain, CORS for tenant subdomains, celery volume mounts, and nginx reverse proxy configs for apex, API, identity, auth, and wildcard tenants.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 21:43:33 +03:30

305 lines
9.5 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { FormEvent, useCallback, useEffect, useState } from "react";
import { api, ApiError, type Feature, type Plan } from "@/lib/api";
import {
AdminPageHeader,
Banner,
Button,
EmptyState,
Modal,
SelectField,
StatusBadge,
TextField,
TextareaField,
} from "@/components/admin/controls";
export default function AdminPlansPage() {
const [plans, setPlans] = useState<Plan[]>([]);
const [features, setFeatures] = useState<Feature[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [attachPlan, setAttachPlan] = useState<Plan | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError("");
try {
const [plansPage, featuresPage] = await Promise.all([
api.plans.list(1, 100),
api.features.list(1, 100),
]);
setPlans(plansPage.items);
setFeatures(featuresPage.items);
} catch (e) {
setError(e instanceof ApiError ? e.message : "خطا در بارگذاری پلن‌ها");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
return (
<div className="space-y-6">
<AdminPageHeader
title="پلن‌ها"
subtitle="تعریف پلن‌ها و اتصال قابلیت‌ها"
action={<Button onClick={() => setCreateOpen(true)}>پلن جدید</Button>}
/>
{error && <Banner variant="error">{error}</Banner>}
<section className="overflow-hidden rounded-2xl border border-gray-100 bg-white shadow-sm">
{loading ? (
<EmptyState message="در حال بارگذاری..." />
) : plans.length === 0 ? (
<EmptyState message="هنوز پلنی تعریف نشده است." />
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] text-sm">
<thead className="bg-gray-50 text-right">
<tr>
<th className="px-4 py-3 font-semibold text-secondary">نام</th>
<th className="px-4 py-3 font-semibold text-secondary">کد</th>
<th className="px-4 py-3 font-semibold text-secondary">وضعیت</th>
<th className="px-4 py-3 font-semibold text-secondary">عملیات</th>
</tr>
</thead>
<tbody>
{plans.map((p) => (
<tr key={p.id} className="border-t border-gray-100">
<td className="px-4 py-3 font-medium text-secondary">{p.name}</td>
<td className="px-4 py-3 font-mono text-gray-600" dir="ltr">
{p.code}
</td>
<td className="px-4 py-3">
<StatusBadge status={p.is_active ? "active" : "inactive"} />
</td>
<td className="px-4 py-3">
<Button variant="outline" size="sm" onClick={() => setAttachPlan(p)}>
اتصال قابلیت
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<CreatePlanModal
open={createOpen}
onClose={() => setCreateOpen(false)}
onCreated={() => {
setCreateOpen(false);
void load();
}}
/>
<AttachFeatureModal
plan={attachPlan}
features={features}
onClose={() => setAttachPlan(null)}
onDone={() => setAttachPlan(null)}
/>
</div>
);
}
function CreatePlanModal({
open,
onClose,
onCreated,
}: {
open: boolean;
onClose: () => void;
onCreated: () => void;
}) {
const [code, setCode] = useState("");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [isActive, setIsActive] = useState("true");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
setSaving(true);
try {
await api.plans.create({
code: code.trim(),
name: name.trim(),
description: description.trim() || null,
is_active: isActive === "true",
});
setCode("");
setName("");
setDescription("");
onCreated();
} catch (err) {
setError(err instanceof ApiError ? err.message : "ایجاد پلن ناموفق بود");
} finally {
setSaving(false);
}
};
return (
<Modal open={open} title="ایجاد پلن جدید" onClose={onClose}>
<form onSubmit={handleSubmit} className="space-y-4">
<TextField
label="کد"
required
dir="ltr"
className="font-mono"
value={code}
onValue={setCode}
disabled={saving}
placeholder="PRO"
/>
<TextField label="نام" required value={name} onValue={setName} disabled={saving} />
<TextareaField
label="توضیحات"
value={description}
onValue={setDescription}
disabled={saving}
/>
<SelectField
label="وضعیت"
value={isActive}
onValue={setIsActive}
options={[
{ value: "true", label: "فعال" },
{ value: "false", label: "غیرفعال" },
]}
disabled={saving}
/>
{error && <Banner variant="error">{error}</Banner>}
<div className="flex justify-end gap-3 pt-2">
<Button variant="outline" onClick={onClose} type="button">
انصراف
</Button>
<Button type="submit" loading={saving} loadingText="در حال ایجاد...">
ایجاد پلن
</Button>
</div>
</form>
</Modal>
);
}
function AttachFeatureModal({
plan,
features,
onClose,
onDone,
}: {
plan: Plan | null;
features: Feature[];
onClose: () => void;
onDone: () => void;
}) {
const [featureId, setFeatureId] = useState("");
const [limitValue, setLimitValue] = useState("");
const [limitPeriod, setLimitPeriod] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!plan) return;
setError("");
setSuccess("");
const fid = featureId || features[0]?.id;
if (!fid) {
setError("قابلیتی برای اتصال وجود ندارد.");
return;
}
setSaving(true);
try {
await api.plans.attachFeature(plan.id, {
feature_id: fid,
limit_value: limitValue.trim() ? Number(limitValue) : null,
limit_period: (limitPeriod || null) as
| "day"
| "month"
| "year"
| "total"
| null,
});
setSuccess("قابلیت با موفقیت متصل شد.");
setLimitValue("");
setLimitPeriod("");
} catch (err) {
setError(err instanceof ApiError ? err.message : "اتصال قابلیت ناموفق بود");
} finally {
setSaving(false);
}
};
return (
<Modal
open={!!plan}
title={plan ? `اتصال قابلیت به پلن «${plan.name}»` : ""}
onClose={onClose}
>
{features.length === 0 ? (
<p className="text-sm text-amber-600">
ابتدا از بخش «قابلیتها» یک قابلیت بسازید.
</p>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<SelectField
label="قابلیت"
value={featureId || features[0]?.id}
onValue={setFeatureId}
options={features.map((f) => ({
value: f.id,
label: `${f.name} (${f.feature_key})`,
}))}
disabled={saving}
/>
<TextField
label="سقف مصرف (اختیاری)"
type="number"
dir="ltr"
value={limitValue}
onValue={setLimitValue}
disabled={saving}
placeholder="مثلاً 1000"
/>
<SelectField
label="دوره محدودیت"
value={limitPeriod}
onValue={setLimitPeriod}
options={[
{ value: "", label: "— بدون دوره —" },
{ value: "day", label: "روزانه" },
{ value: "month", label: "ماهانه" },
{ value: "year", label: "سالانه" },
{ value: "total", label: "کل" },
]}
disabled={saving}
/>
{error && <Banner variant="error">{error}</Banner>}
{success && <Banner variant="success">{success}</Banner>}
<div className="flex justify-end gap-3 pt-2">
<Button variant="outline" onClick={onDone} type="button">
بستن
</Button>
<Button type="submit" loading={saving} loadingText="در حال اتصال...">
اتصال قابلیت
</Button>
</div>
</form>
)}
</Modal>
);
}