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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
src/app/(modules)/plans/hooks/usePlanFilters.ts
Normal file
35
src/app/(modules)/plans/hooks/usePlanFilters.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
144
src/app/(modules)/plans/hooks/usePlanForm.ts
Normal file
144
src/app/(modules)/plans/hooks/usePlanForm.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
38
src/app/(modules)/plans/hooks/usePlanMutations.ts
Normal file
38
src/app/(modules)/plans/hooks/usePlanMutations.ts
Normal 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'),
|
||||||
|
});
|
||||||
|
}
|
||||||
64
src/app/(modules)/plans/hooks/usePlanQueries.ts
Normal file
64
src/app/(modules)/plans/hooks/usePlanQueries.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
189
src/app/(modules)/plans/page.tsx
Normal file
189
src/app/(modules)/plans/page.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/app/(modules)/plans/queries/planKeys.ts
Normal file
11
src/app/(modules)/plans/queries/planKeys.ts
Normal 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,
|
||||||
|
};
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
56
src/services/api/plan.service.ts
Normal file
56
src/services/api/plan.service.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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
56
src/types/plan.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user