feat: add superadmin plan management module
This commit is contained in:
126
src/app/(modules)/plans/components/PlanColumns.tsx
Normal file
126
src/app/(modules)/plans/components/PlanColumns.tsx
Normal 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]);
|
||||
}
|
||||
51
src/app/(modules)/plans/components/PlanFilters.tsx
Normal file
51
src/app/(modules)/plans/components/PlanFilters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
237
src/app/(modules)/plans/components/PlanSheet.tsx
Normal file
237
src/app/(modules)/plans/components/PlanSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
src/app/(modules)/plans/components/PlanTable.tsx
Normal file
47
src/app/(modules)/plans/components/PlanTable.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user