feat(plans): Stage 4 — full admin plan/feature editor
The admin → Plans screen now edits EVERYTHING per plan (the backend already accepted it; only the UI was partial): - All limits (orders/day, tables, terminals, branches, menu categories, menu items, customers, report history, SMS, AI-3D) with an "unlimited (∞)" toggle. - Display names (fa/en), monthly price, sort order, billable-online, active on/off. - Per-plan feature checkboxes grouped by module, plus an "all features (*)" toggle (Enterprise). Sourced from the live feature catalog (/api/admin/features). - Plans listed in sort order (Free·Starter·Pro·Business·Enterprise). - i18n fa/en/ar. Admin tsc + build clean.
This commit is contained in:
@@ -1114,7 +1114,29 @@
|
|||||||
"title": "خطط الاشتراك",
|
"title": "خطط الاشتراك",
|
||||||
"monthlyPrice": "السعر الشهري (تومان)",
|
"monthlyPrice": "السعر الشهري (تومان)",
|
||||||
"maxOrders": "حد الطلبات اليومي",
|
"maxOrders": "حد الطلبات اليومي",
|
||||||
"saved": "تم الحفظ"
|
"saved": "تم الحفظ",
|
||||||
|
"active": "مفعل",
|
||||||
|
"nameFa": "الاسم (فارسي)",
|
||||||
|
"nameEn": "الاسم (إنجليزي)",
|
||||||
|
"sortOrder": "الترتيب",
|
||||||
|
"billable": "قابل للدفع عبر الإنترنت",
|
||||||
|
"limitsTitle": "الحدود",
|
||||||
|
"featuresTitle": "الميزات",
|
||||||
|
"allFeatures": "كل الميزات",
|
||||||
|
"allFeaturesNote": "تشمل هذه الباقة جميع الميزات الحالية والمستقبلية.",
|
||||||
|
"save": "حفظ",
|
||||||
|
"limits": {
|
||||||
|
"maxOrders": "طلبات/يوم",
|
||||||
|
"maxTables": "الطاولات",
|
||||||
|
"maxTerminals": "أجهزة POS",
|
||||||
|
"maxBranches": "الفروع",
|
||||||
|
"maxCategories": "فئات القائمة",
|
||||||
|
"maxItems": "أصناف القائمة",
|
||||||
|
"maxCustomers": "العملاء",
|
||||||
|
"maxReportDays": "سجل التقارير (أيام)",
|
||||||
|
"maxSms": "رسائل/شهر",
|
||||||
|
"maxAi3d": "3D/شهر"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "إعدادات التطبيق",
|
"title": "إعدادات التطبيق",
|
||||||
|
|||||||
@@ -1107,7 +1107,29 @@
|
|||||||
"title": "Subscription plans",
|
"title": "Subscription plans",
|
||||||
"monthlyPrice": "Monthly price (Toman)",
|
"monthlyPrice": "Monthly price (Toman)",
|
||||||
"maxOrders": "Max orders per day",
|
"maxOrders": "Max orders per day",
|
||||||
"saved": "Plan saved"
|
"saved": "Plan saved",
|
||||||
|
"active": "Active",
|
||||||
|
"nameFa": "Name (Persian)",
|
||||||
|
"nameEn": "Name (English)",
|
||||||
|
"sortOrder": "Sort order",
|
||||||
|
"billable": "Billable online",
|
||||||
|
"limitsTitle": "Limits",
|
||||||
|
"featuresTitle": "Features",
|
||||||
|
"allFeatures": "All features",
|
||||||
|
"allFeaturesNote": "This plan includes all features (current and future).",
|
||||||
|
"save": "Save",
|
||||||
|
"limits": {
|
||||||
|
"maxOrders": "Orders/day",
|
||||||
|
"maxTables": "Tables",
|
||||||
|
"maxTerminals": "POS terminals",
|
||||||
|
"maxBranches": "Branches",
|
||||||
|
"maxCategories": "Menu categories",
|
||||||
|
"maxItems": "Menu items",
|
||||||
|
"maxCustomers": "Customers",
|
||||||
|
"maxReportDays": "Report history (days)",
|
||||||
|
"maxSms": "SMS/month",
|
||||||
|
"maxAi3d": "AI 3D/month"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Application settings",
|
"title": "Application settings",
|
||||||
|
|||||||
@@ -1107,7 +1107,29 @@
|
|||||||
"title": "پلنها و قیمتگذاری",
|
"title": "پلنها و قیمتگذاری",
|
||||||
"monthlyPrice": "قیمت ماهانه (تومان)",
|
"monthlyPrice": "قیمت ماهانه (تومان)",
|
||||||
"maxOrders": "سقف سفارش روزانه",
|
"maxOrders": "سقف سفارش روزانه",
|
||||||
"saved": "پلن ذخیره شد"
|
"saved": "پلن ذخیره شد",
|
||||||
|
"active": "فعال",
|
||||||
|
"nameFa": "نام (فارسی)",
|
||||||
|
"nameEn": "نام (انگلیسی)",
|
||||||
|
"sortOrder": "ترتیب",
|
||||||
|
"billable": "قابل پرداخت آنلاین",
|
||||||
|
"limitsTitle": "محدودیتها",
|
||||||
|
"featuresTitle": "امکانات",
|
||||||
|
"allFeatures": "همه امکانات",
|
||||||
|
"allFeaturesNote": "این پلن به همه امکانات (فعلی و آینده) دسترسی دارد.",
|
||||||
|
"save": "ذخیره",
|
||||||
|
"limits": {
|
||||||
|
"maxOrders": "سفارش روزانه",
|
||||||
|
"maxTables": "میزها",
|
||||||
|
"maxTerminals": "پایانه POS",
|
||||||
|
"maxBranches": "شعب",
|
||||||
|
"maxCategories": "دسته منو",
|
||||||
|
"maxItems": "آیتم منو",
|
||||||
|
"maxCustomers": "مشتریان",
|
||||||
|
"maxReportDays": "تاریخچه گزارش (روز)",
|
||||||
|
"maxSms": "پیامک ماهانه",
|
||||||
|
"maxAi3d": "تولید ۳D ماهانه"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "تنظیمات اپلیکیشن",
|
"title": "تنظیمات اپلیکیشن",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type {
|
|||||||
AdminNotificationRow,
|
AdminNotificationRow,
|
||||||
AdminPlan,
|
AdminPlan,
|
||||||
AdminStats,
|
AdminStats,
|
||||||
|
PlanLimitsData,
|
||||||
GatewayCredentials,
|
GatewayCredentials,
|
||||||
PaymentGatewayConfig,
|
PaymentGatewayConfig,
|
||||||
PlatformFeature,
|
PlatformFeature,
|
||||||
@@ -131,45 +132,167 @@ function StatCard({ label, value }: { label: string; value: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PlanCard({ plan, onSave }: { plan: AdminPlan; onSave: (p: AdminPlan) => void }) {
|
const PLAN_UNLIMITED = 2147483647;
|
||||||
|
|
||||||
|
const LIMIT_FIELDS: { key: keyof PlanLimitsData; label: string }[] = [
|
||||||
|
{ key: "maxOrdersPerDay", label: "maxOrders" },
|
||||||
|
{ key: "maxTables", label: "maxTables" },
|
||||||
|
{ key: "maxTerminals", label: "maxTerminals" },
|
||||||
|
{ key: "maxBranches", label: "maxBranches" },
|
||||||
|
{ key: "maxMenuCategories", label: "maxCategories" },
|
||||||
|
{ key: "maxMenuItems", label: "maxItems" },
|
||||||
|
{ key: "maxCustomers", label: "maxCustomers" },
|
||||||
|
{ key: "maxReportHistoryDays", label: "maxReportDays" },
|
||||||
|
{ key: "maxSmsPerMonth", label: "maxSms" },
|
||||||
|
{ key: "maxMenuAi3dPerMonth", label: "maxAi3d" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function LimitField({ label, value, onChange }: { label: string; value: number; onChange: (n: number) => void }) {
|
||||||
|
const unlimited = value >= PLAN_UNLIMITED;
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border/70 p-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs font-medium">{label}</span>
|
||||||
|
<label className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||||
|
<input type="checkbox" checked={unlimited} onChange={(e) => onChange(e.target.checked ? PLAN_UNLIMITED : 0)} />∞
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="mt-1 h-8 text-sm"
|
||||||
|
disabled={unlimited}
|
||||||
|
value={unlimited ? "" : value}
|
||||||
|
onChange={(e) => onChange(Math.max(0, Number(e.target.value)))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlanCard({
|
||||||
|
plan,
|
||||||
|
features,
|
||||||
|
onSave,
|
||||||
|
saving,
|
||||||
|
}: {
|
||||||
|
plan: AdminPlan;
|
||||||
|
features: PlatformFeature[];
|
||||||
|
onSave: (p: AdminPlan) => void;
|
||||||
|
saving: boolean;
|
||||||
|
}) {
|
||||||
const t = useTranslations("admin.plans");
|
const t = useTranslations("admin.plans");
|
||||||
const [price, setPrice] = useState(plan.monthlyPriceToman);
|
const [draft, setDraft] = useState<AdminPlan>(plan);
|
||||||
const [maxOrders, setMaxOrders] = useState(plan.limits.maxOrdersPerDay);
|
// Re-sync from server after a save/refetch.
|
||||||
|
useEffect(() => { setDraft(plan); }, [plan]);
|
||||||
|
|
||||||
// Sync server values if they change (e.g. after successful save + refetch)
|
const setField = <K extends keyof AdminPlan>(k: K, v: AdminPlan[K]) =>
|
||||||
useEffect(() => { setPrice(plan.monthlyPriceToman); }, [plan.monthlyPriceToman]);
|
setDraft((d) => ({ ...d, [k]: v }));
|
||||||
useEffect(() => { setMaxOrders(plan.limits.maxOrdersPerDay); }, [plan.limits.maxOrdersPerDay]);
|
const setLimit = (k: keyof PlanLimitsData, v: number) =>
|
||||||
|
setDraft((d) => ({ ...d, limits: { ...d.limits, [k]: v } }));
|
||||||
|
|
||||||
const flush = () =>
|
const wildcard = draft.featureKeys.includes("*");
|
||||||
onSave({ ...plan, monthlyPriceToman: price, limits: { ...plan.limits, maxOrdersPerDay: maxOrders } });
|
const toggleFeature = (key: string, on: boolean) =>
|
||||||
|
setDraft((d) => {
|
||||||
|
const set = new Set(d.featureKeys.filter((k) => k !== "*"));
|
||||||
|
if (on) set.add(key);
|
||||||
|
else set.delete(key);
|
||||||
|
return { ...d, featureKeys: Array.from(set) };
|
||||||
|
});
|
||||||
|
|
||||||
|
const groups = Array.from(new Set(features.map((f) => f.moduleGroup)));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="rounded-xl border border-border/80">
|
<Card className="rounded-xl border border-border/80">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="flex flex-row items-center justify-between gap-2 pb-2">
|
||||||
<CardTitle className="text-base">{plan.displayNameFa}</CardTitle>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">{plan.tier}</p>
|
<CardTitle className="text-base">{draft.displayNameFa || draft.tier}</CardTitle>
|
||||||
|
<p className="text-xs text-muted-foreground">{draft.tier}</p>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-1.5 text-xs">
|
||||||
|
<input type="checkbox" checked={draft.isActive} onChange={(e) => setField("isActive", e.target.checked)} />
|
||||||
|
{t("active")}
|
||||||
|
</label>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-3 sm:grid-cols-2">
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<label className="text-sm">
|
||||||
|
{t("nameFa")}
|
||||||
|
<Input className="mt-1 h-8" value={draft.displayNameFa} onChange={(e) => setField("displayNameFa", e.target.value)} dir="rtl" />
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
{t("nameEn")}
|
||||||
|
<Input className="mt-1 h-8" value={draft.displayNameEn ?? ""} onChange={(e) => setField("displayNameEn", e.target.value)} dir="ltr" />
|
||||||
|
</label>
|
||||||
<label className="text-sm">
|
<label className="text-sm">
|
||||||
{t("monthlyPrice")}
|
{t("monthlyPrice")}
|
||||||
<Input
|
<Input type="number" className="mt-1 h-8" value={draft.monthlyPriceToman} onChange={(e) => setField("monthlyPriceToman", Number(e.target.value))} />
|
||||||
type="number"
|
|
||||||
className="mt-1"
|
|
||||||
value={price}
|
|
||||||
onChange={(e) => setPrice(Number(e.target.value))}
|
|
||||||
onBlur={flush}
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label className="text-sm">
|
<label className="text-sm">
|
||||||
{t("maxOrders")}
|
{t("sortOrder")}
|
||||||
<Input
|
<Input type="number" className="mt-1 h-8" value={draft.sortOrder} onChange={(e) => setField("sortOrder", Number(e.target.value))} />
|
||||||
type="number"
|
|
||||||
className="mt-1"
|
|
||||||
value={maxOrders}
|
|
||||||
onChange={(e) => setMaxOrders(Number(e.target.value))}
|
|
||||||
onBlur={flush}
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex w-fit items-center gap-1.5 text-xs">
|
||||||
|
<input type="checkbox" checked={draft.isBillableOnline} onChange={(e) => setField("isBillableOnline", e.target.checked)} />
|
||||||
|
{t("billable")}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs font-semibold text-muted-foreground">{t("limitsTitle")}</p>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
|
{LIMIT_FIELDS.map((f) => (
|
||||||
|
<LimitField key={f.key} label={t(`limits.${f.label}`)} value={draft.limits[f.key] ?? PLAN_UNLIMITED} onChange={(v) => setLimit(f.key, v)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<p className="text-xs font-semibold text-muted-foreground">{t("featuresTitle")}</p>
|
||||||
|
<label className="flex items-center gap-1.5 text-xs">
|
||||||
|
<input type="checkbox" checked={wildcard} onChange={(e) => setField("featureKeys", e.target.checked ? ["*"] : [])} />
|
||||||
|
{t("allFeatures")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{wildcard ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{t("allFeaturesNote")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{groups.map((g) => (
|
||||||
|
<div key={g}>
|
||||||
|
<p className="mb-1 text-[11px] uppercase tracking-wide text-muted-foreground">{g}</p>
|
||||||
|
<div className="grid gap-1.5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{features
|
||||||
|
.filter((f) => f.moduleGroup === g)
|
||||||
|
.map((f) => (
|
||||||
|
<label
|
||||||
|
key={f.key}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 rounded-md border border-border/60 px-2 py-1.5 text-sm",
|
||||||
|
!f.isEnabledGlobally && "opacity-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input type="checkbox" checked={draft.featureKeys.includes(f.key)} onChange={(e) => toggleFeature(f.key, e.target.checked)} />
|
||||||
|
<span className="truncate">{f.displayNameFa}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSave(draft)}
|
||||||
|
disabled={saving}
|
||||||
|
className="rounded-lg bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t("save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -182,6 +305,10 @@ export function AdminPlansScreen() {
|
|||||||
queryKey: ["admin", "plans"],
|
queryKey: ["admin", "plans"],
|
||||||
queryFn: () => adminGet<AdminPlan[]>("/api/admin/plans"),
|
queryFn: () => adminGet<AdminPlan[]>("/api/admin/plans"),
|
||||||
});
|
});
|
||||||
|
const { data: features = [] } = useQuery({
|
||||||
|
queryKey: ["admin", "features"],
|
||||||
|
queryFn: () => adminGet<PlatformFeature[]>("/api/admin/features"),
|
||||||
|
});
|
||||||
|
|
||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
mutationFn: (plan: AdminPlan) =>
|
mutationFn: (plan: AdminPlan) =>
|
||||||
@@ -201,11 +328,19 @@ export function AdminPlansScreen() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ordered = [...plans].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h1 className="text-lg font-medium">{t("title")}</h1>
|
<h1 className="text-lg font-medium">{t("title")}</h1>
|
||||||
{plans.map((plan) => (
|
{ordered.map((plan) => (
|
||||||
<PlanCard key={plan.tier} plan={plan} onSave={(p) => save.mutate(p)} />
|
<PlanCard
|
||||||
|
key={plan.tier}
|
||||||
|
plan={plan}
|
||||||
|
features={features}
|
||||||
|
onSave={(p) => save.mutate(p)}
|
||||||
|
saving={save.isPending}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,11 +8,15 @@ export type AdminStats = {
|
|||||||
|
|
||||||
export type PlanLimitsData = {
|
export type PlanLimitsData = {
|
||||||
maxOrdersPerDay: number;
|
maxOrdersPerDay: number;
|
||||||
|
maxTables: number;
|
||||||
maxTerminals: number;
|
maxTerminals: number;
|
||||||
maxCustomers: number;
|
maxCustomers: number;
|
||||||
maxSmsPerMonth: number;
|
maxSmsPerMonth: number;
|
||||||
maxBranches: number;
|
maxBranches: number;
|
||||||
maxReportHistoryDays: number;
|
maxReportHistoryDays: number;
|
||||||
|
maxMenuCategories: number;
|
||||||
|
maxMenuItems: number;
|
||||||
|
maxMenuAi3dPerMonth: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminPlan = {
|
export type AdminPlan = {
|
||||||
|
|||||||
Reference in New Issue
Block a user