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>
305 lines
9.5 KiB
TypeScript
305 lines
9.5 KiB
TypeScript
"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>
|
||
);
|
||
}
|