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,
}}
/>
);
}