feat: add superadmin plan management module

This commit is contained in:
2026-06-16 18:55:48 +05:30
parent d8587a43d4
commit 6c97f89b30
19 changed files with 1082 additions and 1 deletions

View File

@@ -0,0 +1,126 @@
'use client';
import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { Edit, RotateCcw } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions';
import { usePermissions } from '@/hooks/usePermissions';
import type { Plan } from '@/types';
function formatDate(value?: string | null) {
if (!value) return '-';
return new Intl.DateTimeFormat('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric',
}).format(new Date(value));
}
function formatPrice(value: string, cycle: string) {
const amount = Number(value);
const price = Number.isFinite(amount) ? amount.toFixed(2) : value;
return `${price} / ${cycle}`;
}
interface UsePlanColumnsParams {
onEdit: (plan: Plan) => void;
onToggleStatus: (plan: Plan) => void;
pendingPlanId?: number;
}
export function usePlanColumns({
onEdit,
onToggleStatus,
pendingPlanId,
}: UsePlanColumnsParams): ColumnDef<Plan>[] {
const { hasPermission } = usePermissions();
const canEdit = hasPermission(PERMISSIONS.PLAN.UPDATE);
const canDelete = hasPermission(PERMISSIONS.PLAN.DELETE);
return useMemo(() => {
const columns: ColumnDef<Plan>[] = [
{
accessorKey: 'name',
header: 'Plan',
cell: ({ row }) => (
<div>
<p className="font-medium">{row.original.name}</p>
<p className="text-xs text-muted-foreground">{row.original.slug}</p>
</div>
),
},
{
accessorKey: 'price',
header: 'Price',
cell: ({ row }) => formatPrice(row.original.price, row.original.billing_cycle),
},
{
accessorKey: 'trial_days',
header: 'Trial',
cell: ({ row }) => `${row.original.trial_days} days`,
},
{
id: 'limits',
header: 'Limits',
cell: ({ row }) => (
<div className="text-xs text-muted-foreground">
<p>{row.original.max_projects} projects</p>
<p>{row.original.max_users} users</p>
</div>
),
},
{
accessorKey: 'created_at',
header: 'Created',
cell: ({ row }) => formatDate(row.original.created_at),
},
{
accessorKey: 'is_active',
header: 'Status',
cell: ({ row }) => (
<div className="flex flex-wrap gap-1.5">
<Badge variant={row.original.is_active ? 'default' : 'secondary'}>
{row.original.is_active ? 'Active' : 'Inactive'}
</Badge>
{row.original.is_custom ? <Badge variant="outline">Custom</Badge> : null}
</div>
),
},
];
if (!canEdit && !canDelete) return columns;
columns.push({
id: 'actions',
header: () => <div className="text-right">Actions</div>,
cell: ({ row }) => {
const plan = row.original;
return (
<div className="flex justify-end gap-2">
{canEdit ? (
<Button variant="outline" size="sm" onClick={() => onEdit(plan)}>
<Edit className="size-4" />
</Button>
) : null}
{canDelete ? (
<Button
variant="outline"
size="sm"
disabled={pendingPlanId === plan.id}
onClick={() => onToggleStatus(plan)}
>
<RotateCcw className="mr-2 size-4" />
{plan.is_active ? 'Deactivate' : 'Activate'}
</Button>
) : null}
</div>
);
},
});
return columns;
}, [canDelete, canEdit, onEdit, onToggleStatus, pendingPlanId]);
}

View File

@@ -0,0 +1,51 @@
'use client';
import { Search } from 'lucide-react';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { PlanStatusFilter } from '../hooks/usePlanFilters';
interface PlanFiltersProps {
searchTerm: string;
statusFilter: PlanStatusFilter;
onSearchChange: (value: string) => void;
onStatusChange: (value: PlanStatusFilter) => void;
}
export function PlanFilters({
searchTerm,
statusFilter,
onSearchChange,
onStatusChange,
}: PlanFiltersProps) {
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-72">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchTerm}
onChange={(event) => onSearchChange(event.target.value)}
placeholder="Search plans"
className="pl-9"
/>
</div>
<Select value={statusFilter} onValueChange={(value) => onStatusChange(value as PlanStatusFilter)}>
<SelectTrigger className="w-full sm:w-40">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
</SelectContent>
</Select>
</div>
);
}

View File

@@ -0,0 +1,237 @@
'use client';
import type { ComponentProps } from 'react';
import type { Control, UseFormRegister } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import { Loader2 } from 'lucide-react';
import { PermissionTree } from '@/components/permission-tree';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import type { PermissionTreeItem } from '@/types';
import type { PlanFormValues } from '../hooks/usePlanForm';
interface PlanSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
planId?: number;
register: UseFormRegister<PlanFormValues>;
control: Control<PlanFormValues>;
onSubmit: ComponentProps<'form'>['onSubmit'];
permissionTree: PermissionTreeItem[];
permissionIds: number[];
onPermissionIdsChange: (ids: number[]) => void;
isPermissionsLoading: boolean;
isSaving: boolean;
}
export function PlanSheet({
open,
onOpenChange,
planId,
register,
control,
onSubmit,
permissionTree,
permissionIds,
onPermissionIdsChange,
isPermissionsLoading,
isSaving,
}: PlanSheetProps) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full overflow-y-auto sm:max-w-3xl">
<SheetHeader>
<SheetTitle>{planId ? 'Edit Plan' : 'Create Plan'}</SheetTitle>
<SheetDescription>
Configure subscription limits, billing, and permission access.
</SheetDescription>
</SheetHeader>
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="plan-name">Name</Label>
<Input
id="plan-name"
placeholder="Starter"
{...register('name', { required: true })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="plan-slug">Slug</Label>
<Input
id="plan-slug"
placeholder="starter"
{...register('slug', { required: true })}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="plan-description">Description</Label>
<textarea
id="plan-description"
placeholder="Basic plan for small teams"
{...register('description', { required: true })}
required
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="plan-price">Price</Label>
<Input
id="plan-price"
type="number"
min="0"
step="0.01"
placeholder="29.99"
{...register('price', { required: true })}
required
/>
</div>
<div className="space-y-2">
<Label>Billing Cycle</Label>
<Controller
control={control}
name="billing_cycle"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Billing cycle" />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Monthly</SelectItem>
<SelectItem value="quarterly">Quarterly</SelectItem>
<SelectItem value="yearly">Yearly</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="plan-trial-days">Trial Days</Label>
<Input
id="plan-trial-days"
type="number"
min="0"
{...register('trial_days', { valueAsNumber: true })}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-4">
<div className="space-y-2">
<Label htmlFor="max-projects">Projects</Label>
<Input
id="max-projects"
type="number"
min="0"
{...register('max_projects', { valueAsNumber: true })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="max-organizations">Organizations</Label>
<Input
id="max-organizations"
type="number"
min="0"
{...register('max_organizations', { valueAsNumber: true })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="max-users">Users</Label>
<Input
id="max-users"
type="number"
min="0"
{...register('max_users', { valueAsNumber: true })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="max-roles">Roles</Label>
<Input
id="max-roles"
type="number"
min="0"
{...register('max_roles', { valueAsNumber: true })}
/>
</div>
</div>
<div className="flex flex-wrap gap-5">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
className="size-4 rounded border-border accent-primary"
{...register('is_active')}
/>
Active
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
className="size-4 rounded border-border accent-primary"
{...register('is_custom')}
/>
Custom plan
</label>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<Label>Permissions</Label>
<span className="text-xs text-muted-foreground">{permissionIds.length} selected</span>
</div>
{isPermissionsLoading ? (
<div className="rounded-md border p-6 text-sm text-muted-foreground">
Loading permissions...
</div>
) : (
<PermissionTree
items={permissionTree}
selectedIds={permissionIds}
onChange={onPermissionIdsChange}
/>
)}
</div>
<SheetFooter className="px-0">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
Cancel
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
{planId ? 'Update Plan' : 'Create Plan'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,47 @@
'use client';
import type { ReactNode } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { DataTable } from '@/components/data-table';
import type { Plan } from '@/types';
interface PlanTableProps {
columns: ColumnDef<Plan>[];
plans: Plan[];
isLoading: boolean;
toolbar: ReactNode;
skip: number;
limit: number;
total: number;
onPageChange: (skip: number) => void;
}
export function PlanTable({
columns,
plans,
isLoading,
toolbar,
skip,
limit,
total,
onPageChange,
}: PlanTableProps) {
return (
<DataTable
title="Plans"
columns={columns}
data={plans}
isLoading={isLoading}
toolbar={toolbar}
emptyTitle="No plans found."
pagination={{
skip,
limit,
totalItems: total,
onPageChange,
onLimitChange: () => undefined,
}}
/>
);
}

View File

@@ -0,0 +1,35 @@
'use client';
import { useState } from 'react';
import { useDebounce } from '@/hooks/useDebounce';
export type PlanStatusFilter = 'all' | 'active' | 'inactive';
export function usePlanFilters() {
const [skip, setSkip] = useState(0);
const [limit] = useState(10);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<PlanStatusFilter>('all');
const debouncedSearchTerm = useDebounce(searchTerm, 400);
const updateSearchTerm = (value: string) => {
setSearchTerm(value);
setSkip(0);
};
const updateStatusFilter = (value: PlanStatusFilter) => {
setStatusFilter(value);
setSkip(0);
};
return {
skip,
setSkip,
limit,
searchTerm,
debouncedSearchTerm,
setSearchTerm: updateSearchTerm,
statusFilter,
setStatusFilter: updateStatusFilter,
};
}

View File

@@ -0,0 +1,144 @@
'use client';
import {
collectDefaultPermissionIds,
collectPermissionIdsByKeys,
} from '@/components/permission-tree';
import type { PermissionTreeItem, Plan } from '@/types';
import { useCallback } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { toast } from 'sonner';
import { useSavePlanMutation } from './usePlanMutations';
export interface PlanFormValues {
id?: number;
name: string;
slug: string;
description: string;
price: string;
billing_cycle: string;
trial_days: number;
max_projects: number;
max_organizations: number;
max_users: number;
max_roles: number;
permission_ids: number[];
is_active: boolean;
is_custom: boolean;
}
const defaultValues: PlanFormValues = {
name: '',
slug: '',
description: '',
price: '',
billing_cycle: 'monthly',
trial_days: 0,
max_projects: 0,
max_organizations: 0,
max_users: 0,
max_roles: 0,
permission_ids: [],
is_active: true,
is_custom: false,
};
function toNumber(value: unknown) {
const numberValue = Number(value);
return Number.isFinite(numberValue) ? numberValue : 0;
}
export function usePlanForm({
permissionTree,
onSaved,
}: {
permissionTree: PermissionTreeItem[];
onSaved: () => void;
}) {
const {
control,
register,
handleSubmit,
reset,
setValue,
formState: { isSubmitting },
} = useForm<PlanFormValues>({
defaultValues,
});
const saveMutation = useSavePlanMutation({
onSaved: () => {
reset(defaultValues);
onSaved();
},
});
const planId = useWatch({ control, name: 'id' });
const permissionIds = useWatch({ control, name: 'permission_ids' }) || [];
const onSubmit = handleSubmit((values) => {
if (values.permission_ids.length === 0) {
toast.error('Select at least one permission');
return;
}
saveMutation.mutate({
...values,
name: values.name.trim(),
slug: values.slug.trim(),
description: values.description.trim(),
price: values.price.trim(),
trial_days: toNumber(values.trial_days),
max_projects: toNumber(values.max_projects),
max_organizations: toNumber(values.max_organizations),
max_users: toNumber(values.max_users),
max_roles: toNumber(values.max_roles),
});
});
const openCreate = useCallback(() => {
reset({
...defaultValues,
permission_ids: collectDefaultPermissionIds(permissionTree),
});
}, [permissionTree, reset]);
const openEdit = useCallback(
(plan: Plan) => {
reset({
id: plan.id,
name: plan.name || '',
slug: plan.slug || '',
description: plan.description || '',
price: plan.price || '',
billing_cycle: plan.billing_cycle || 'monthly',
trial_days: plan.trial_days ?? 0,
max_projects: plan.max_projects ?? 0,
max_organizations: plan.max_organizations ?? 0,
max_users: plan.max_users ?? 0,
max_roles: plan.max_roles ?? 0,
permission_ids: collectPermissionIdsByKeys(permissionTree, plan.permissions || []),
is_active: plan.is_active,
is_custom: plan.is_custom,
});
},
[permissionTree, reset],
);
const setPermissionIds = useCallback(
(ids: number[]) => setValue('permission_ids', ids, { shouldDirty: true, shouldValidate: true }),
[setValue],
);
return {
register,
control,
onSubmit,
openCreate,
openEdit,
planId,
permissionIds,
setPermissionIds,
isSaving: isSubmitting || saveMutation.isPending,
};
}

View File

@@ -0,0 +1,38 @@
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { planService } from '@/services/api';
import type { Plan, PlanRequest } from '@/types';
import { planKeys } from '../queries/planKeys';
export function useSavePlanMutation({ onSaved }: { onSaved: () => void }) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: PlanRequest) => planService.savePlan(payload),
onSuccess: (_data, values) => {
toast.success(values.id ? 'Plan updated' : 'Plan created');
queryClient.invalidateQueries({ queryKey: planKeys.lists() });
onSaved();
},
onError: (_error, values) => {
toast.error(values.id ? 'Failed to update plan' : 'Failed to create plan');
},
});
}
export function usePlanStatusMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (plan: Plan) => planService.updatePlanStatus(plan.id, !plan.is_active),
onSuccess: () => {
toast.success('Plan status updated');
queryClient.invalidateQueries({ queryKey: planKeys.lists() });
},
onError: () => toast.error('Failed to update plan status'),
});
}

View File

@@ -0,0 +1,64 @@
'use client';
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { mapPermissionTree } from '@/components/permission-tree';
import { permissionService, planService } from '@/services/api';
import type { PlanListParams } from '@/types';
import { planKeys, planPermissionTreeKeys } from '../queries/planKeys';
import type { PlanStatusFilter } from './usePlanFilters';
interface UsePlansQueryParams {
skip: number;
limit: number;
searchTerm: string;
statusFilter: PlanStatusFilter;
}
function buildPlanListParams({
skip,
limit,
searchTerm,
statusFilter,
}: UsePlansQueryParams): PlanListParams {
return {
skip,
limit,
search_term: searchTerm || undefined,
is_active: statusFilter === 'all' ? undefined : statusFilter === 'active',
sort_by: 'created_at',
sort_order: 'desc',
};
}
export function usePlansQuery(params: UsePlansQueryParams) {
const listParams = useMemo(() => buildPlanListParams(params), [params]);
return useQuery({
queryKey: planKeys.list(listParams),
queryFn: () => planService.getPlans(listParams),
});
}
export function useOrganizationPermissionTreeQuery(enabled: boolean) {
const permissionsQuery = useQuery({
queryKey: planPermissionTreeKeys.all,
queryFn: permissionService.getOrganizationPermissionTree,
enabled,
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
refetchOnMount: false,
refetchOnReconnect: false,
});
const permissionTree = useMemo(
() => mapPermissionTree(permissionsQuery.data || []),
[permissionsQuery.data],
);
return {
...permissionsQuery,
permissionTree,
};
}

View File

@@ -0,0 +1,189 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { BadgeIndianRupee, Plus } from 'lucide-react';
import { PageHeader } from '@/components/page-header';
import { PoweredBy } from '@/components/powered-by';
import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
import type { Plan } from '@/types';
import { usePlanColumns } from './components/PlanColumns';
import { PlanFilters } from './components/PlanFilters';
import { PlanSheet } from './components/PlanSheet';
import { PlanTable } from './components/PlanTable';
import { usePlanFilters } from './hooks/usePlanFilters';
import { usePlanForm } from './hooks/usePlanForm';
import { usePlanStatusMutation } from './hooks/usePlanMutations';
import { useOrganizationPermissionTreeQuery, usePlansQuery } from './hooks/usePlanQueries';
export default function PlansPage() {
const [isSheetOpen, setIsSheetOpen] = useState(false);
const pendingPlanRef = useRef<Plan | null>(null);
const hasSyncedPermissionsRef = useRef(false);
const {
skip,
setSkip,
limit,
searchTerm,
debouncedSearchTerm,
setSearchTerm,
statusFilter,
setStatusFilter,
} = usePlanFilters();
const plansQuery = usePlansQuery({
skip,
limit,
searchTerm: debouncedSearchTerm,
statusFilter,
});
const permissionsQuery = useOrganizationPermissionTreeQuery(isSheetOpen);
const handleSheetOpenChange = useCallback((open: boolean) => {
if (!open) {
pendingPlanRef.current = null;
}
setIsSheetOpen(open);
}, []);
const planForm = usePlanForm({
permissionTree: permissionsQuery.permissionTree,
onSaved: () => handleSheetOpenChange(false),
});
const { openCreate: prepareCreatePlan, openEdit: prepareEditPlan } = planForm;
const {
register,
control,
onSubmit,
planId,
permissionIds,
setPermissionIds,
isSaving,
} = planForm;
const statusMutation = usePlanStatusMutation();
const openCreate = useCallback(() => {
pendingPlanRef.current = null;
setIsSheetOpen(true);
prepareCreatePlan();
}, [prepareCreatePlan]);
const openEdit = useCallback(
(plan: Plan) => {
pendingPlanRef.current = plan;
setIsSheetOpen(true);
prepareEditPlan(plan);
},
[prepareEditPlan],
);
useEffect(() => {
if (!isSheetOpen) {
hasSyncedPermissionsRef.current = false;
return;
}
if (
hasSyncedPermissionsRef.current ||
permissionsQuery.isLoading ||
permissionsQuery.permissionTree.length === 0
) {
return;
}
hasSyncedPermissionsRef.current = true;
if (pendingPlanRef.current) {
prepareEditPlan(pendingPlanRef.current);
return;
}
if (!planId) {
prepareCreatePlan();
}
}, [
isSheetOpen,
permissionsQuery.isLoading,
permissionsQuery.permissionTree,
planId,
prepareCreatePlan,
prepareEditPlan,
]);
const toggleStatus = useCallback(
(plan: Plan) => {
statusMutation.mutate(plan);
},
[statusMutation],
);
const columns = usePlanColumns({
onEdit: openEdit,
onToggleStatus: toggleStatus,
pendingPlanId: statusMutation.variables?.id,
});
const plans = plansQuery.data?.items ?? [];
const total = plansQuery.data?.total ?? plansQuery.data?.totalItems ?? 0;
const toolbar = useMemo(
() => (
<PlanFilters
searchTerm={searchTerm}
statusFilter={statusFilter}
onSearchChange={setSearchTerm}
onStatusChange={setStatusFilter}
/>
),
[searchTerm, setSearchTerm, setStatusFilter, statusFilter],
);
return (
<>
<main className="relative z-10 space-y-5">
<PageHeader
title="Plan Management"
description="Manage subscription plans, limits, and permission access"
icon={BadgeIndianRupee}
actions={
<PermissionGuard permissions={PERMISSIONS.PLAN.CREATE}>
<Button onClick={openCreate}>
<Plus className="mr-2 size-4" />
Add Plan
</Button>
</PermissionGuard>
}
/>
<PlanTable
columns={columns}
plans={plans}
isLoading={plansQuery.isLoading}
toolbar={toolbar}
skip={skip}
limit={limit}
total={total}
onPageChange={setSkip}
/>
<PoweredBy />
</main>
<PlanSheet
open={isSheetOpen}
onOpenChange={handleSheetOpenChange}
planId={planId}
register={register}
control={control}
onSubmit={onSubmit}
permissionTree={permissionsQuery.permissionTree}
permissionIds={permissionIds}
onPermissionIdsChange={setPermissionIds}
isPermissionsLoading={permissionsQuery.isLoading}
isSaving={isSaving}
/>
</>
);
}

View File

@@ -0,0 +1,11 @@
import type { PlanListParams } from '@/types';
export const planKeys = {
all: ['plans'] as const,
lists: () => [...planKeys.all, 'list'] as const,
list: (params: PlanListParams) => [...planKeys.lists(), params] as const,
};
export const planPermissionTreeKeys = {
all: ['plan-permission-tree'] as const,
};

View File

@@ -19,6 +19,10 @@ export const appRoutes: AppRoute[] = [
path: ROUTES.CLIENTS, path: ROUTES.CLIENTS,
permission: PERMISSIONS.CLIENT.READ, permission: PERMISSIONS.CLIENT.READ,
}, },
{
path: ROUTES.PLANS,
permission: PERMISSIONS.PLAN.READ,
},
]; ];
export function getRoutePermission(pathname: string) { export function getRoutePermission(pathname: string) {

View File

@@ -8,6 +8,7 @@ import {
ShieldCheck, ShieldCheck,
Users, Users,
Building2, Building2,
BadgeIndianRupee,
} from 'lucide-react'; } from 'lucide-react';
import type { ComponentType, SVGProps } from 'react'; import type { ComponentType, SVGProps } from 'react';
@@ -70,6 +71,12 @@ export const menuItems: MenuItem[] = [
icon: Building2, icon: Building2,
permission: PERMISSIONS.CLIENT.READ, permission: PERMISSIONS.CLIENT.READ,
}, },
{
title: 'Plans',
path: ROUTES.PLANS,
icon: BadgeIndianRupee,
permission: PERMISSIONS.PLAN.READ,
},
], ],
}, },
]; ];

View File

@@ -17,6 +17,12 @@ export const PERMISSIONS = {
UPDATE: 'administration.client.update', UPDATE: 'administration.client.update',
DELETE: 'administration.client.delete', DELETE: 'administration.client.delete',
}, },
PLAN: {
CREATE: 'administration.subscriptions.create',
READ: 'administration.subscriptions.read',
UPDATE: 'administration.subscriptions.update',
DELETE: 'administration.subscriptions.delete',
},
LOG: { LOG: {
VIEW: 'log.view', VIEW: 'log.view',
}, },

View File

@@ -10,3 +10,4 @@ export * from './permission.service';
export * from './role.service'; export * from './role.service';
export * from './user.service'; export * from './user.service';
export * from './client.service'; export * from './client.service';
export * from './plan.service';

View File

@@ -1,9 +1,16 @@
import axiosClient from '../axios/axios'; import axiosClient from '../axios/axios';
import type { PermissionResponse } from '@/types'; import type { PermissionResponse, PermissionTreeNode } from '@/types';
export const permissionService = { export const permissionService = {
getPermissions: async (): Promise<PermissionResponse> => { getPermissions: async (): Promise<PermissionResponse> => {
const response = await axiosClient.get<PermissionResponse>('api/permissions/my-permissions'); const response = await axiosClient.get<PermissionResponse>('api/permissions/my-permissions');
return response.data; return response.data;
}, },
getOrganizationPermissionTree: async (): Promise<PermissionTreeNode[]> => {
const response = await axiosClient.get<PermissionTreeNode[]>(
'api/permissions/organization-tree',
);
return response.data;
},
}; };

View File

@@ -0,0 +1,56 @@
import axiosClient from '../axios/axios';
import type { Plan, PlanListParams, PlanListResponse, PlanRequest } from '@/types';
const PLANS_ENDPOINT = 'api/superadmin/plans';
function toPlanPayload(payload: PlanRequest) {
return {
name: payload.name,
slug: payload.slug,
description: payload.description,
price: payload.price,
billing_cycle: payload.billing_cycle,
trial_days: payload.trial_days,
max_projects: payload.max_projects,
max_organizations: payload.max_organizations,
max_users: payload.max_users,
max_roles: payload.max_roles,
permission_ids: payload.permission_ids,
is_active: payload.is_active,
is_custom: payload.is_custom,
};
}
export const planService = {
getPlans: async (params?: PlanListParams): Promise<PlanListResponse> => {
const response = await axiosClient.get<PlanListResponse>(PLANS_ENDPOINT, {
params: {
skip: params?.skip ?? 0,
limit: params?.limit ?? 10,
search_term: params?.search_term,
name: params?.name,
slug: params?.slug,
is_active: params?.is_active,
is_custom: params?.is_custom,
sort_by: params?.sort_by,
sort_order: params?.sort_order,
},
});
return response.data;
},
savePlan: async (payload: PlanRequest): Promise<Plan> => {
const requestPayload = toPlanPayload(payload);
const response = payload.id
? await axiosClient.put<Plan>(`${PLANS_ENDPOINT}/${payload.id}`, requestPayload)
: await axiosClient.post<Plan>(PLANS_ENDPOINT, requestPayload);
return response.data;
},
updatePlanStatus: async (id: number, isActive: boolean): Promise<Plan> => {
const response = await axiosClient.patch<Plan>(`${PLANS_ENDPOINT}/${id}/status`, {
is_active: isActive,
});
return response.data;
},
};

View File

@@ -11,3 +11,4 @@ export * from './permission';
export * from './role'; export * from './role';
export * from './user'; export * from './user';
export * from './client'; export * from './client';
export * from './plan';

56
src/types/plan.ts Normal file
View File

@@ -0,0 +1,56 @@
import type { PaginationParams } from './common';
export type PlanBillingCycle = 'monthly' | 'yearly' | 'quarterly' | string;
export interface Plan {
id: number;
name: string;
slug: string;
description: string;
price: string;
billing_cycle: PlanBillingCycle;
trial_days: number;
max_projects: number;
max_organizations: number;
max_users: number;
max_roles: number;
is_active: boolean;
is_custom: boolean;
permissions: string[];
created_by: number;
created_at: string;
updated_at: string | null;
}
export interface PlanRequest {
id?: number;
name: string;
slug: string;
description: string;
price: string;
billing_cycle: PlanBillingCycle;
trial_days: number;
max_projects: number;
max_organizations: number;
max_users: number;
max_roles: number;
permission_ids: number[];
is_active: boolean;
is_custom: boolean;
}
export interface PlanListParams extends PaginationParams {
search_term?: string;
name?: string;
slug?: string;
is_active?: boolean;
is_custom?: boolean;
sort_by?: string;
sort_order?: string;
}
export interface PlanListResponse {
items: Plan[];
total?: number;
totalItems?: number;
}

View File

@@ -7,6 +7,7 @@ export const ROUTES = {
ROLES: '/roles', ROLES: '/roles',
USERS: '/users', USERS: '/users',
CLIENTS: '/clients', CLIENTS: '/clients',
PLANS: '/plans',
ACCESS: '/access', ACCESS: '/access',
ACCOUNT: '/account', ACCOUNT: '/account',
UPLOAD: '/upload', UPLOAD: '/upload',