Compare commits
10 Commits
7911a3fd38
...
6f4399a048
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f4399a048 | |||
| 6c97f89b30 | |||
| d8587a43d4 | |||
| 964b177ae1 | |||
| 893b49c701 | |||
| bea47d29d5 | |||
| 8d4ff49633 | |||
| 9de65d0ce6 | |||
| 134e2d1bb8 | |||
| 925a2eb4fa |
7
.cursor/mcp.json
Normal file
7
.cursor/mcp.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"dual-graph": {
|
||||
"url": "http://127.0.0.1:8080/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -50,3 +50,4 @@ coverage/
|
||||
|
||||
# Misc
|
||||
.vercel
|
||||
.dual-graph/
|
||||
|
||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
111
package-lock.json
generated
111
package-lock.json
generated
@@ -17,6 +17,8 @@
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-query-devtools": "^5.101.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "^1.13.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -29,10 +31,12 @@
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-hook-form": "^7.79.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"recharts": "2.15.4",
|
||||
"sonner": "^1.7.4",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.9",
|
||||
@@ -41,6 +45,7 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"baseline-browser-mapping": "^2.10.37",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "^16.1.7",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
@@ -3416,6 +3421,59 @@
|
||||
"tailwindcss": "4.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.101.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz",
|
||||
"integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-devtools": {
|
||||
"version": "5.101.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.101.0.tgz",
|
||||
"integrity": "sha512-MVqw17k08RQtGGLEL654+dX/btbX9p/8WjkznO//zusLTMaObxi3Q+MoFwGVkC9K3tqjn8qrrNhJevXx4fJTeQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.101.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz",
|
||||
"integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.101.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query-devtools": {
|
||||
"version": "5.101.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.101.0.tgz",
|
||||
"integrity": "sha512-cpZA0+WqKXwrwMfiWZEGGF6QrIWVQFbhBtxqDF5sQsAfrFf47HIE6fiPbQU3wyAUEN2+7UNqLCQe7oG6m3f93w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-devtools": "5.101.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-table": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
|
||||
@@ -4531,9 +4589,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.8",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz",
|
||||
"integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==",
|
||||
"version": "2.10.37",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz",
|
||||
"integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -8523,6 +8581,22 @@
|
||||
"react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.79.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.79.0.tgz",
|
||||
"integrity": "sha512-mhYp/MTmXvzYX6AJcJVko0rktoIhhmRnEouObj4wF5i/tCttgJvnp1+9wRkpITZjDTqpo4IOSJqu0dBlPlV/Lw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/react-hook-form"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
@@ -10111,6 +10185,35 @@
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.14",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz",
|
||||
"integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18.0.0",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=18.0.0",
|
||||
"use-sync-external-store": ">=1.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"use-sync-external-store": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "16.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.10.tgz",
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-query-devtools": "^5.101.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "^1.13.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -41,10 +43,12 @@
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-hook-form": "^7.79.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"recharts": "2.15.4",
|
||||
"sonner": "^1.7.4",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.9",
|
||||
@@ -53,6 +57,7 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"baseline-browser-mapping": "^2.10.37",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "^16.1.7",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
|
||||
106
src/app/(full-page)/login/page.tsx
Normal file
106
src/app/(full-page)/login/page.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { GuestGuard } from '@/guards';
|
||||
import { useLoginForm } from '@/hooks/useLoginForm';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
|
||||
export default function LoginPage() {
|
||||
const { register, handleSubmit, errors, isLoading } = useLoginForm();
|
||||
|
||||
return (
|
||||
<GuestGuard>
|
||||
<main className="grid min-h-screen bg-background text-foreground lg:grid-cols-[1.05fr_0.95fr]">
|
||||
<section className="relative hidden overflow-hidden border-r border-border/60 lg:block">
|
||||
<Image
|
||||
src="/background.png"
|
||||
alt=""
|
||||
fill
|
||||
priority
|
||||
className="object-cover"
|
||||
sizes="55vw"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-background/35" />
|
||||
<div className="absolute inset-x-0 bottom-0 p-10">
|
||||
<div className="max-w-lg space-y-3">
|
||||
<p className="text-sm font-medium uppercase tracking-[0.18em] text-primary">
|
||||
VisionRoad
|
||||
</p>
|
||||
<h1 className="text-4xl font-semibold tracking-normal text-foreground">
|
||||
Road intelligence for faster inspection decisions
|
||||
</h1>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
Review projects, uploads, detections, and chainage insights from one secure
|
||||
workspace.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex min-h-screen items-center justify-center px-6 py-10">
|
||||
<div className="w-full max-w-sm space-y-7">
|
||||
<header className="space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">VisionRoad</p>
|
||||
<h2 className="text-2xl font-semibold tracking-normal">Sign in</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Use your email and password to continue.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form className="space-y-5" onSubmit={handleSubmit}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="admin@visionroad.ai"
|
||||
autoComplete="email"
|
||||
disabled={isLoading}
|
||||
aria-invalid={!!errors.email}
|
||||
{...register('email', {
|
||||
required: 'Email is required',
|
||||
})}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-destructive">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Enter password"
|
||||
autoComplete="current-password"
|
||||
disabled={isLoading}
|
||||
aria-invalid={!!errors.password}
|
||||
{...register('password', {
|
||||
required: 'Password is required',
|
||||
})}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-sm text-destructive">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Signing in
|
||||
</>
|
||||
) : (
|
||||
'Sign in'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</GuestGuard>
|
||||
);
|
||||
}
|
||||
20
src/app/(modules)/access/page.tsx
Normal file
20
src/app/(modules)/access/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { ShieldX } from 'lucide-react';
|
||||
|
||||
export default function AccessPage() {
|
||||
return (
|
||||
<main className="flex min-h-[calc(100vh-9rem)] items-center justify-center">
|
||||
<div className="flex max-w-md flex-col items-center text-center">
|
||||
<div className="mb-4 flex size-14 items-center justify-center rounded-full border bg-card">
|
||||
<ShieldX className="size-7 text-muted-foreground" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold">Access denied</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
You do not have permission to access any available module. Contact your administrator if
|
||||
you need access.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
111
src/app/(modules)/clients/components/ClientColumns.tsx
Normal file
111
src/app/(modules)/clients/components/ClientColumns.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
'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 { Client } 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));
|
||||
}
|
||||
|
||||
interface UseClientColumnsParams {
|
||||
onEdit: (client: Client) => void;
|
||||
onToggleStatus: (client: Client) => void;
|
||||
pendingClientId?: number;
|
||||
}
|
||||
|
||||
export function useClientColumns({
|
||||
onEdit,
|
||||
onToggleStatus,
|
||||
pendingClientId,
|
||||
}: UseClientColumnsParams): ColumnDef<Client>[] {
|
||||
const { hasPermission } = usePermissions();
|
||||
const canEdit = hasPermission(PERMISSIONS.CLIENT.UPDATE);
|
||||
const canDelete = hasPermission(PERMISSIONS.CLIENT.DELETE);
|
||||
|
||||
return useMemo(() => {
|
||||
const columns: ColumnDef<Client>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Client',
|
||||
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'email',
|
||||
header: 'Email',
|
||||
},
|
||||
{
|
||||
accessorKey: 'contact_name',
|
||||
header: 'Contact',
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p className="font-medium">{row.original.contact_name}</p>
|
||||
<p className="text-xs text-muted-foreground">{row.original.contact_phone_number}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'gst',
|
||||
header: 'GST',
|
||||
cell: ({ row }) => row.original.gst || '-',
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: 'Created',
|
||||
cell: ({ row }) => formatDate(row.original.created_at),
|
||||
},
|
||||
{
|
||||
accessorKey: 'is_active',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={row.original.is_active ? 'default' : 'secondary'}>
|
||||
{row.original.is_active ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (!canEdit && !canDelete) return columns;
|
||||
|
||||
columns.push({
|
||||
id: 'actions',
|
||||
header: () => <div className="text-right">Actions</div>,
|
||||
cell: ({ row }) => {
|
||||
const client = row.original;
|
||||
return (
|
||||
<div className="flex justify-end gap-2">
|
||||
{canEdit ? (
|
||||
<Button variant="outline" size="sm" onClick={() => onEdit(client)}>
|
||||
<Edit className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
{canDelete ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pendingClientId === client.id}
|
||||
onClick={() => onToggleStatus(client)}
|
||||
>
|
||||
<RotateCcw className="mr-2 size-4" />
|
||||
{client.is_active ? 'Deactivate' : 'Activate'}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return columns;
|
||||
}, [canDelete, canEdit, onEdit, onToggleStatus, pendingClientId]);
|
||||
}
|
||||
49
src/app/(modules)/clients/components/ClientFilters.tsx
Normal file
49
src/app/(modules)/clients/components/ClientFilters.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { ClientStatusFilter } from '../hooks/useClientFilters';
|
||||
|
||||
interface ClientFiltersProps {
|
||||
searchTerm: string;
|
||||
statusFilter: ClientStatusFilter;
|
||||
onSearchChange: (value: string) => void;
|
||||
onStatusChange: (value: ClientStatusFilter) => void;
|
||||
}
|
||||
|
||||
export function ClientFilters({
|
||||
searchTerm,
|
||||
statusFilter,
|
||||
onSearchChange,
|
||||
onStatusChange,
|
||||
}: ClientFiltersProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<Input
|
||||
value={searchTerm}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
placeholder="Search clients"
|
||||
className="md:max-w-sm"
|
||||
/>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={(value) => onStatusChange(value as ClientStatusFilter)}
|
||||
>
|
||||
<SelectTrigger className="md:w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All status</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="inactive">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
152
src/app/(modules)/clients/components/ClientSheet.tsx
Normal file
152
src/app/(modules)/clients/components/ClientSheet.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
'use client';
|
||||
|
||||
import type { ComponentProps } from 'react';
|
||||
import type { UseFormRegister } from 'react-hook-form';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import type { ClientFormValues } from '../hooks/useClientForm';
|
||||
|
||||
interface ClientSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
clientId?: number;
|
||||
register: UseFormRegister<ClientFormValues>;
|
||||
onSubmit: ComponentProps<'form'>['onSubmit'];
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
export function ClientSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
clientId,
|
||||
register,
|
||||
onSubmit,
|
||||
isSaving,
|
||||
}: ClientSheetProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{clientId ? 'Edit Client' : 'Create Client'}</DialogTitle>
|
||||
<DialogDescription>Manage company and primary contact details.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<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="client-name">Name</Label>
|
||||
<Input
|
||||
id="client-name"
|
||||
placeholder="Acme Corp"
|
||||
{...register('name', { required: true })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="client-email">Email</Label>
|
||||
<Input
|
||||
id="client-email"
|
||||
type="email"
|
||||
placeholder="info@acme.com"
|
||||
{...register('email', { required: true })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="landline-number">Landline Number</Label>
|
||||
<Input
|
||||
id="landline-number"
|
||||
placeholder="+91-22-12345678"
|
||||
{...register('landline_number', { required: true })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address">Address</Label>
|
||||
<Input
|
||||
id="address"
|
||||
placeholder="12 MG Road, Mumbai, MH 400001"
|
||||
{...register('address', { required: true })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gst">GST</Label>
|
||||
<Input id="gst" placeholder="27ABCDE1234F1Z5" {...register('gst')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pan">PAN</Label>
|
||||
<Input id="pan" placeholder="ABCDE1234F" {...register('pan')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tan">TAN</Label>
|
||||
<Input id="tan" placeholder="MUMA12345B" {...register('tan')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contact-name">Contact Name</Label>
|
||||
<Input
|
||||
id="contact-name"
|
||||
placeholder="Jane Doe"
|
||||
{...register('contact_name', { required: true })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contact-phone-number">Contact Phone</Label>
|
||||
<Input
|
||||
id="contact-phone-number"
|
||||
placeholder="+91-9876543210"
|
||||
{...register('contact_phone_number', { required: true })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contact-email">Contact Email</Label>
|
||||
<Input
|
||||
id="contact-email"
|
||||
type="email"
|
||||
placeholder="jane.doe@acme.com"
|
||||
{...register('contact_email', { required: true })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter 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}
|
||||
{clientId ? 'Update Client' : 'Create Client'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
47
src/app/(modules)/clients/components/ClientTable.tsx
Normal file
47
src/app/(modules)/clients/components/ClientTable.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 { Client } from '@/types';
|
||||
|
||||
interface ClientTableProps {
|
||||
columns: ColumnDef<Client>[];
|
||||
clients: Client[];
|
||||
isLoading: boolean;
|
||||
toolbar: ReactNode;
|
||||
skip: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
onPageChange: (skip: number) => void;
|
||||
}
|
||||
|
||||
export function ClientTable({
|
||||
columns,
|
||||
clients,
|
||||
isLoading,
|
||||
toolbar,
|
||||
skip,
|
||||
limit,
|
||||
total,
|
||||
onPageChange,
|
||||
}: ClientTableProps) {
|
||||
return (
|
||||
<DataTable
|
||||
title="Clients"
|
||||
columns={columns}
|
||||
data={clients}
|
||||
isLoading={isLoading}
|
||||
toolbar={toolbar}
|
||||
emptyTitle="No clients found."
|
||||
pagination={{
|
||||
skip,
|
||||
limit,
|
||||
totalItems: total,
|
||||
onPageChange,
|
||||
onLimitChange: () => undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
36
src/app/(modules)/clients/hooks/useClientFilters.ts
Normal file
36
src/app/(modules)/clients/hooks/useClientFilters.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
|
||||
export type ClientStatusFilter = 'all' | 'active' | 'inactive';
|
||||
|
||||
export function useClientFilters() {
|
||||
const [skip, setSkip] = useState(0);
|
||||
const [limit] = useState(10);
|
||||
const [searchTerm, setSearchTermValue] = useState('');
|
||||
const [statusFilter, setStatusFilterValue] = useState<ClientStatusFilter>('all');
|
||||
const debouncedSearchTerm = useDebounce(searchTerm.trim(), 400);
|
||||
|
||||
const setSearchTerm = useCallback((value: string) => {
|
||||
setSearchTermValue(value);
|
||||
setSkip(0);
|
||||
}, []);
|
||||
|
||||
const setStatusFilter = useCallback((value: ClientStatusFilter) => {
|
||||
setStatusFilterValue(value);
|
||||
setSkip(0);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
skip,
|
||||
setSkip,
|
||||
limit,
|
||||
searchTerm,
|
||||
debouncedSearchTerm,
|
||||
setSearchTerm,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
};
|
||||
}
|
||||
109
src/app/(modules)/clients/hooks/useClientForm.ts
Normal file
109
src/app/(modules)/clients/hooks/useClientForm.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Client } from '@/types';
|
||||
import { useSaveClientMutation } from './useClientMutations';
|
||||
|
||||
export interface ClientFormValues {
|
||||
id?: number;
|
||||
name: string;
|
||||
email: string;
|
||||
landline_number: string;
|
||||
address: string;
|
||||
gst: string;
|
||||
pan: string;
|
||||
tan: string;
|
||||
contact_name: string;
|
||||
contact_phone_number: string;
|
||||
contact_email: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
const defaultValues: ClientFormValues = {
|
||||
name: '',
|
||||
email: '',
|
||||
landline_number: '',
|
||||
address: '',
|
||||
gst: '',
|
||||
pan: '',
|
||||
tan: '',
|
||||
contact_name: '',
|
||||
contact_phone_number: '',
|
||||
contact_email: '',
|
||||
is_active: true,
|
||||
};
|
||||
|
||||
export function useClientForm({ onSaved }: { onSaved: () => void }) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit: submitForm,
|
||||
reset,
|
||||
control,
|
||||
formState: { isSubmitting, errors },
|
||||
} = useForm<ClientFormValues>({
|
||||
defaultValues,
|
||||
});
|
||||
const saveMutation = useSaveClientMutation({
|
||||
onSaved: () => {
|
||||
reset(defaultValues);
|
||||
onSaved();
|
||||
},
|
||||
});
|
||||
|
||||
const clientId = useWatch({ control, name: 'id' });
|
||||
|
||||
const handleSubmit = submitForm(
|
||||
(values) => saveMutation.mutate(values),
|
||||
(formErrors) => {
|
||||
if (
|
||||
formErrors.name ||
|
||||
formErrors.email ||
|
||||
formErrors.landline_number ||
|
||||
formErrors.address ||
|
||||
formErrors.contact_name ||
|
||||
formErrors.contact_phone_number ||
|
||||
formErrors.contact_email
|
||||
) {
|
||||
toast.error('Complete all required client fields');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
reset(defaultValues);
|
||||
}, [reset]);
|
||||
|
||||
const openEdit = useCallback(
|
||||
(client: Client) => {
|
||||
reset({
|
||||
id: client.id,
|
||||
name: client.name || '',
|
||||
email: client.email || '',
|
||||
landline_number: client.landline_number || '',
|
||||
address: client.address || '',
|
||||
gst: client.gst || '',
|
||||
pan: client.pan || '',
|
||||
tan: client.tan || '',
|
||||
contact_name: client.contact_name || '',
|
||||
contact_phone_number: client.contact_phone_number || '',
|
||||
contact_email: client.contact_email || '',
|
||||
is_active: client.is_active,
|
||||
});
|
||||
},
|
||||
[reset],
|
||||
);
|
||||
|
||||
return {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
openCreate,
|
||||
openEdit,
|
||||
clientId,
|
||||
errors,
|
||||
isSaving: isSubmitting || saveMutation.isPending,
|
||||
};
|
||||
}
|
||||
61
src/app/(modules)/clients/hooks/useClientMutations.ts
Normal file
61
src/app/(modules)/clients/hooks/useClientMutations.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { clientService } from '@/services/api';
|
||||
import type { Client } from '@/types';
|
||||
import { clientKeys } from '../queries/clientKeys';
|
||||
import type { ClientFormValues } from './useClientForm';
|
||||
|
||||
function optionalValue(value: string) {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
export function useSaveClientMutation({ onSaved }: { onSaved: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (values: ClientFormValues) =>
|
||||
clientService.saveClient({
|
||||
id: values.id,
|
||||
name: values.name.trim(),
|
||||
email: values.email.trim(),
|
||||
landline_number: values.landline_number.trim(),
|
||||
address: values.address.trim(),
|
||||
gst: optionalValue(values.gst),
|
||||
pan: optionalValue(values.pan),
|
||||
tan: optionalValue(values.tan),
|
||||
contact_name: values.contact_name.trim(),
|
||||
contact_phone_number: values.contact_phone_number.trim(),
|
||||
contact_email: values.contact_email.trim(),
|
||||
is_active: values.is_active,
|
||||
}),
|
||||
onSuccess: (_data, values) => {
|
||||
toast.success(values.id ? 'Client updated' : 'Client created');
|
||||
onSaved();
|
||||
queryClient.invalidateQueries({ queryKey: clientKeys.all });
|
||||
},
|
||||
onError: (_error, values) => {
|
||||
toast.error(values.id ? 'Failed to update client' : 'Failed to create client');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useClientStatusMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation({
|
||||
mutationFn: (client: Client) => clientService.updateClientStatus(client.id, !client.is_active),
|
||||
onSuccess: () => {
|
||||
toast.success('Client status updated');
|
||||
queryClient.invalidateQueries({ queryKey: clientKeys.all });
|
||||
},
|
||||
onError: () => toast.error('Failed to update client status'),
|
||||
});
|
||||
|
||||
return {
|
||||
...mutation,
|
||||
pendingClientId: mutation.isPending ? mutation.variables?.id : undefined,
|
||||
};
|
||||
}
|
||||
45
src/app/(modules)/clients/hooks/useClientQueries.ts
Normal file
45
src/app/(modules)/clients/hooks/useClientQueries.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { clientService } from '@/services/api';
|
||||
import type { ClientListParams } from '@/types';
|
||||
import { clientKeys } from '../queries/clientKeys';
|
||||
import type { ClientStatusFilter } from './useClientFilters';
|
||||
|
||||
interface UseClientsQueryParams {
|
||||
skip: number;
|
||||
limit: number;
|
||||
searchTerm: string;
|
||||
statusFilter: ClientStatusFilter;
|
||||
}
|
||||
|
||||
function buildClientListParams({
|
||||
skip,
|
||||
limit,
|
||||
searchTerm,
|
||||
statusFilter,
|
||||
}: UseClientsQueryParams): ClientListParams {
|
||||
return {
|
||||
skip,
|
||||
limit,
|
||||
search_term: searchTerm || undefined,
|
||||
is_active: statusFilter === 'all' ? undefined : statusFilter === 'active',
|
||||
sort_by: 'created_at',
|
||||
sort_order: 'desc',
|
||||
};
|
||||
}
|
||||
|
||||
export function useClientsQuery(params: UseClientsQueryParams) {
|
||||
const { skip, limit, searchTerm, statusFilter } = params;
|
||||
const listParams = useMemo(
|
||||
() => buildClientListParams({ skip, limit, searchTerm, statusFilter }),
|
||||
[limit, searchTerm, skip, statusFilter],
|
||||
);
|
||||
|
||||
return useQuery({
|
||||
queryKey: clientKeys.list(listParams),
|
||||
queryFn: () => clientService.getClients(listParams),
|
||||
});
|
||||
}
|
||||
130
src/app/(modules)/clients/page.tsx
Normal file
130
src/app/(modules)/clients/page.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Building2, 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 { Client } from '@/types';
|
||||
|
||||
import { useClientColumns } from './components/ClientColumns';
|
||||
import { ClientFilters } from './components/ClientFilters';
|
||||
import { ClientSheet } from './components/ClientSheet';
|
||||
import { ClientTable } from './components/ClientTable';
|
||||
import { useClientFilters } from './hooks/useClientFilters';
|
||||
import { useClientForm } from './hooks/useClientForm';
|
||||
import { useClientStatusMutation } from './hooks/useClientMutations';
|
||||
import { useClientsQuery } from './hooks/useClientQueries';
|
||||
|
||||
export default function ClientsPage() {
|
||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||
const {
|
||||
skip,
|
||||
setSkip,
|
||||
limit,
|
||||
searchTerm,
|
||||
debouncedSearchTerm,
|
||||
setSearchTerm,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
} = useClientFilters();
|
||||
|
||||
const clientsQuery = useClientsQuery({
|
||||
skip,
|
||||
limit,
|
||||
searchTerm: debouncedSearchTerm,
|
||||
statusFilter,
|
||||
});
|
||||
const clientForm = useClientForm({
|
||||
onSaved: () => setIsSheetOpen(false),
|
||||
});
|
||||
const { openCreate: prepareCreateClient, openEdit: prepareEditClient } = clientForm;
|
||||
const { register, handleSubmit, clientId, isSaving } = clientForm;
|
||||
const statusMutation = useClientStatusMutation();
|
||||
const { mutate: updateClientStatus, pendingClientId } = statusMutation;
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
prepareCreateClient();
|
||||
setIsSheetOpen(true);
|
||||
}, [prepareCreateClient]);
|
||||
|
||||
const openEdit = useCallback(
|
||||
(client: Client) => {
|
||||
prepareEditClient(client);
|
||||
setIsSheetOpen(true);
|
||||
},
|
||||
[prepareEditClient],
|
||||
);
|
||||
|
||||
const toggleStatus = useCallback(
|
||||
(client: Client) => {
|
||||
updateClientStatus(client);
|
||||
},
|
||||
[updateClientStatus],
|
||||
);
|
||||
|
||||
const columns = useClientColumns({
|
||||
onEdit: openEdit,
|
||||
onToggleStatus: toggleStatus,
|
||||
pendingClientId,
|
||||
});
|
||||
|
||||
const total = clientsQuery.data?.total ?? 0;
|
||||
const clients = clientsQuery.data?.items ?? [];
|
||||
const toolbar = useMemo(
|
||||
() => (
|
||||
<ClientFilters
|
||||
searchTerm={searchTerm}
|
||||
statusFilter={statusFilter}
|
||||
onSearchChange={setSearchTerm}
|
||||
onStatusChange={setStatusFilter}
|
||||
/>
|
||||
),
|
||||
[searchTerm, setSearchTerm, setStatusFilter, statusFilter],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="relative z-10 space-y-5">
|
||||
<PageHeader
|
||||
title="Client Management"
|
||||
description="Manage client company and contact details"
|
||||
icon={Building2}
|
||||
actions={
|
||||
<PermissionGuard permissions={PERMISSIONS.CLIENT.CREATE}>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="mr-2 size-4" />
|
||||
Add Client
|
||||
</Button>
|
||||
</PermissionGuard>
|
||||
}
|
||||
/>
|
||||
|
||||
<ClientTable
|
||||
columns={columns}
|
||||
clients={clients}
|
||||
isLoading={clientsQuery.isLoading}
|
||||
toolbar={toolbar}
|
||||
skip={skip}
|
||||
limit={limit}
|
||||
total={total}
|
||||
onPageChange={setSkip}
|
||||
/>
|
||||
|
||||
<PoweredBy />
|
||||
</main>
|
||||
|
||||
<ClientSheet
|
||||
open={isSheetOpen}
|
||||
onOpenChange={setIsSheetOpen}
|
||||
clientId={clientId}
|
||||
register={register}
|
||||
onSubmit={handleSubmit}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
7
src/app/(modules)/clients/queries/clientKeys.ts
Normal file
7
src/app/(modules)/clients/queries/clientKeys.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { ClientListParams } from '@/types';
|
||||
|
||||
export const clientKeys = {
|
||||
all: ['clients'] as const,
|
||||
lists: () => [...clientKeys.all, 'list'] as const,
|
||||
list: (params: ClientListParams) => [...clientKeys.lists(), params] as const,
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { ModeToggle } from '@/components/mode-toogle';
|
||||
import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { AuthGuard } from '@/guards';
|
||||
import React from 'react';
|
||||
|
||||
const ModulesLayout = ({
|
||||
@@ -11,23 +12,25 @@ const ModulesLayout = ({
|
||||
children: React.ReactNode;
|
||||
}>) => {
|
||||
return (
|
||||
<SidebarProvider className="bg-transparent overflow-hidden">
|
||||
<AppSidebar className="bg-transparent" />
|
||||
<main className="flex flex-1 flex-col w-full h-screen overflow-hidden bg-transparent">
|
||||
<div className="flex-1 overflow-auto">
|
||||
{/* Centering container wrapper */}
|
||||
<div className="max-w-380 mx-auto w-full flex flex-col min-h-full">
|
||||
<div className="flex gap-3 items-center sticky top-0 bg-background/30 backdrop-blur-xl z-30 py-3 px-6 border-b border-border/10">
|
||||
<SidebarTrigger />
|
||||
<ModeToggle />
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
<BreadcrumbBasic />
|
||||
<AuthGuard>
|
||||
<SidebarProvider className="bg-transparent overflow-hidden">
|
||||
<AppSidebar className="bg-transparent" />
|
||||
<main className="flex flex-1 flex-col w-full h-screen overflow-hidden bg-transparent">
|
||||
<div className="flex-1 overflow-auto">
|
||||
{/* Centering container wrapper */}
|
||||
<div className="max-w-380 mx-auto w-full flex flex-col min-h-full">
|
||||
<div className="flex gap-3 items-center sticky top-0 bg-background/30 backdrop-blur-xl z-30 py-3 px-6 border-b border-border/10">
|
||||
<SidebarTrigger />
|
||||
<ModeToggle />
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
<BreadcrumbBasic />
|
||||
</div>
|
||||
<div className="p-6 flex-1">{children}</div>
|
||||
</div>
|
||||
<div className="p-6 flex-1">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</SidebarProvider>
|
||||
</main>
|
||||
</SidebarProvider>
|
||||
</AuthGuard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
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,
|
||||
};
|
||||
96
src/app/(modules)/roles/components/RoleColumns.tsx
Normal file
96
src/app/(modules)/roles/components/RoleColumns.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
'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 { Role } from '@/types';
|
||||
|
||||
interface UseRoleColumnsParams {
|
||||
onEdit: (role: Role) => void;
|
||||
onToggleStatus: (role: Role) => void;
|
||||
isStatusPending: boolean;
|
||||
}
|
||||
|
||||
export function useRoleColumns({
|
||||
onEdit,
|
||||
onToggleStatus,
|
||||
isStatusPending,
|
||||
}: UseRoleColumnsParams): ColumnDef<Role>[] {
|
||||
const { hasPermission } = usePermissions();
|
||||
const canEdit = hasPermission(PERMISSIONS.ROLE.UPDATE);
|
||||
const canDelete = hasPermission(PERMISSIONS.ROLE.DELETE);
|
||||
|
||||
return useMemo(() => {
|
||||
const columns: ColumnDef<Role>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'display_name',
|
||||
header: 'Display Name',
|
||||
},
|
||||
{
|
||||
accessorKey: 'description',
|
||||
header: 'Description',
|
||||
cell: ({ row }) => (
|
||||
<span className="block max-w-sm truncate text-muted-foreground">
|
||||
{row.original.description || '-'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'user_count',
|
||||
header: 'Users',
|
||||
cell: ({ row }) => row.original.user_count ?? 0,
|
||||
},
|
||||
{
|
||||
accessorKey: 'effective_status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={row.original.effective_status ? 'default' : 'secondary'}>
|
||||
{row.original.effective_status ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (!canEdit && !canDelete) return columns;
|
||||
|
||||
columns.push({
|
||||
id: 'actions',
|
||||
header: () => <div className="text-right">Actions</div>,
|
||||
cell: ({ row }) => {
|
||||
const role = row.original;
|
||||
return (
|
||||
<div className="flex justify-end gap-2">
|
||||
{canEdit ? (
|
||||
<Button variant="outline" size="sm" onClick={() => onEdit(role)}>
|
||||
<Edit className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
{canDelete ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isStatusPending}
|
||||
onClick={() => onToggleStatus(role)}
|
||||
>
|
||||
<RotateCcw className="mr-2 size-4" />
|
||||
{role.effective_status ? 'Deactivate' : 'Activate'}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return columns;
|
||||
}, [canDelete, canEdit, isStatusPending, onEdit, onToggleStatus]);
|
||||
}
|
||||
47
src/app/(modules)/roles/components/RoleFilters.tsx
Normal file
47
src/app/(modules)/roles/components/RoleFilters.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
import type { StatusFilter } from '../hooks/useRoleFilters';
|
||||
|
||||
interface RoleFiltersProps {
|
||||
searchTerm: string;
|
||||
statusFilter: StatusFilter;
|
||||
onSearchChange: (value: string) => void;
|
||||
onStatusChange: (value: StatusFilter) => void;
|
||||
}
|
||||
|
||||
export function RoleFilters({
|
||||
searchTerm,
|
||||
statusFilter,
|
||||
onSearchChange,
|
||||
onStatusChange,
|
||||
}: RoleFiltersProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<Input
|
||||
value={searchTerm}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
placeholder="Search roles"
|
||||
className="md:max-w-sm"
|
||||
/>
|
||||
<Select value={statusFilter} onValueChange={(value) => onStatusChange(value as StatusFilter)}>
|
||||
<SelectTrigger className="md:w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All status</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="inactive">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
src/app/(modules)/roles/components/RoleSheet.tsx
Normal file
125
src/app/(modules)/roles/components/RoleSheet.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import type { ComponentProps } from 'react';
|
||||
import type { UseFormRegister } 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 {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import type { PermissionTreeItem } from '@/types';
|
||||
|
||||
import type { RoleFormValues } from '../hooks/useRoleForm';
|
||||
|
||||
interface RoleSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
roleId?: number;
|
||||
register: UseFormRegister<RoleFormValues>;
|
||||
onSubmit: ComponentProps<'form'>['onSubmit'];
|
||||
permissionTree: PermissionTreeItem[];
|
||||
permissionIds: number[];
|
||||
onPermissionIdsChange: (ids: number[]) => void;
|
||||
isPermissionsLoading: boolean;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
export function RoleSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
roleId,
|
||||
register,
|
||||
onSubmit,
|
||||
permissionTree,
|
||||
permissionIds,
|
||||
onPermissionIdsChange,
|
||||
isPermissionsLoading,
|
||||
isSaving,
|
||||
}: RoleSheetProps) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full overflow-y-auto sm:max-w-2xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{roleId ? 'Edit Role' : 'Create Role'}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Assign the role details and permission access for this organization.
|
||||
</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="role-name">Role Name</Label>
|
||||
<Input
|
||||
id="role-name"
|
||||
placeholder="Enter role name"
|
||||
{...register('name', { required: true })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role-display-name">Display Name</Label>
|
||||
<Input
|
||||
id="role-display-name"
|
||||
placeholder="Enter display name"
|
||||
{...register('display_name', { required: true })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role-description">Description</Label>
|
||||
<textarea
|
||||
id="role-description"
|
||||
placeholder="Describe this role"
|
||||
{...register('description')}
|
||||
className="min-h-24 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="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}
|
||||
{roleId ? 'Update Role' : 'Create Role'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
47
src/app/(modules)/roles/components/RoleTable.tsx
Normal file
47
src/app/(modules)/roles/components/RoleTable.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import type { Role } from '@/types';
|
||||
|
||||
interface RoleTableProps {
|
||||
columns: ColumnDef<Role>[];
|
||||
roles: Role[];
|
||||
isLoading: boolean;
|
||||
toolbar: ReactNode;
|
||||
skip: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
onPageChange: (skip: number) => void;
|
||||
}
|
||||
|
||||
export function RoleTable({
|
||||
columns,
|
||||
roles,
|
||||
isLoading,
|
||||
toolbar,
|
||||
skip,
|
||||
limit,
|
||||
total,
|
||||
onPageChange,
|
||||
}: RoleTableProps) {
|
||||
return (
|
||||
<DataTable
|
||||
title="Roles"
|
||||
columns={columns}
|
||||
data={roles}
|
||||
isLoading={isLoading}
|
||||
toolbar={toolbar}
|
||||
emptyTitle="No roles found."
|
||||
pagination={{
|
||||
skip,
|
||||
limit,
|
||||
totalItems: total,
|
||||
onPageChange,
|
||||
onLimitChange: () => undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
44
src/app/(modules)/roles/hooks/useRoleFilters.ts
Normal file
44
src/app/(modules)/roles/hooks/useRoleFilters.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export type StatusFilter = 'all' | 'active' | 'inactive';
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 400;
|
||||
|
||||
export function useRoleFilters() {
|
||||
const [skip, setSkip] = useState(0);
|
||||
const [limit] = useState(10);
|
||||
const [searchTerm, setSearchTermValue] = useState('');
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilterValue] = useState<StatusFilter>('all');
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
setDebouncedSearchTerm(searchTerm.trim());
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [searchTerm]);
|
||||
|
||||
const setSearchTerm = (value: string) => {
|
||||
setSearchTermValue(value);
|
||||
setSkip(0);
|
||||
};
|
||||
|
||||
const setStatusFilter = (value: StatusFilter) => {
|
||||
setStatusFilterValue(value);
|
||||
setSkip(0);
|
||||
};
|
||||
|
||||
return {
|
||||
skip,
|
||||
setSkip,
|
||||
limit,
|
||||
searchTerm,
|
||||
debouncedSearchTerm,
|
||||
setSearchTerm,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
};
|
||||
}
|
||||
120
src/app/(modules)/roles/hooks/useRoleForm.ts
Normal file
120
src/app/(modules)/roles/hooks/useRoleForm.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
collectDefaultPermissionIds,
|
||||
collectPermissionIdsByKeys,
|
||||
} from '@/components/permission-tree';
|
||||
import { roleService } from '@/services/api';
|
||||
import type { PermissionTreeItem, Role } from '@/types';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback } from 'react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { roleKeys } from '../queries/roleKeys';
|
||||
|
||||
export interface RoleFormValues {
|
||||
id?: number;
|
||||
name: string;
|
||||
display_name: string;
|
||||
description: string;
|
||||
permission_ids: number[];
|
||||
}
|
||||
|
||||
const defaultValues: RoleFormValues = {
|
||||
name: '',
|
||||
display_name: '',
|
||||
description: '',
|
||||
permission_ids: [],
|
||||
};
|
||||
|
||||
export function useRoleForm({
|
||||
organizationId,
|
||||
permissionTree,
|
||||
onSaved,
|
||||
}: {
|
||||
organizationId: number | null;
|
||||
permissionTree: PermissionTreeItem[];
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<RoleFormValues>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const permissionIds = useWatch({ control, name: 'permission_ids' }) || [];
|
||||
const roleId = useWatch({ control, name: 'id' });
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (values: RoleFormValues) =>
|
||||
roleService.saveRole({
|
||||
id: values.id,
|
||||
name: values.name.trim(),
|
||||
display_name: values.display_name.trim(),
|
||||
description: values.description.trim(),
|
||||
organization_id: organizationId,
|
||||
permission_ids: values.permission_ids,
|
||||
is_default: false,
|
||||
}),
|
||||
onSuccess: (_data, values) => {
|
||||
toast.success(values.id ? 'Role updated' : 'Role created');
|
||||
reset(defaultValues);
|
||||
onSaved();
|
||||
queryClient.invalidateQueries({ queryKey: roleKeys.all });
|
||||
},
|
||||
onError: (_error, values) => {
|
||||
toast.error(values.id ? 'Failed to update role' : 'Failed to create role');
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit((values) => {
|
||||
if (values.permission_ids.length === 0) {
|
||||
toast.error('Select at least one permission');
|
||||
return;
|
||||
}
|
||||
saveMutation.mutate(values);
|
||||
});
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
reset({
|
||||
...defaultValues,
|
||||
permission_ids: collectDefaultPermissionIds(permissionTree),
|
||||
});
|
||||
}, [permissionTree, reset]);
|
||||
|
||||
const openEdit = useCallback(
|
||||
(role: Role) => {
|
||||
reset({
|
||||
id: role.id,
|
||||
name: role.name || '',
|
||||
display_name: role.display_name || '',
|
||||
description: role.description || '',
|
||||
permission_ids: collectPermissionIdsByKeys(permissionTree, role.permissions || []),
|
||||
});
|
||||
},
|
||||
[permissionTree, reset],
|
||||
);
|
||||
|
||||
const setPermissionIds = useCallback(
|
||||
(ids: number[]) => setValue('permission_ids', ids, { shouldDirty: true, shouldValidate: true }),
|
||||
[setValue],
|
||||
);
|
||||
|
||||
return {
|
||||
register,
|
||||
onSubmit,
|
||||
openCreate,
|
||||
openEdit,
|
||||
setPermissionIds,
|
||||
permissionIds,
|
||||
roleId,
|
||||
isSaving: isSubmitting || saveMutation.isPending,
|
||||
};
|
||||
}
|
||||
78
src/app/(modules)/roles/hooks/useRoleQueries.ts
Normal file
78
src/app/(modules)/roles/hooks/useRoleQueries.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { mapPermissionTree } from '@/components/permission-tree';
|
||||
import { permissionService, roleService } from '@/services/api';
|
||||
import type { Role, RoleListParams } from '@/types';
|
||||
|
||||
import { roleKeys } from '../queries/roleKeys';
|
||||
import type { StatusFilter } from './useRoleFilters';
|
||||
|
||||
interface UseRolesQueryParams {
|
||||
skip: number;
|
||||
limit: number;
|
||||
searchTerm: string;
|
||||
statusFilter: StatusFilter;
|
||||
}
|
||||
|
||||
function buildRoleListParams({
|
||||
skip,
|
||||
limit,
|
||||
searchTerm,
|
||||
statusFilter,
|
||||
}: UseRolesQueryParams): RoleListParams {
|
||||
return {
|
||||
skip,
|
||||
limit,
|
||||
search_term: searchTerm || undefined,
|
||||
effective_status: statusFilter === 'all' ? undefined : statusFilter === 'active',
|
||||
sort_by: 'created_at',
|
||||
sort_order: 'desc',
|
||||
};
|
||||
}
|
||||
|
||||
export function useRolesQuery(params: UseRolesQueryParams) {
|
||||
const { skip, limit, searchTerm, statusFilter } = params;
|
||||
const listParams = useMemo(
|
||||
() => buildRoleListParams({ skip, limit, searchTerm, statusFilter }),
|
||||
[limit, searchTerm, skip, statusFilter],
|
||||
);
|
||||
|
||||
return useQuery({
|
||||
queryKey: roleKeys.list(listParams),
|
||||
queryFn: () => roleService.getRoles(listParams),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePermissionsTreeQuery(enabled: boolean) {
|
||||
const permissionsQuery = useQuery({
|
||||
queryKey: ['permissions', 'tree'],
|
||||
queryFn: permissionService.getPermissions,
|
||||
enabled,
|
||||
});
|
||||
const permissionTree = useMemo(
|
||||
() => mapPermissionTree(permissionsQuery.data?.tree || []),
|
||||
[permissionsQuery.data?.tree],
|
||||
);
|
||||
|
||||
return {
|
||||
...permissionsQuery,
|
||||
permissionTree,
|
||||
};
|
||||
}
|
||||
|
||||
export function useRoleStatusMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (role: Role) => roleService.updateRoleStatus(role.id, !role.effective_status),
|
||||
onSuccess: () => {
|
||||
toast.success('Role status updated');
|
||||
queryClient.invalidateQueries({ queryKey: roleKeys.all });
|
||||
},
|
||||
onError: () => toast.error('Failed to update role status'),
|
||||
});
|
||||
}
|
||||
147
src/app/(modules)/roles/page.tsx
Normal file
147
src/app/(modules)/roles/page.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Plus, ShieldCheck } 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 { useAppStore } from '@/store/app.store';
|
||||
import type { Role } from '@/types';
|
||||
|
||||
import { useRoleColumns } from './components/RoleColumns';
|
||||
import { RoleFilters } from './components/RoleFilters';
|
||||
import { RoleSheet } from './components/RoleSheet';
|
||||
import { RoleTable } from './components/RoleTable';
|
||||
import { useRoleFilters } from './hooks/useRoleFilters';
|
||||
import { useRoleForm } from './hooks/useRoleForm';
|
||||
import {
|
||||
usePermissionsTreeQuery,
|
||||
useRolesQuery,
|
||||
useRoleStatusMutation,
|
||||
} from './hooks/useRoleQueries';
|
||||
|
||||
export default function RolesPage() {
|
||||
const organizationId = useAppStore((state) => state.user?.organization_id ?? null);
|
||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||
const {
|
||||
skip,
|
||||
setSkip,
|
||||
limit,
|
||||
searchTerm,
|
||||
debouncedSearchTerm,
|
||||
setSearchTerm,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
} = useRoleFilters();
|
||||
|
||||
const rolesQuery = useRolesQuery({
|
||||
skip,
|
||||
limit,
|
||||
searchTerm: debouncedSearchTerm,
|
||||
statusFilter,
|
||||
});
|
||||
const permissionsQuery = usePermissionsTreeQuery(isSheetOpen);
|
||||
const roleForm = useRoleForm({
|
||||
organizationId,
|
||||
permissionTree: permissionsQuery.permissionTree,
|
||||
onSaved: () => setIsSheetOpen(false),
|
||||
});
|
||||
const {
|
||||
openCreate: prepareCreateRole,
|
||||
openEdit: prepareEditRole,
|
||||
register,
|
||||
onSubmit,
|
||||
roleId,
|
||||
permissionIds,
|
||||
setPermissionIds,
|
||||
isSaving,
|
||||
} = roleForm;
|
||||
const statusMutation = useRoleStatusMutation();
|
||||
const { mutate: updateRoleStatus, isPending: isStatusPending } = statusMutation;
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
prepareCreateRole();
|
||||
setIsSheetOpen(true);
|
||||
}, [prepareCreateRole]);
|
||||
|
||||
const openEdit = useCallback(
|
||||
(role: Role) => {
|
||||
prepareEditRole(role);
|
||||
setIsSheetOpen(true);
|
||||
},
|
||||
[prepareEditRole],
|
||||
);
|
||||
|
||||
const toggleStatus = useCallback(
|
||||
(role: Role) => {
|
||||
updateRoleStatus(role);
|
||||
},
|
||||
[updateRoleStatus],
|
||||
);
|
||||
|
||||
const columns = useRoleColumns({
|
||||
onEdit: openEdit,
|
||||
onToggleStatus: toggleStatus,
|
||||
isStatusPending,
|
||||
});
|
||||
|
||||
const total = rolesQuery.data?.total ?? 0;
|
||||
const roles = rolesQuery.data?.items ?? [];
|
||||
const toolbar = (
|
||||
<RoleFilters
|
||||
searchTerm={searchTerm}
|
||||
statusFilter={statusFilter}
|
||||
onSearchChange={setSearchTerm}
|
||||
onStatusChange={setStatusFilter}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="relative z-10 space-y-5">
|
||||
<PageHeader
|
||||
title="Role Management"
|
||||
description="Manage roles and permission access"
|
||||
icon={ShieldCheck}
|
||||
actions={
|
||||
<PermissionGuard permissions={PERMISSIONS.ROLE.CREATE}>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="mr-2 size-4" />
|
||||
Add Role
|
||||
</Button>
|
||||
</PermissionGuard>
|
||||
}
|
||||
/>
|
||||
|
||||
<RoleTable
|
||||
columns={columns}
|
||||
roles={roles}
|
||||
isLoading={rolesQuery.isLoading}
|
||||
toolbar={toolbar}
|
||||
skip={skip}
|
||||
limit={limit}
|
||||
total={total}
|
||||
onPageChange={setSkip}
|
||||
/>
|
||||
|
||||
<PoweredBy />
|
||||
</main>
|
||||
|
||||
<RoleSheet
|
||||
open={isSheetOpen}
|
||||
onOpenChange={setIsSheetOpen}
|
||||
roleId={roleId}
|
||||
register={register}
|
||||
onSubmit={onSubmit}
|
||||
permissionTree={permissionsQuery.permissionTree}
|
||||
permissionIds={permissionIds}
|
||||
onPermissionIdsChange={setPermissionIds}
|
||||
isPermissionsLoading={permissionsQuery.isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
9
src/app/(modules)/roles/queries/roleKeys.ts
Normal file
9
src/app/(modules)/roles/queries/roleKeys.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { RoleListParams } from '@/types';
|
||||
|
||||
export const roleKeys = {
|
||||
all: ['roles'] as const,
|
||||
lists: () => [...roleKeys.all, 'list'] as const,
|
||||
list: (params: RoleListParams) => [...roleKeys.lists(), params] as const,
|
||||
details: () => [...roleKeys.all, 'detail'] as const,
|
||||
detail: (id: number) => [...roleKeys.details(), id] as const,
|
||||
};
|
||||
111
src/app/(modules)/tenants/components/TenantColumns.tsx
Normal file
111
src/app/(modules)/tenants/components/TenantColumns.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { Edit, Trash2 } 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 { Tenant } 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));
|
||||
}
|
||||
|
||||
interface UseTenantColumnsParams {
|
||||
onEdit: (tenant: Tenant) => void;
|
||||
onDelete: (tenant: Tenant) => void;
|
||||
pendingDeleteId?: number;
|
||||
}
|
||||
|
||||
export function useTenantColumns({
|
||||
onEdit,
|
||||
onDelete,
|
||||
pendingDeleteId,
|
||||
}: UseTenantColumnsParams): ColumnDef<Tenant>[] {
|
||||
const { hasPermission } = usePermissions();
|
||||
const canEdit = hasPermission(PERMISSIONS.TENANT.UPDATE);
|
||||
const canDelete = hasPermission(PERMISSIONS.TENANT.DELETE);
|
||||
|
||||
return useMemo(() => {
|
||||
const columns: ColumnDef<Tenant>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Tenant',
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p className="font-medium">{row.original.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{row.original.slug}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'domain',
|
||||
header: 'Domain',
|
||||
cell: ({ row }) => row.original.domain || '-',
|
||||
},
|
||||
{
|
||||
accessorKey: 'admin_email',
|
||||
header: 'Admin Email',
|
||||
cell: ({ row }) => row.original.admin_email || '-',
|
||||
},
|
||||
{
|
||||
accessorKey: 'plan_id',
|
||||
header: 'Plan',
|
||||
cell: ({ row }) => row.original.plan_id || '-',
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: 'Created',
|
||||
cell: ({ row }) => formatDate(row.original.created_at),
|
||||
},
|
||||
{
|
||||
accessorKey: 'is_active',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={row.original.is_active ? 'default' : 'secondary'}>
|
||||
{row.original.is_active ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (!canEdit && !canDelete) return columns;
|
||||
|
||||
columns.push({
|
||||
id: 'actions',
|
||||
header: () => <div className="text-right">Actions</div>,
|
||||
cell: ({ row }) => {
|
||||
const tenant = row.original;
|
||||
return (
|
||||
<div className="flex justify-end gap-2">
|
||||
{canEdit ? (
|
||||
<Button variant="outline" size="sm" onClick={() => onEdit(tenant)}>
|
||||
<Edit className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
{canDelete ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pendingDeleteId === tenant.id}
|
||||
onClick={() => onDelete(tenant)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return columns;
|
||||
}, [canDelete, canEdit, onDelete, onEdit, pendingDeleteId]);
|
||||
}
|
||||
49
src/app/(modules)/tenants/components/TenantFilters.tsx
Normal file
49
src/app/(modules)/tenants/components/TenantFilters.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { TenantStatusFilter } from '../hooks/useTenantFilters';
|
||||
|
||||
interface TenantFiltersProps {
|
||||
searchTerm: string;
|
||||
statusFilter: TenantStatusFilter;
|
||||
onSearchChange: (value: string) => void;
|
||||
onStatusChange: (value: TenantStatusFilter) => void;
|
||||
}
|
||||
|
||||
export function TenantFilters({
|
||||
searchTerm,
|
||||
statusFilter,
|
||||
onSearchChange,
|
||||
onStatusChange,
|
||||
}: TenantFiltersProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<Input
|
||||
value={searchTerm}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
placeholder="Search tenants"
|
||||
className="md:max-w-sm"
|
||||
/>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={(value) => onStatusChange(value as TenantStatusFilter)}
|
||||
>
|
||||
<SelectTrigger className="md:w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All status</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="inactive">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
233
src/app/(modules)/tenants/components/TenantSheet.tsx
Normal file
233
src/app/(modules)/tenants/components/TenantSheet.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
'use client';
|
||||
|
||||
import type { ComponentProps } from 'react';
|
||||
import type { UseFormRegister } from 'react-hook-form';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { Client, Plan } from '@/types';
|
||||
|
||||
import type { TenantFormValues } from '../hooks/useTenantForm';
|
||||
|
||||
interface TenantSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
tenantId?: number;
|
||||
isEditMode: boolean;
|
||||
register: UseFormRegister<TenantFormValues>;
|
||||
onSubmit: ComponentProps<'form'>['onSubmit'];
|
||||
clientId: string;
|
||||
planId: string;
|
||||
onClientChange: (clientId: string) => void;
|
||||
onPlanChange: (planId: string) => void;
|
||||
onNameChange: (name: string) => void;
|
||||
clients: Client[];
|
||||
plans: Plan[];
|
||||
isLookupsLoading: boolean;
|
||||
isSaving: boolean;
|
||||
adminEmail?: string;
|
||||
}
|
||||
|
||||
export function TenantSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
tenantId,
|
||||
isEditMode,
|
||||
register,
|
||||
onSubmit,
|
||||
clientId,
|
||||
planId,
|
||||
onClientChange,
|
||||
onPlanChange,
|
||||
onNameChange,
|
||||
clients,
|
||||
plans,
|
||||
isLookupsLoading,
|
||||
isSaving,
|
||||
adminEmail,
|
||||
}: TenantSheetProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{tenantId ? 'Edit Tenant' : 'Create Tenant'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Bind a client and subscription plan, then invite the tenant administrator.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">Basic Information</p>
|
||||
<p className="text-xs text-muted-foreground">Tenant identity and subscription binding.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-name">Tenant Name</Label>
|
||||
<Input
|
||||
id="tenant-name"
|
||||
placeholder="Acme Corp"
|
||||
{...register('name', {
|
||||
required: true,
|
||||
onChange: (event) => onNameChange(event.target.value),
|
||||
})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-slug">Slug</Label>
|
||||
<Input
|
||||
id="tenant-slug"
|
||||
placeholder="acme-corp"
|
||||
{...register('slug', { required: true })}
|
||||
required
|
||||
readOnly={isEditMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Client</Label>
|
||||
<Select value={clientId} onValueChange={onClientChange} disabled={isLookupsLoading}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={isLookupsLoading ? 'Loading clients...' : 'Select client'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients.map((client) => (
|
||||
<SelectItem key={client.id} value={String(client.id)}>
|
||||
{client.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Subscription Plan</Label>
|
||||
<Select value={planId} onValueChange={onPlanChange} disabled={isLookupsLoading}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={isLookupsLoading ? 'Loading plans...' : 'Select plan'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{plans.map((plan) => (
|
||||
<SelectItem key={plan.id} value={String(plan.id)}>
|
||||
{plan.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-domain">Domain</Label>
|
||||
<Input id="tenant-domain" placeholder="acme.example.com" {...register('domain')} />
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor="tenant-description">Description</Label>
|
||||
<textarea
|
||||
id="tenant-description"
|
||||
placeholder="North zone operations"
|
||||
{...register('description')}
|
||||
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>
|
||||
|
||||
{!isEditMode ? (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">Tenant Administrator</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Invitation details for the primary tenant admin account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="admin-first-name">First Name</Label>
|
||||
<Input
|
||||
id="admin-first-name"
|
||||
placeholder="Jane"
|
||||
{...register('admin_first_name', { required: !isEditMode })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="admin-last-name">Last Name</Label>
|
||||
<Input
|
||||
id="admin-last-name"
|
||||
placeholder="Doe"
|
||||
{...register('admin_last_name', { required: !isEditMode })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="admin-email">Admin Email</Label>
|
||||
<Input
|
||||
id="admin-email"
|
||||
type="email"
|
||||
placeholder="jane@acme.com"
|
||||
{...register('admin_email', { required: !isEditMode })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="admin-phone">Phone Number</Label>
|
||||
<Input
|
||||
id="admin-phone"
|
||||
placeholder="+91-9000011111"
|
||||
{...register('admin_phone_number')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : adminEmail ? (
|
||||
<div className="rounded-md border bg-muted/40 p-4 text-sm text-muted-foreground">
|
||||
Admin invitation details cannot be changed after tenant creation.
|
||||
<p className="mt-1 text-foreground">
|
||||
Current admin email: <span className="font-medium">{adminEmail}</span>
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter className="px-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving || isLookupsLoading}>
|
||||
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
|
||||
{tenantId ? 'Update Tenant' : 'Create Tenant'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
47
src/app/(modules)/tenants/components/TenantTable.tsx
Normal file
47
src/app/(modules)/tenants/components/TenantTable.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 { Tenant } from '@/types';
|
||||
|
||||
interface TenantTableProps {
|
||||
columns: ColumnDef<Tenant>[];
|
||||
tenants: Tenant[];
|
||||
isLoading: boolean;
|
||||
toolbar: ReactNode;
|
||||
skip: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
onPageChange: (skip: number) => void;
|
||||
}
|
||||
|
||||
export function TenantTable({
|
||||
columns,
|
||||
tenants,
|
||||
isLoading,
|
||||
toolbar,
|
||||
skip,
|
||||
limit,
|
||||
total,
|
||||
onPageChange,
|
||||
}: TenantTableProps) {
|
||||
return (
|
||||
<DataTable
|
||||
title="Tenants"
|
||||
columns={columns}
|
||||
data={tenants}
|
||||
isLoading={isLoading}
|
||||
toolbar={toolbar}
|
||||
emptyTitle="No tenants found."
|
||||
pagination={{
|
||||
skip,
|
||||
limit,
|
||||
totalItems: total,
|
||||
onPageChange,
|
||||
onLimitChange: () => undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
36
src/app/(modules)/tenants/hooks/useTenantFilters.ts
Normal file
36
src/app/(modules)/tenants/hooks/useTenantFilters.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
|
||||
export type TenantStatusFilter = 'all' | 'active' | 'inactive';
|
||||
|
||||
export function useTenantFilters() {
|
||||
const [skip, setSkip] = useState(0);
|
||||
const [limit] = useState(10);
|
||||
const [searchTerm, setSearchTermValue] = useState('');
|
||||
const [statusFilter, setStatusFilterValue] = useState<TenantStatusFilter>('all');
|
||||
const debouncedSearchTerm = useDebounce(searchTerm.trim(), 400);
|
||||
|
||||
const setSearchTerm = useCallback((value: string) => {
|
||||
setSearchTermValue(value);
|
||||
setSkip(0);
|
||||
}, []);
|
||||
|
||||
const setStatusFilter = useCallback((value: TenantStatusFilter) => {
|
||||
setStatusFilterValue(value);
|
||||
setSkip(0);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
skip,
|
||||
setSkip,
|
||||
limit,
|
||||
searchTerm,
|
||||
debouncedSearchTerm,
|
||||
setSearchTerm,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
};
|
||||
}
|
||||
157
src/app/(modules)/tenants/hooks/useTenantForm.ts
Normal file
157
src/app/(modules)/tenants/hooks/useTenantForm.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Tenant } from '@/types';
|
||||
|
||||
import { useSaveTenantMutation } from './useTenantMutations';
|
||||
|
||||
export interface TenantFormValues {
|
||||
id?: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
client_id: string;
|
||||
plan_id: string;
|
||||
domain: string;
|
||||
description: string;
|
||||
admin_first_name: string;
|
||||
admin_last_name: string;
|
||||
admin_email: string;
|
||||
admin_phone_number: string;
|
||||
}
|
||||
|
||||
const defaultValues: TenantFormValues = {
|
||||
name: '',
|
||||
slug: '',
|
||||
client_id: '',
|
||||
plan_id: '',
|
||||
domain: '',
|
||||
description: '',
|
||||
admin_first_name: '',
|
||||
admin_last_name: '',
|
||||
admin_email: '',
|
||||
admin_phone_number: '',
|
||||
};
|
||||
|
||||
function createSlug(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function splitContactName(contactName: string) {
|
||||
const parts = contactName.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return { firstName: '', lastName: '' };
|
||||
if (parts.length === 1) return { firstName: parts[0], lastName: '' };
|
||||
return { firstName: parts[0], lastName: parts.slice(1).join(' ') };
|
||||
}
|
||||
|
||||
export function useTenantForm({ onSaved }: { onSaved: () => void }) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit: submitForm,
|
||||
reset,
|
||||
control,
|
||||
setValue,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<TenantFormValues>({
|
||||
defaultValues,
|
||||
});
|
||||
const saveMutation = useSaveTenantMutation({
|
||||
onSaved: () => {
|
||||
reset(defaultValues);
|
||||
onSaved();
|
||||
},
|
||||
});
|
||||
|
||||
const tenantId = useWatch({ control, name: 'id' });
|
||||
const clientId = useWatch({ control, name: 'client_id' }) || '';
|
||||
const planId = useWatch({ control, name: 'plan_id' }) || '';
|
||||
const adminEmail = useWatch({ control, name: 'admin_email' }) || '';
|
||||
const isEditMode = Boolean(tenantId);
|
||||
|
||||
const handleSubmit = submitForm(
|
||||
(values) => {
|
||||
if (!values.client_id || !values.plan_id) {
|
||||
toast.error('Select a client and plan');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isEditMode) {
|
||||
if (!values.admin_first_name.trim() || !values.admin_last_name.trim() || !values.admin_email.trim()) {
|
||||
toast.error('Complete all required admin fields');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
saveMutation.mutate(values);
|
||||
},
|
||||
() => toast.error('Complete all required tenant fields'),
|
||||
);
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
reset(defaultValues);
|
||||
}, [reset]);
|
||||
|
||||
const openEdit = useCallback(
|
||||
(tenant: Tenant) => {
|
||||
reset({
|
||||
id: tenant.id,
|
||||
name: tenant.name || '',
|
||||
slug: tenant.slug || '',
|
||||
client_id: String(tenant.client_id ?? ''),
|
||||
plan_id: String(tenant.plan_id ?? ''),
|
||||
domain: tenant.domain || '',
|
||||
description: tenant.description || '',
|
||||
admin_first_name: '',
|
||||
admin_last_name: '',
|
||||
admin_email: tenant.admin_email || '',
|
||||
admin_phone_number: '',
|
||||
});
|
||||
},
|
||||
[reset],
|
||||
);
|
||||
|
||||
const applyClientAdminDefaults = useCallback(
|
||||
(contactName: string, contactEmail: string, contactPhone: string) => {
|
||||
if (isEditMode) return;
|
||||
|
||||
const { firstName, lastName } = splitContactName(contactName);
|
||||
setValue('admin_first_name', firstName, { shouldDirty: true });
|
||||
setValue('admin_last_name', lastName, { shouldDirty: true });
|
||||
setValue('admin_email', contactEmail, { shouldDirty: true });
|
||||
setValue('admin_phone_number', contactPhone, { shouldDirty: true });
|
||||
},
|
||||
[isEditMode, setValue],
|
||||
);
|
||||
|
||||
const syncSlugFromName = useCallback(
|
||||
(name: string) => {
|
||||
if (isEditMode) return;
|
||||
setValue('slug', createSlug(name), { shouldDirty: true });
|
||||
},
|
||||
[isEditMode, setValue],
|
||||
);
|
||||
|
||||
return {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
control,
|
||||
setValue,
|
||||
openCreate,
|
||||
openEdit,
|
||||
tenantId,
|
||||
clientId,
|
||||
planId,
|
||||
adminEmail,
|
||||
isEditMode,
|
||||
applyClientAdminDefaults,
|
||||
syncSlugFromName,
|
||||
isSaving: isSubmitting || saveMutation.isPending,
|
||||
};
|
||||
}
|
||||
77
src/app/(modules)/tenants/hooks/useTenantMutations.ts
Normal file
77
src/app/(modules)/tenants/hooks/useTenantMutations.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { tenantService } from '@/services/api';
|
||||
|
||||
import { tenantKeys } from '../queries/tenantKeys';
|
||||
import type { TenantFormValues } from './useTenantForm';
|
||||
|
||||
function createSlug(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function optionalValue(value: string) {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function useSaveTenantMutation({ onSaved }: { onSaved: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (values: TenantFormValues) => {
|
||||
const basePayload = {
|
||||
name: values.name.trim(),
|
||||
slug: values.slug.trim() || createSlug(values.name),
|
||||
client_id: Number(values.client_id),
|
||||
plan_id: Number(values.plan_id),
|
||||
domain: optionalValue(values.domain),
|
||||
description: optionalValue(values.description),
|
||||
};
|
||||
|
||||
if (values.id) {
|
||||
return tenantService.updateTenant({
|
||||
id: values.id,
|
||||
...basePayload,
|
||||
});
|
||||
}
|
||||
|
||||
return tenantService.createTenant({
|
||||
...basePayload,
|
||||
admin: {
|
||||
first_name: values.admin_first_name.trim(),
|
||||
last_name: values.admin_last_name.trim(),
|
||||
email: values.admin_email.trim(),
|
||||
phone_number: optionalValue(values.admin_phone_number),
|
||||
},
|
||||
});
|
||||
},
|
||||
onSuccess: (_data, values) => {
|
||||
toast.success(values.id ? 'Tenant updated' : 'Tenant created');
|
||||
queryClient.invalidateQueries({ queryKey: tenantKeys.lists() });
|
||||
onSaved();
|
||||
},
|
||||
onError: (_error, values) => {
|
||||
toast.error(values.id ? 'Failed to update tenant' : 'Failed to create tenant');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteTenantMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (tenantId: number) => tenantService.deleteTenant(tenantId),
|
||||
onSuccess: () => {
|
||||
toast.success('Tenant deleted');
|
||||
queryClient.invalidateQueries({ queryKey: tenantKeys.lists() });
|
||||
},
|
||||
onError: () => toast.error('Failed to delete tenant'),
|
||||
});
|
||||
}
|
||||
82
src/app/(modules)/tenants/hooks/useTenantQueries.ts
Normal file
82
src/app/(modules)/tenants/hooks/useTenantQueries.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { clientService, planService, tenantService } from '@/services/api';
|
||||
import type { TenantListParams } from '@/types';
|
||||
|
||||
import { tenantKeys, tenantLookupKeys } from '../queries/tenantKeys';
|
||||
import type { TenantStatusFilter } from './useTenantFilters';
|
||||
|
||||
interface UseTenantsQueryParams {
|
||||
skip: number;
|
||||
limit: number;
|
||||
searchTerm: string;
|
||||
statusFilter: TenantStatusFilter;
|
||||
}
|
||||
|
||||
const clientLookupParams = {
|
||||
skip: 0,
|
||||
limit: 100,
|
||||
is_active: true,
|
||||
sort_by: 'name',
|
||||
sort_order: 'asc',
|
||||
} as const;
|
||||
|
||||
const planLookupParams = {
|
||||
skip: 0,
|
||||
limit: 100,
|
||||
is_active: true,
|
||||
sort_by: 'name',
|
||||
sort_order: 'asc',
|
||||
} as const;
|
||||
|
||||
function buildTenantListParams({
|
||||
skip,
|
||||
limit,
|
||||
searchTerm,
|
||||
statusFilter,
|
||||
}: UseTenantsQueryParams): TenantListParams {
|
||||
return {
|
||||
skip,
|
||||
limit,
|
||||
search_term: searchTerm || undefined,
|
||||
is_active: statusFilter === 'all' ? undefined : statusFilter === 'active',
|
||||
sort_by: 'created_at',
|
||||
sort_order: 'desc',
|
||||
};
|
||||
}
|
||||
|
||||
export function useTenantsQuery(params: UseTenantsQueryParams) {
|
||||
const listParams = useMemo(() => buildTenantListParams(params), [params]);
|
||||
|
||||
return useQuery({
|
||||
queryKey: tenantKeys.list(listParams),
|
||||
queryFn: () => tenantService.getTenants(listParams),
|
||||
});
|
||||
}
|
||||
|
||||
export function useClientLookupQuery(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: tenantLookupKeys.clients,
|
||||
queryFn: () => clientService.getClients(clientLookupParams),
|
||||
enabled,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 30 * 60 * 1000,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePlanLookupQuery(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: tenantLookupKeys.plans,
|
||||
queryFn: () => planService.getPlans(planLookupParams),
|
||||
enabled,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 30 * 60 * 1000,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
}
|
||||
191
src/app/(modules)/tenants/page.tsx
Normal file
191
src/app/(modules)/tenants/page.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Globe, 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 { clientService } from '@/services/api';
|
||||
import type { Tenant } from '@/types';
|
||||
|
||||
import { useTenantColumns } from './components/TenantColumns';
|
||||
import { TenantFilters } from './components/TenantFilters';
|
||||
import { TenantSheet } from './components/TenantSheet';
|
||||
import { TenantTable } from './components/TenantTable';
|
||||
import { useTenantFilters } from './hooks/useTenantFilters';
|
||||
import { useTenantForm } from './hooks/useTenantForm';
|
||||
import { useDeleteTenantMutation } from './hooks/useTenantMutations';
|
||||
import {
|
||||
useClientLookupQuery,
|
||||
usePlanLookupQuery,
|
||||
useTenantsQuery,
|
||||
} from './hooks/useTenantQueries';
|
||||
|
||||
export default function TenantsPage() {
|
||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||
const {
|
||||
skip,
|
||||
setSkip,
|
||||
limit,
|
||||
searchTerm,
|
||||
debouncedSearchTerm,
|
||||
setSearchTerm,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
} = useTenantFilters();
|
||||
|
||||
const tenantsQuery = useTenantsQuery({
|
||||
skip,
|
||||
limit,
|
||||
searchTerm: debouncedSearchTerm,
|
||||
statusFilter,
|
||||
});
|
||||
const clientsLookupQuery = useClientLookupQuery(isSheetOpen);
|
||||
const plansLookupQuery = usePlanLookupQuery(isSheetOpen);
|
||||
|
||||
const tenantForm = useTenantForm({
|
||||
onSaved: () => setIsSheetOpen(false),
|
||||
});
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
openCreate: prepareCreateTenant,
|
||||
openEdit: prepareEditTenant,
|
||||
tenantId,
|
||||
clientId,
|
||||
planId,
|
||||
adminEmail,
|
||||
isEditMode,
|
||||
applyClientAdminDefaults,
|
||||
syncSlugFromName,
|
||||
setValue,
|
||||
isSaving,
|
||||
} = tenantForm;
|
||||
|
||||
const deleteMutation = useDeleteTenantMutation();
|
||||
|
||||
const handleClientChange = useCallback(
|
||||
(value: string) => {
|
||||
setValue('client_id', value, { shouldDirty: true, shouldValidate: true });
|
||||
|
||||
if (!value || isEditMode) return;
|
||||
|
||||
clientService.getClientById(Number(value)).then((client) => {
|
||||
applyClientAdminDefaults(
|
||||
client.contact_name,
|
||||
client.contact_email,
|
||||
client.contact_phone_number,
|
||||
);
|
||||
});
|
||||
},
|
||||
[applyClientAdminDefaults, isEditMode, setValue],
|
||||
);
|
||||
|
||||
const handlePlanChange = useCallback(
|
||||
(value: string) => {
|
||||
setValue('plan_id', value, { shouldDirty: true, shouldValidate: true });
|
||||
},
|
||||
[setValue],
|
||||
);
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
prepareCreateTenant();
|
||||
setIsSheetOpen(true);
|
||||
}, [prepareCreateTenant]);
|
||||
|
||||
const openEdit = useCallback(
|
||||
(tenant: Tenant) => {
|
||||
prepareEditTenant(tenant);
|
||||
setIsSheetOpen(true);
|
||||
},
|
||||
[prepareEditTenant],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(tenant: Tenant) => {
|
||||
const confirmed = window.confirm(`Delete tenant "${tenant.name}"?`);
|
||||
if (!confirmed) return;
|
||||
deleteMutation.mutate(tenant.id);
|
||||
},
|
||||
[deleteMutation],
|
||||
);
|
||||
|
||||
const columns = useTenantColumns({
|
||||
onEdit: openEdit,
|
||||
onDelete: handleDelete,
|
||||
pendingDeleteId: deleteMutation.isPending ? deleteMutation.variables : undefined,
|
||||
});
|
||||
|
||||
const tenants = tenantsQuery.data?.items ?? [];
|
||||
const total = tenantsQuery.data?.total ?? 0;
|
||||
const clients = clientsLookupQuery.data?.items ?? [];
|
||||
const plans = plansLookupQuery.data?.items ?? [];
|
||||
const isLookupsLoading = clientsLookupQuery.isLoading || plansLookupQuery.isLoading;
|
||||
|
||||
const toolbar = useMemo(
|
||||
() => (
|
||||
<TenantFilters
|
||||
searchTerm={searchTerm}
|
||||
statusFilter={statusFilter}
|
||||
onSearchChange={setSearchTerm}
|
||||
onStatusChange={setStatusFilter}
|
||||
/>
|
||||
),
|
||||
[searchTerm, setSearchTerm, setStatusFilter, statusFilter],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="relative z-10 space-y-5">
|
||||
<PageHeader
|
||||
title="Tenant Management"
|
||||
description="Manage tenants, domains, and administrator invitations"
|
||||
icon={Globe}
|
||||
actions={
|
||||
<PermissionGuard permissions={PERMISSIONS.TENANT.CREATE}>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="mr-2 size-4" />
|
||||
Add Tenant
|
||||
</Button>
|
||||
</PermissionGuard>
|
||||
}
|
||||
/>
|
||||
|
||||
<TenantTable
|
||||
columns={columns}
|
||||
tenants={tenants}
|
||||
isLoading={tenantsQuery.isLoading}
|
||||
toolbar={toolbar}
|
||||
skip={skip}
|
||||
limit={limit}
|
||||
total={total}
|
||||
onPageChange={setSkip}
|
||||
/>
|
||||
|
||||
<PoweredBy />
|
||||
</main>
|
||||
|
||||
<TenantSheet
|
||||
open={isSheetOpen}
|
||||
onOpenChange={setIsSheetOpen}
|
||||
tenantId={tenantId}
|
||||
isEditMode={isEditMode}
|
||||
register={register}
|
||||
onSubmit={handleSubmit}
|
||||
clientId={clientId}
|
||||
planId={planId}
|
||||
onClientChange={handleClientChange}
|
||||
onPlanChange={handlePlanChange}
|
||||
onNameChange={syncSlugFromName}
|
||||
clients={clients}
|
||||
plans={plans}
|
||||
isLookupsLoading={isLookupsLoading}
|
||||
isSaving={isSaving}
|
||||
adminEmail={adminEmail}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
12
src/app/(modules)/tenants/queries/tenantKeys.ts
Normal file
12
src/app/(modules)/tenants/queries/tenantKeys.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { TenantListParams } from '@/types';
|
||||
|
||||
export const tenantKeys = {
|
||||
all: ['tenants'] as const,
|
||||
lists: () => [...tenantKeys.all, 'list'] as const,
|
||||
list: (params: TenantListParams) => [...tenantKeys.lists(), params] as const,
|
||||
};
|
||||
|
||||
export const tenantLookupKeys = {
|
||||
clients: ['tenant-lookup', 'clients'] as const,
|
||||
plans: ['tenant-lookup', 'plans'] as const,
|
||||
};
|
||||
@@ -150,7 +150,7 @@ export default function VideoProcessingPage() {
|
||||
<div className="flex-1 container mx-auto px-6 py-6 max-w-340 flex flex-col">
|
||||
<div className="mb-6">
|
||||
<PageHeader
|
||||
title="Processing Analysis"
|
||||
title="Uploading Analysis"
|
||||
description={`Real-time analysis progress for video ID: ${videoId}`}
|
||||
icon={TrendingUp}
|
||||
/>
|
||||
@@ -204,7 +204,7 @@ export default function VideoProcessingPage() {
|
||||
<Card className="flex-1 flex flex-col items-center justify-center py-12 px-8 min-h-[450px]">
|
||||
<div className="flex flex-col items-center justify-center w-full max-w-2xl space-y-10">
|
||||
<div className="text-center space-y-4">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Processing Analysis</h2>
|
||||
<h2 className="text-3xl font-bold tracking-tight">Uploading ...</h2>
|
||||
<p className="text-sm font-mono text-muted-foreground tracking-widest">
|
||||
ID: {videoId}
|
||||
</p>
|
||||
|
||||
123
src/app/(modules)/users/components/UserColumns.tsx
Normal file
123
src/app/(modules)/users/components/UserColumns.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
'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 { AdministrationUser } 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));
|
||||
}
|
||||
|
||||
interface UseUserColumnsParams {
|
||||
onEdit: (user: AdministrationUser) => void;
|
||||
onToggleStatus: (user: AdministrationUser) => void;
|
||||
pendingUserId?: number;
|
||||
}
|
||||
|
||||
export function useUserColumns({
|
||||
onEdit,
|
||||
onToggleStatus,
|
||||
pendingUserId,
|
||||
}: UseUserColumnsParams): ColumnDef<AdministrationUser>[] {
|
||||
const { hasPermission } = usePermissions();
|
||||
const canEdit = hasPermission(PERMISSIONS.USER.UPDATE);
|
||||
const canDelete = hasPermission(PERMISSIONS.USER.DELETE);
|
||||
|
||||
return useMemo(() => {
|
||||
const columns: ColumnDef<AdministrationUser>[] = [
|
||||
{
|
||||
accessorKey: 'first_name',
|
||||
header: 'Name',
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">
|
||||
{[row.original.first_name, row.original.last_name].filter(Boolean).join(' ') ||
|
||||
row.original.username}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'email',
|
||||
header: 'Email',
|
||||
},
|
||||
{
|
||||
accessorKey: 'phone_number',
|
||||
header: 'Phone',
|
||||
cell: ({ row }) => row.original.phone_number || '-',
|
||||
},
|
||||
{
|
||||
accessorKey: 'roles',
|
||||
header: 'Roles',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{row.original.roles?.length ? (
|
||||
row.original.roles.map((role) => (
|
||||
<Badge key={role.id ?? role.name} variant="outline">
|
||||
{role.display_name || role.name}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: 'Created',
|
||||
cell: ({ row }) => formatDate(row.original.created_at),
|
||||
},
|
||||
{
|
||||
accessorKey: 'effective_status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={row.original.effective_status === 'active' ? 'default' : 'secondary'}>
|
||||
{row.original.effective_status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (!canEdit && !canDelete) return columns;
|
||||
|
||||
columns.push({
|
||||
id: 'actions',
|
||||
header: () => <div className="text-right">Actions</div>,
|
||||
cell: ({ row }) => {
|
||||
const user = row.original;
|
||||
return (
|
||||
<div className="flex justify-end gap-2">
|
||||
{canEdit ? (
|
||||
<Button variant="outline" size="sm" onClick={() => onEdit(user)}>
|
||||
<Edit className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
{canDelete ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pendingUserId === user.id || user.effective_status === 'pending'}
|
||||
onClick={() => onToggleStatus(user)}
|
||||
>
|
||||
<RotateCcw className="mr-2 size-4" />
|
||||
{user.effective_status === 'active' ? 'Deactivate' : 'Activate'}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return columns;
|
||||
}, [canDelete, canEdit, onEdit, onToggleStatus, pendingUserId]);
|
||||
}
|
||||
47
src/app/(modules)/users/components/UserFilters.tsx
Normal file
47
src/app/(modules)/users/components/UserFilters.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { StatusFilter } from '../hooks/useUserFilters';
|
||||
|
||||
interface UserFiltersProps {
|
||||
searchTerm: string;
|
||||
statusFilter: StatusFilter;
|
||||
onSearchChange: (value: string) => void;
|
||||
onStatusChange: (value: StatusFilter) => void;
|
||||
}
|
||||
|
||||
export function UserFilters({
|
||||
searchTerm,
|
||||
statusFilter,
|
||||
onSearchChange,
|
||||
onStatusChange,
|
||||
}: UserFiltersProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<Input
|
||||
value={searchTerm}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
placeholder="Search users"
|
||||
className="md:max-w-sm"
|
||||
/>
|
||||
<Select value={statusFilter} onValueChange={(value) => onStatusChange(value as StatusFilter)}>
|
||||
<SelectTrigger className="md:w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All status</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="inactive">Inactive</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
src/app/(modules)/users/components/UserSheet.tsx
Normal file
142
src/app/(modules)/users/components/UserSheet.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
'use client';
|
||||
|
||||
import type { ComponentProps } from 'react';
|
||||
import type { UseFormRegister } from 'react-hook-form';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { Role } from '@/types';
|
||||
import type { UserFormValues } from '../hooks/useUserForm';
|
||||
|
||||
interface UserSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
userId?: number;
|
||||
register: UseFormRegister<UserFormValues>;
|
||||
onSubmit: ComponentProps<'form'>['onSubmit'];
|
||||
roleId: string;
|
||||
onRoleChange: (roleId: string) => void;
|
||||
roles: Role[];
|
||||
isRolesLoading: boolean;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
export function UserSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
userId,
|
||||
register,
|
||||
onSubmit,
|
||||
roleId,
|
||||
onRoleChange,
|
||||
roles,
|
||||
isRolesLoading,
|
||||
isSaving,
|
||||
}: UserSheetProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{userId ? 'Edit User' : 'Create User'}</DialogTitle>
|
||||
<DialogDescription>Assign user details and a role.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<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="first-name">First Name</Label>
|
||||
<Input
|
||||
id="first-name"
|
||||
placeholder="Enter first name"
|
||||
{...register('first_name', { required: true })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="last-name">Last Name</Label>
|
||||
<Input
|
||||
id="last-name"
|
||||
placeholder="Enter last name"
|
||||
{...register('last_name', { required: true })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="name@example.com"
|
||||
{...register('email', { required: true })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone-number">Phone Number</Label>
|
||||
<Input
|
||||
id="phone-number"
|
||||
placeholder="Enter phone number"
|
||||
{...register('phone_number')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Role</Label>
|
||||
<Select value={roleId} onValueChange={onRoleChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={isRolesLoading ? 'Loading roles...' : 'Select role'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.map((role) => (
|
||||
<SelectItem key={role.id} value={String(role.id)}>
|
||||
{role.display_name || role.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input
|
||||
type="hidden"
|
||||
{...register('role_id', { required: true })}
|
||||
value={roleId}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter 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}
|
||||
{userId ? 'Update User' : 'Create User'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
28
src/app/(modules)/users/components/UserStats.tsx
Normal file
28
src/app/(modules)/users/components/UserStats.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
interface UserStatsProps {
|
||||
total: number;
|
||||
activeCount: number;
|
||||
inactiveCount: number;
|
||||
pendingCount: number;
|
||||
}
|
||||
|
||||
export function UserStats({ total, activeCount, inactiveCount, pendingCount }: UserStatsProps) {
|
||||
const stats = [
|
||||
{ label: 'Total', value: total },
|
||||
{ label: 'Active', value: activeCount },
|
||||
{ label: 'Inactive', value: inactiveCount },
|
||||
{ label: 'Pending', value: pendingCount },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label} className="rounded-lg border bg-card p-4">
|
||||
<p className="text-xs text-muted-foreground">{stat.label}</p>
|
||||
<p className="mt-1 text-2xl font-semibold">{stat.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
src/app/(modules)/users/components/UserTable.tsx
Normal file
47
src/app/(modules)/users/components/UserTable.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 { AdministrationUser } from '@/types';
|
||||
|
||||
interface UserTableProps {
|
||||
columns: ColumnDef<AdministrationUser>[];
|
||||
users: AdministrationUser[];
|
||||
isLoading: boolean;
|
||||
toolbar: ReactNode;
|
||||
skip: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
onPageChange: (skip: number) => void;
|
||||
}
|
||||
|
||||
export function UserTable({
|
||||
columns,
|
||||
users,
|
||||
isLoading,
|
||||
toolbar,
|
||||
skip,
|
||||
limit,
|
||||
total,
|
||||
onPageChange,
|
||||
}: UserTableProps) {
|
||||
return (
|
||||
<DataTable
|
||||
title="Users"
|
||||
columns={columns}
|
||||
data={users}
|
||||
isLoading={isLoading}
|
||||
toolbar={toolbar}
|
||||
emptyTitle="No users found."
|
||||
pagination={{
|
||||
skip,
|
||||
limit,
|
||||
totalItems: total,
|
||||
onPageChange,
|
||||
onLimitChange: () => undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
36
src/app/(modules)/users/hooks/useUserFilters.ts
Normal file
36
src/app/(modules)/users/hooks/useUserFilters.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
import type { UserStatus } from '@/types';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
export type StatusFilter = 'all' | UserStatus;
|
||||
|
||||
export function useUserFilters() {
|
||||
const [skip, setSkip] = useState(0);
|
||||
const [limit] = useState(10);
|
||||
const [searchTerm, setSearchTermValue] = useState('');
|
||||
const [statusFilter, setStatusFilterValue] = useState<StatusFilter>('all');
|
||||
const debouncedSearchTerm = useDebounce(searchTerm.trim(), 400);
|
||||
|
||||
const setSearchTerm = useCallback((value: string) => {
|
||||
setSearchTermValue(value);
|
||||
setSkip(0);
|
||||
}, []);
|
||||
|
||||
const setStatusFilter = useCallback((value: StatusFilter) => {
|
||||
setStatusFilterValue(value);
|
||||
setSkip(0);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
skip,
|
||||
setSkip,
|
||||
limit,
|
||||
searchTerm,
|
||||
debouncedSearchTerm,
|
||||
setSearchTerm,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
};
|
||||
}
|
||||
99
src/app/(modules)/users/hooks/useUserForm.ts
Normal file
99
src/app/(modules)/users/hooks/useUserForm.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { AdministrationUser } from '@/types';
|
||||
import { useSaveUserMutation } from './useUserMutations';
|
||||
|
||||
export interface UserFormValues {
|
||||
id?: number;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
phone_number: string;
|
||||
role_id: string;
|
||||
}
|
||||
|
||||
const defaultValues: UserFormValues = {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
phone_number: '',
|
||||
role_id: '',
|
||||
};
|
||||
|
||||
export function useUserForm({ onSaved }: { onSaved: () => void }) {
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
handleSubmit: submitForm,
|
||||
reset,
|
||||
setValue,
|
||||
formState: { isSubmitting, errors },
|
||||
} = useForm<UserFormValues>({
|
||||
defaultValues,
|
||||
});
|
||||
const saveMutation = useSaveUserMutation({
|
||||
onSaved: () => {
|
||||
reset(defaultValues);
|
||||
onSaved();
|
||||
},
|
||||
});
|
||||
|
||||
const userId = useWatch({ control, name: 'id' });
|
||||
const roleId = useWatch({ control, name: 'role_id' }) || '';
|
||||
|
||||
const handleSubmit = submitForm(
|
||||
(values) => saveMutation.mutate(values),
|
||||
(formErrors) => {
|
||||
if (formErrors.first_name || formErrors.last_name || formErrors.email) {
|
||||
toast.error('First name, last name and email are required');
|
||||
return;
|
||||
}
|
||||
if (formErrors.role_id) {
|
||||
toast.error('Select a role');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
reset(defaultValues);
|
||||
}, [reset]);
|
||||
|
||||
const openEdit = useCallback(
|
||||
(user: AdministrationUser) => {
|
||||
const selectedRoleId = user.role_ids?.[0] ?? user.roles?.[0]?.id;
|
||||
reset({
|
||||
id: user.id,
|
||||
first_name: user.first_name || '',
|
||||
last_name: user.last_name || '',
|
||||
email: user.email || '',
|
||||
phone_number: user.phone_number || '',
|
||||
role_id: selectedRoleId ? String(selectedRoleId) : '',
|
||||
});
|
||||
},
|
||||
[reset],
|
||||
);
|
||||
|
||||
const setRoleId = useCallback(
|
||||
(value: string) => {
|
||||
setValue('role_id', value, { shouldDirty: true, shouldValidate: true });
|
||||
},
|
||||
[setValue],
|
||||
);
|
||||
|
||||
return {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
openCreate,
|
||||
openEdit,
|
||||
setRoleId,
|
||||
roleId,
|
||||
userId,
|
||||
errors,
|
||||
isSaving: isSubmitting || saveMutation.isPending,
|
||||
};
|
||||
}
|
||||
55
src/app/(modules)/users/hooks/useUserMutations.ts
Normal file
55
src/app/(modules)/users/hooks/useUserMutations.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { userService } from '@/services/api';
|
||||
import type { AdministrationUser, UserStatus } from '@/types';
|
||||
import { userKeys } from '../queries/userKeys';
|
||||
import type { UserFormValues } from './useUserForm';
|
||||
|
||||
function getNextStatus(status: UserStatus): 'active' | 'inactive' {
|
||||
return status === 'active' ? 'inactive' : 'active';
|
||||
}
|
||||
|
||||
export function useSaveUserMutation({ onSaved }: { onSaved: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (values: UserFormValues) =>
|
||||
userService.saveUser({
|
||||
id: values.id,
|
||||
email: values.email.trim(),
|
||||
first_name: values.first_name.trim(),
|
||||
last_name: values.last_name.trim(),
|
||||
phone_number: values.phone_number.trim() || undefined,
|
||||
roles: [Number(values.role_id)],
|
||||
}),
|
||||
onSuccess: (_data, values) => {
|
||||
toast.success(values.id ? 'User updated' : 'User created');
|
||||
onSaved();
|
||||
queryClient.invalidateQueries({ queryKey: userKeys.all });
|
||||
},
|
||||
onError: (_error, values) => {
|
||||
toast.error(values.id ? 'Failed to update user' : 'Failed to create user');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUserStatusMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation({
|
||||
mutationFn: (user: AdministrationUser) =>
|
||||
userService.updateUserStatus(user.id, getNextStatus(user.effective_status)),
|
||||
onSuccess: () => {
|
||||
toast.success('User status updated');
|
||||
queryClient.invalidateQueries({ queryKey: userKeys.all });
|
||||
},
|
||||
onError: () => toast.error('Failed to update user status'),
|
||||
});
|
||||
|
||||
return {
|
||||
...mutation,
|
||||
pendingUserId: mutation.isPending ? mutation.variables?.id : undefined,
|
||||
};
|
||||
}
|
||||
64
src/app/(modules)/users/hooks/useUserQueries.ts
Normal file
64
src/app/(modules)/users/hooks/useUserQueries.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { roleService, userService } from '@/services/api';
|
||||
import type { RoleListParams, UserListParams } from '@/types';
|
||||
import { roleKeys } from '../../roles/queries/roleKeys';
|
||||
import { userKeys } from '../queries/userKeys';
|
||||
import type { StatusFilter } from './useUserFilters';
|
||||
|
||||
interface UseUsersQueryParams {
|
||||
skip: number;
|
||||
limit: number;
|
||||
searchTerm: string;
|
||||
statusFilter: StatusFilter;
|
||||
}
|
||||
|
||||
const roleLookupParams: RoleListParams = {
|
||||
skip: 0,
|
||||
limit: 100,
|
||||
effective_status: true,
|
||||
sort_by: 'display_name',
|
||||
sort_order: 'asc',
|
||||
};
|
||||
|
||||
function buildUserListParams({
|
||||
skip,
|
||||
limit,
|
||||
searchTerm,
|
||||
statusFilter,
|
||||
}: UseUsersQueryParams): UserListParams {
|
||||
return {
|
||||
skip,
|
||||
limit,
|
||||
search_term: searchTerm || undefined,
|
||||
effective_status: statusFilter === 'all' ? undefined : statusFilter,
|
||||
sort_by: 'created_at',
|
||||
sort_order: 'desc',
|
||||
};
|
||||
}
|
||||
|
||||
export function useUsersQuery(params: UseUsersQueryParams) {
|
||||
const { skip, limit, searchTerm, statusFilter } = params;
|
||||
const listParams = useMemo(
|
||||
() => buildUserListParams({ skip, limit, searchTerm, statusFilter }),
|
||||
[limit, searchTerm, skip, statusFilter],
|
||||
);
|
||||
|
||||
return useQuery({
|
||||
queryKey: userKeys.list(listParams),
|
||||
queryFn: () => userService.getUsers(listParams),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRoleLookupQuery(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: roleKeys.list(roleLookupParams),
|
||||
queryFn: () => roleService.getRoles(roleLookupParams),
|
||||
enabled,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 30 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
152
src/app/(modules)/users/page.tsx
Normal file
152
src/app/(modules)/users/page.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Plus, Users } 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 { AdministrationUser } from '@/types';
|
||||
|
||||
import { useUserColumns } from './components/UserColumns';
|
||||
import { UserFilters } from './components/UserFilters';
|
||||
import { UserSheet } from './components/UserSheet';
|
||||
import { UserStats } from './components/UserStats';
|
||||
import { UserTable } from './components/UserTable';
|
||||
import { useUserFilters } from './hooks/useUserFilters';
|
||||
import { useUserForm } from './hooks/useUserForm';
|
||||
import { useUserStatusMutation } from './hooks/useUserMutations';
|
||||
import { useRoleLookupQuery, useUsersQuery } from './hooks/useUserQueries';
|
||||
|
||||
export default function UsersPage() {
|
||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||
const {
|
||||
skip,
|
||||
setSkip,
|
||||
limit,
|
||||
searchTerm,
|
||||
debouncedSearchTerm,
|
||||
setSearchTerm,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
} = useUserFilters();
|
||||
|
||||
const usersQuery = useUsersQuery({
|
||||
skip,
|
||||
limit,
|
||||
searchTerm: debouncedSearchTerm,
|
||||
statusFilter,
|
||||
});
|
||||
const rolesQuery = useRoleLookupQuery(isSheetOpen);
|
||||
const userForm = useUserForm({
|
||||
onSaved: () => setIsSheetOpen(false),
|
||||
});
|
||||
const {
|
||||
openCreate: prepareCreateUser,
|
||||
openEdit: prepareEditUser,
|
||||
register,
|
||||
handleSubmit,
|
||||
userId,
|
||||
roleId,
|
||||
setRoleId,
|
||||
isSaving,
|
||||
} = userForm;
|
||||
const statusMutation = useUserStatusMutation();
|
||||
const { mutate: updateUserStatus, pendingUserId } = statusMutation;
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
prepareCreateUser();
|
||||
setIsSheetOpen(true);
|
||||
}, [prepareCreateUser]);
|
||||
|
||||
const openEdit = useCallback(
|
||||
(user: AdministrationUser) => {
|
||||
prepareEditUser(user);
|
||||
setIsSheetOpen(true);
|
||||
},
|
||||
[prepareEditUser],
|
||||
);
|
||||
|
||||
const toggleStatus = useCallback(
|
||||
(user: AdministrationUser) => {
|
||||
updateUserStatus(user);
|
||||
},
|
||||
[updateUserStatus],
|
||||
);
|
||||
|
||||
const columns = useUserColumns({
|
||||
onEdit: openEdit,
|
||||
onToggleStatus: toggleStatus,
|
||||
pendingUserId,
|
||||
});
|
||||
|
||||
const total = usersQuery.data?.total ?? 0;
|
||||
const users = usersQuery.data?.items ?? [];
|
||||
const roleOptions = rolesQuery.data?.items ?? [];
|
||||
const toolbar = useMemo(
|
||||
() => (
|
||||
<UserFilters
|
||||
searchTerm={searchTerm}
|
||||
statusFilter={statusFilter}
|
||||
onSearchChange={setSearchTerm}
|
||||
onStatusChange={setStatusFilter}
|
||||
/>
|
||||
),
|
||||
[searchTerm, setSearchTerm, setStatusFilter, statusFilter],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="relative z-10 space-y-5">
|
||||
<PageHeader
|
||||
title="User Management"
|
||||
description="Manage users and role assignment"
|
||||
icon={Users}
|
||||
actions={
|
||||
<PermissionGuard permissions={PERMISSIONS.USER.CREATE}>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="mr-2 size-4" />
|
||||
Add User
|
||||
</Button>
|
||||
</PermissionGuard>
|
||||
}
|
||||
/>
|
||||
|
||||
<UserStats
|
||||
total={total}
|
||||
activeCount={usersQuery.data?.active_count ?? 0}
|
||||
inactiveCount={usersQuery.data?.inactive_count ?? 0}
|
||||
pendingCount={usersQuery.data?.pending_count ?? 0}
|
||||
/>
|
||||
|
||||
<UserTable
|
||||
columns={columns}
|
||||
users={users}
|
||||
isLoading={usersQuery.isLoading}
|
||||
toolbar={toolbar}
|
||||
skip={skip}
|
||||
limit={limit}
|
||||
total={total}
|
||||
onPageChange={setSkip}
|
||||
/>
|
||||
|
||||
<PoweredBy />
|
||||
</main>
|
||||
|
||||
<UserSheet
|
||||
open={isSheetOpen}
|
||||
onOpenChange={setIsSheetOpen}
|
||||
userId={userId}
|
||||
register={register}
|
||||
onSubmit={handleSubmit}
|
||||
roleId={roleId}
|
||||
onRoleChange={setRoleId}
|
||||
roles={roleOptions}
|
||||
isRolesLoading={rolesQuery.isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
src/app/(modules)/users/queries/userKeys.ts
Normal file
10
src/app/(modules)/users/queries/userKeys.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { UserListParams } from '@/types';
|
||||
|
||||
export const userKeys = {
|
||||
all: ['users'] as const,
|
||||
lists: () => [...userKeys.all, 'list'] as const,
|
||||
list: (filters: UserListParams) => [...userKeys.lists(), filters] as const,
|
||||
details: () => [...userKeys.all, 'detail'] as const,
|
||||
detail: (id: number) => [...userKeys.details(), id] as const,
|
||||
stats: () => [...userKeys.all, 'stats'] as const,
|
||||
};
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import { ThemeProvider } from '@/components/theme-provider';
|
||||
import { ThemeProvider } from '@/providers/ThemeProvider';
|
||||
import AppInitializer from '@/providers/AppInitializer';
|
||||
import QueryProvider from '@/providers/QueryProvider';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { BackgroundGradient } from '@/components/background-gradient';
|
||||
|
||||
@@ -34,11 +36,15 @@ export default function RootLayout({
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<div className="relative min-h-screen isolate">
|
||||
<BackgroundGradient />
|
||||
{children}
|
||||
</div>
|
||||
<Toaster position="top-center" />
|
||||
<QueryProvider>
|
||||
<AppInitializer>
|
||||
<div className="relative min-h-screen isolate">
|
||||
<BackgroundGradient />
|
||||
{children}
|
||||
</div>
|
||||
<Toaster position="top-center" />
|
||||
</AppInitializer>
|
||||
</QueryProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
21
src/app/not-found.tsx
Normal file
21
src/app/not-found.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-background p-6">
|
||||
<div className="max-w-md text-center">
|
||||
<p className="text-sm font-medium text-muted-foreground">404</p>
|
||||
<h1 className="mt-2 text-3xl font-semibold">Page not found</h1>
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
The page you are looking for does not exist or may have been moved.
|
||||
</p>
|
||||
<Button asChild className="mt-6">
|
||||
<Link href={ROUTES.DASHBOARD}>Go to dashboard</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
18
src/components/AppLoader.tsx
Normal file
18
src/components/AppLoader.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
interface AppLoaderProps {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function AppLoader({ label: _label }: AppLoaderProps) {
|
||||
return (
|
||||
<main
|
||||
className="flex min-h-screen items-center justify-center bg-background"
|
||||
aria-label="Loading"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="size-2.5 animate-bounce rounded-full bg-primary [animation-delay:-0.24s]" />
|
||||
<span className="size-2.5 animate-bounce rounded-full bg-primary [animation-delay:-0.12s]" />
|
||||
<span className="size-2.5 animate-bounce rounded-full bg-primary" />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
'use client';
|
||||
import * as React from 'react';
|
||||
import { LayoutDashboard, Plus, Layers, Package, MapPin, Milestone } from 'lucide-react';
|
||||
import { ChevronRight, Package } from 'lucide-react';
|
||||
import { NavUser } from '@/components/nav-user';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
import { filterMenuItems, menuItems, type MenuItem } from '@/config/menu.config';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -12,50 +14,26 @@ import {
|
||||
SidebarMenu,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
} from '@/components/ui/sidebar';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
// This is the navigation data
|
||||
const data = {
|
||||
user: {
|
||||
name: 'Vision Admin',
|
||||
email: 'admin@visionroad.ai',
|
||||
avatar: '/avatars/profile.jpg',
|
||||
},
|
||||
navMain: [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
url: ROUTES.DASHBOARD,
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
title: 'New Analysis',
|
||||
url: ROUTES.UPLOAD,
|
||||
icon: Plus,
|
||||
},
|
||||
{
|
||||
title: 'Project',
|
||||
url: ROUTES.PROJECT,
|
||||
icon: Layers,
|
||||
},
|
||||
{
|
||||
title: 'Package',
|
||||
url: ROUTES.PACKAGE,
|
||||
icon: Package,
|
||||
},
|
||||
{
|
||||
title: 'Segment',
|
||||
url: ROUTES.CHAINAGE,
|
||||
icon: Milestone,
|
||||
},
|
||||
],
|
||||
};
|
||||
import { useAppStore } from '@/store/app.store';
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const pathname = usePathname();
|
||||
const { hasPermission } = usePermissions();
|
||||
const authUser = useAppStore((state) => state.user);
|
||||
const navItems = filterMenuItems(menuItems, hasPermission);
|
||||
const user = {
|
||||
name: [authUser?.first_name, authUser?.last_name].filter(Boolean).join(' ') || 'Admin',
|
||||
email: authUser?.email || '',
|
||||
avatar: authUser?.profile_photo_url || '/avatars/profile.jpg',
|
||||
};
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" {...props}>
|
||||
@@ -73,23 +51,72 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarMenu>
|
||||
{data.navMain.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild tooltip={item.title} isActive={pathname === item.url}>
|
||||
<Link href={item.url}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
{navItems.map((item) => (
|
||||
<SidebarNavItem key={item.title} item={item} pathname={pathname} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser user={data.user} />
|
||||
<NavUser user={user} />
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarNavItem({ item, pathname }: { item: MenuItem; pathname: string }) {
|
||||
const isActive = item.path ? pathname === item.path : false;
|
||||
const isChildActive = item.children?.some((child) => child.path === pathname) ?? false;
|
||||
const hasChildren = Boolean(item.children?.length);
|
||||
|
||||
if (hasChildren) {
|
||||
return (
|
||||
<Collapsible asChild defaultOpen={isChildActive} className="group/collapsible">
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton tooltip={item.title} isActive={isChildActive}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{item.children?.map((child) => (
|
||||
<SidebarMenuSubItem key={child.title}>
|
||||
{child.path ? (
|
||||
<SidebarMenuSubButton asChild isActive={pathname === child.path}>
|
||||
<Link href={child.path}>
|
||||
<child.icon />
|
||||
<span>{child.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
) : null}
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
{item.path ? (
|
||||
<SidebarMenuButton asChild tooltip={item.title} isActive={isActive}>
|
||||
<Link href={item.path}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
) : (
|
||||
<SidebarMenuButton tooltip={item.title} isActive={isChildActive}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</SidebarMenuButton>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,70 +1,38 @@
|
||||
'use client';
|
||||
import { Table } from '@tanstack/react-table';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
export function TableFooter<TData>({ table }: { table: Table<TData> }) {
|
||||
export function TableFooter<TData>({
|
||||
table,
|
||||
totalItems,
|
||||
}: {
|
||||
table: Table<TData>;
|
||||
totalItems: number;
|
||||
}) {
|
||||
const rows = table.getRowModel().rows.length;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-border/40 bg-muted/5">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider">
|
||||
Rows per page
|
||||
</p>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[70px] bg-transparent border-border/40 text-xs font-semibold">
|
||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top" className="min-w-[70px]">
|
||||
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`} className="text-xs">
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="flex items-center text-[11px] font-bold text-muted-foreground uppercase tracking-widest gap-1">
|
||||
<span className="text-foreground">Page {table.getState().pagination.pageIndex + 1}</span>
|
||||
<span className="opacity-40">/</span>
|
||||
<span>{table.getPageCount() || 1}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
|
||||
<span>
|
||||
Showing {rows} of {totalItems}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0 border-border/40 bg-transparent hover:bg-muted/50 transition-colors"
|
||||
size="sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
<ChevronLeft className="h-4 w-4 opacity-70" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0 border-border/40 bg-transparent hover:bg-muted/50 transition-colors"
|
||||
size="sm"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
<ChevronRight className="h-4 w-4 opacity-70" />
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,45 +1,40 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import SearchBar from './SearchBar';
|
||||
import { FolderPlus, Settings2, SlidersHorizontal } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
interface TopHeaderProps {
|
||||
title?: string;
|
||||
itemCount?: number;
|
||||
onAddNew?: () => void;
|
||||
addButtonText?: string;
|
||||
toolbar?: React.ReactNode;
|
||||
}
|
||||
|
||||
const TopHeader = ({ title, itemCount, onAddNew, addButtonText = 'Add New' }: TopHeaderProps) => {
|
||||
const TopHeader = ({
|
||||
title,
|
||||
itemCount,
|
||||
onAddNew,
|
||||
addButtonText = 'Add New',
|
||||
toolbar,
|
||||
}: TopHeaderProps) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 p-3 w-full">
|
||||
{/* Connected Summary Block */}
|
||||
<div className="w-full bg-muted/10 border border-border/50 rounded-lg p-4 flex items-center">
|
||||
<div className="flex items-center gap-2 text-muted-foreground font-semibold tracking-tight">
|
||||
<span className="text-base text-foreground/80">Total {title || 'Items'} :</span>
|
||||
<span className="text-primary font-bold text-lg">{itemCount || 0}</span>
|
||||
<div className="space-y-4">
|
||||
{(title || onAddNew) && (
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
{title ? <h2 className="text-base font-semibold">{title}</h2> : null}
|
||||
<p className="text-sm text-muted-foreground">Total {itemCount ?? 0}</p>
|
||||
</div>
|
||||
{onAddNew ? (
|
||||
<Button onClick={onAddNew}>
|
||||
<Plus className="mr-2 size-4" />
|
||||
{addButtonText}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* Search Bar Hidden for now as per requirement */}
|
||||
{/* <div className="flex w-full max-w-sm">
|
||||
<SearchBar />
|
||||
</div> */}
|
||||
|
||||
{/* Add New Button moved to PageHeader */}
|
||||
{/* {onAddNew && (
|
||||
<Button
|
||||
onClick={onAddNew}
|
||||
className="h-11 px-6 rounded-xl bg-primary hover:bg-primary/90 text-primary-foreground font-bold text-sm shadow-md shadow-primary/20 transition-all hover:-translate-y-0.5"
|
||||
>
|
||||
<FolderPlus className="mr-2 h-5 w-5" />
|
||||
{addButtonText}
|
||||
</Button>
|
||||
)} */}
|
||||
</div>
|
||||
)}
|
||||
{toolbar ? <div>{toolbar}</div> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,21 +2,20 @@
|
||||
import { flexRender } from '@tanstack/react-table';
|
||||
import { TableHead, TableHeader as ShadTableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Table } from '@tanstack/react-table';
|
||||
import { ChevronDown, ChevronsUpDown } from 'lucide-react';
|
||||
|
||||
const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
|
||||
return (
|
||||
<ShadTableHeader>
|
||||
<ShadTableHeader className="sticky top-0 z-10 bg-card">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="hover:bg-transparent border-b border-border/30">
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className="h-14 px-6 text-muted-foreground border-b border-border/30 font-medium text-sm"
|
||||
className="h-10 px-2 text-foreground bg-card"
|
||||
>
|
||||
{header.isPlaceholder ? null : (
|
||||
<div className="flex items-center gap-2 group cursor-pointer select-none">
|
||||
<div className="flex items-center gap-2">
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -13,12 +13,11 @@ import {
|
||||
import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Edit3, MoreHorizontal, Trash2 } from 'lucide-react';
|
||||
import { Edit3, Trash2 } from 'lucide-react';
|
||||
|
||||
import TopHeader from './Header';
|
||||
import TableHeader from './TableHeader';
|
||||
import { TableFooter } from './Footer';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
@@ -29,6 +28,9 @@ export interface DataTableProps<TData, TValue> {
|
||||
isLoading?: boolean;
|
||||
onEdit?: (item: TData) => void;
|
||||
onDelete?: (item: TData) => void;
|
||||
toolbar?: React.ReactNode;
|
||||
emptyTitle?: string;
|
||||
emptyDescription?: string;
|
||||
pagination?: {
|
||||
skip: number;
|
||||
limit: number;
|
||||
@@ -47,6 +49,9 @@ export function DataTable<TData, TValue>({
|
||||
isLoading = false,
|
||||
onEdit,
|
||||
onDelete,
|
||||
toolbar,
|
||||
emptyTitle = 'No results found.',
|
||||
emptyDescription = 'Try adjusting your filters or search terms.',
|
||||
pagination,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [rowSelection, setRowSelection] = React.useState({});
|
||||
@@ -58,32 +63,34 @@ export function DataTable<TData, TValue>({
|
||||
if (onEdit || onDelete) {
|
||||
cols.push({
|
||||
id: 'actions',
|
||||
header: () => <div className="text-right px-4">Action</div>,
|
||||
header: () => <div className="text-right">Actions</div>,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original;
|
||||
return (
|
||||
<div className="flex justify-end gap-3 px-4">
|
||||
<div className="flex justify-end gap-2">
|
||||
{onEdit && (
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(item);
|
||||
}}
|
||||
className="flex items-center justify-center h-8 w-8 rounded-md text-primary hover:bg-foreground/10 transition-all duration-200"
|
||||
>
|
||||
<Edit3 className="h-4.5 w-4.5" />
|
||||
</button>
|
||||
<Edit3 className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(item);
|
||||
}}
|
||||
className="flex items-center justify-center h-8 w-8 rounded-md text-red-500 hover:bg-foreground/10 transition-all duration-200"
|
||||
>
|
||||
<Trash2 className="h-4.5 w-4.5" />
|
||||
</button>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -126,31 +133,28 @@ export function DataTable<TData, TValue>({
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border/50 bg-muted/10 p-1 space-y-6">
|
||||
<div className="bg-background/40 dark:bg-background/60 backdrop-blur-xl rounded-xl border border-border/50 overflow-hidden transition-all duration-300">
|
||||
<section className="space-y-4 rounded-lg border bg-card p-4">
|
||||
<TopHeader
|
||||
title={title}
|
||||
itemCount={data.length}
|
||||
title={onAddNew ? title : undefined}
|
||||
itemCount={pagination?.totalItems ?? data.length}
|
||||
onAddNew={onAddNew}
|
||||
addButtonText={addButtonText}
|
||||
toolbar={toolbar}
|
||||
/>
|
||||
|
||||
<div className="px-6 py-2">
|
||||
<div>
|
||||
<Table
|
||||
containerClassName="max-h-[calc(100vh-300px)] overflow-y-auto scrollbar-thin"
|
||||
containerClassName="max-h-[calc(100vh-300px)] overflow-y-auto"
|
||||
className="border-separate border-spacing-0"
|
||||
>
|
||||
<TableHeader table={table} />
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
Array.from({ length: 5 }).map((_, idx) => (
|
||||
<TableRow
|
||||
key={idx}
|
||||
className="border-b border-border/30 last:border-0 hover:bg-muted/5"
|
||||
>
|
||||
<TableRow key={idx}>
|
||||
{columns.map((_, colIdx) => (
|
||||
<TableCell key={colIdx} className="px-6 py-6 border-b border-border/30">
|
||||
<Skeleton className="h-4 w-full max-w-[140px] opacity-20" />
|
||||
<TableCell key={colIdx}>
|
||||
<Skeleton className="h-4 w-full max-w-[140px]" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
@@ -160,13 +164,9 @@ export function DataTable<TData, TValue>({
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
className="group hover:bg-muted/10 transition-colors"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className="px-4 py-3 border-b border-border/50 align-middle text-muted-foreground text-sm font-medium group-hover:text-foreground"
|
||||
>
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
@@ -174,12 +174,10 @@ export function DataTable<TData, TValue>({
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-48 text-center">
|
||||
<TableCell colSpan={columns.length} className="h-28 text-center">
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground gap-1">
|
||||
<p className="font-bold text-sm tracking-tight">No results found.</p>
|
||||
<p className="text-xs opacity-60 font-medium">
|
||||
Try adjusting your filters or search terms.
|
||||
</p>
|
||||
<p className="text-sm font-medium">{emptyTitle}</p>
|
||||
<p className="text-xs">{emptyDescription}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -187,8 +185,7 @@ export function DataTable<TData, TValue>({
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<TableFooter table={table} />
|
||||
</div>
|
||||
</div>
|
||||
<TableFooter table={table} totalItems={pagination?.totalItems ?? data.length} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { BadgeCheck, Bell, ChevronsUpDown, CreditCard, LogOut, Sparkles } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import {
|
||||
@@ -18,6 +19,11 @@ import {
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { authService } from '@/services/api/auth.service';
|
||||
import { useAppStore } from '@/store/app.store';
|
||||
import { useAuthStore } from '@/store/auth.store';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function NavUser({
|
||||
user,
|
||||
@@ -29,6 +35,27 @@ export function NavUser({
|
||||
};
|
||||
}) {
|
||||
const { isMobile } = useSidebar();
|
||||
const router = useRouter();
|
||||
const clearAuth = useAuthStore((state) => state.logout);
|
||||
const clearApp = useAppStore((state) => state.clear);
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (isLoggingOut) return;
|
||||
|
||||
setIsLoggingOut(true);
|
||||
|
||||
try {
|
||||
await authService.logout();
|
||||
} catch {
|
||||
// Local logout should still complete if the server session is already invalid.
|
||||
} finally {
|
||||
clearAuth();
|
||||
clearApp();
|
||||
router.replace(ROUTES.LOGIN);
|
||||
setIsLoggingOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
@@ -91,9 +118,15 @@ export function NavUser({
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<LogOut />
|
||||
Log out
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
handleLogout();
|
||||
}}
|
||||
disabled={isLoggingOut}
|
||||
>
|
||||
<LogOut />
|
||||
{isLoggingOut ? 'Logging out' : 'Log out'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
56
src/components/permission-tree.tsx
Normal file
56
src/components/permission-tree.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { CheckboxTree } from '@/components/ui/tree';
|
||||
import type { PermissionTreeItem, PermissionTreeNode } from '@/types';
|
||||
|
||||
export function mapPermissionTree(nodes: PermissionTreeNode[]): PermissionTreeItem[] {
|
||||
return nodes.map((node) => ({
|
||||
id: node.id,
|
||||
key: node.slug || node.name,
|
||||
label: node.display_name || node.name,
|
||||
description: node.description,
|
||||
type: node.type,
|
||||
isGrantedByDefault: Boolean(node.is_granted_by_default),
|
||||
children: mapPermissionTree(node.children || []),
|
||||
}));
|
||||
}
|
||||
|
||||
export function collectDefaultPermissionIds(nodes: PermissionTreeItem[]): number[] {
|
||||
return nodes.flatMap((node) => [
|
||||
...(node.isGrantedByDefault ? [node.id] : []),
|
||||
...collectDefaultPermissionIds(node.children),
|
||||
]);
|
||||
}
|
||||
|
||||
export function collectPermissionIdsByKeys(nodes: PermissionTreeItem[], keys: string[]): number[] {
|
||||
const selectedKeys = new Set(keys);
|
||||
return nodes.flatMap((node) => [
|
||||
...(selectedKeys.has(node.key) ? [node.id] : []),
|
||||
...collectPermissionIdsByKeys(node.children, keys),
|
||||
]);
|
||||
}
|
||||
|
||||
export function PermissionTree({
|
||||
items,
|
||||
selectedIds,
|
||||
onChange,
|
||||
}: {
|
||||
items: PermissionTreeItem[];
|
||||
selectedIds: number[];
|
||||
onChange: (ids: number[]) => void;
|
||||
}) {
|
||||
return (
|
||||
<CheckboxTree
|
||||
items={items}
|
||||
selectedIds={selectedIds}
|
||||
onSelectedIdsChange={onChange}
|
||||
emptyMessage="No permissions available."
|
||||
renderMeta={(item) => (
|
||||
<Badge variant="outline" className="text-[10px] capitalize">
|
||||
{item.type}
|
||||
</Badge>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
161
src/components/ui/tree.tsx
Normal file
161
src/components/ui/tree.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface TreeItem {
|
||||
id: number;
|
||||
label: string;
|
||||
description?: string;
|
||||
children: TreeItem[];
|
||||
}
|
||||
|
||||
export function collectTreeItemIds<TItem extends TreeItem>(item: TItem): number[] {
|
||||
return [item.id, ...item.children.flatMap((child) => collectTreeItemIds(child as TItem))];
|
||||
}
|
||||
|
||||
function TreeCheckbox({
|
||||
checked,
|
||||
indeterminate,
|
||||
onChange,
|
||||
}: {
|
||||
checked: boolean;
|
||||
indeterminate: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
ref.current.indeterminate = indeterminate;
|
||||
}
|
||||
}, [indeterminate]);
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
className="size-4 rounded border-border accent-primary"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckboxTreeNode<TItem extends TreeItem>({
|
||||
item,
|
||||
selectedIds,
|
||||
onToggle,
|
||||
renderMeta,
|
||||
}: {
|
||||
item: TItem;
|
||||
selectedIds: Set<number>;
|
||||
onToggle: (item: TItem, checked: boolean) => void;
|
||||
renderMeta?: (item: TItem) => ReactNode;
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const itemIds = collectTreeItemIds(item);
|
||||
const selectedCount = itemIds.filter((id) => selectedIds.has(id)).length;
|
||||
const checked = selectedCount === itemIds.length;
|
||||
const indeterminate = selectedCount > 0 && !checked;
|
||||
const hasChildren = item.children.length > 0;
|
||||
|
||||
return (
|
||||
<li className="space-y-2">
|
||||
<div className="flex items-start gap-2 rounded-md border border-border/60 bg-background px-3 py-2">
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded((current) => !current)}
|
||||
aria-label={isExpanded ? `Collapse ${item.label}` : `Expand ${item.label}`}
|
||||
className="mt-0.5 inline-flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`size-4 transition-transform ${isExpanded ? '' : '-rotate-90'}`}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span className="size-5 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
<TreeCheckbox
|
||||
checked={checked}
|
||||
indeterminate={indeterminate}
|
||||
onChange={(isChecked) => onToggle(item, isChecked)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 leading-none">
|
||||
<div className="flex min-h-5 flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium leading-5">{item.label}</span>
|
||||
{renderMeta?.(item)}
|
||||
</div>
|
||||
{item.description ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{item.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{hasChildren && isExpanded ? (
|
||||
<ul className="ml-5 space-y-2 border-l border-border/70 pl-3">
|
||||
{item.children.map((child) => (
|
||||
<CheckboxTreeNode
|
||||
key={child.id}
|
||||
item={child as TItem}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={onToggle}
|
||||
renderMeta={renderMeta}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function CheckboxTree<TItem extends TreeItem>({
|
||||
items,
|
||||
selectedIds,
|
||||
onSelectedIdsChange,
|
||||
renderMeta,
|
||||
emptyMessage = 'No items available.',
|
||||
}: {
|
||||
items: TItem[];
|
||||
selectedIds: number[];
|
||||
onSelectedIdsChange: (ids: number[]) => void;
|
||||
renderMeta?: (item: TItem) => ReactNode;
|
||||
emptyMessage?: string;
|
||||
}) {
|
||||
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
||||
|
||||
const handleToggle = (item: TItem, checked: boolean) => {
|
||||
const next = new Set(selectedSet);
|
||||
collectTreeItemIds(item).forEach((id) => {
|
||||
if (checked) {
|
||||
next.add(id);
|
||||
} else {
|
||||
next.delete(id);
|
||||
}
|
||||
});
|
||||
onSelectedIdsChange(Array.from(next));
|
||||
};
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<CheckboxTreeNode
|
||||
key={item.id}
|
||||
item={item}
|
||||
selectedIds={selectedSet}
|
||||
onToggle={handleToggle}
|
||||
renderMeta={renderMeta}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
30
src/config/app.routes.ts
Normal file
30
src/config/app.routes.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
|
||||
export type AppRoute = {
|
||||
path: string;
|
||||
permission?: string;
|
||||
};
|
||||
|
||||
export const appRoutes: AppRoute[] = [
|
||||
{
|
||||
path: ROUTES.ROLES,
|
||||
permission: PERMISSIONS.ROLE.READ,
|
||||
},
|
||||
{
|
||||
path: ROUTES.USERS,
|
||||
permission: PERMISSIONS.USER.READ,
|
||||
},
|
||||
{
|
||||
path: ROUTES.CLIENTS,
|
||||
permission: PERMISSIONS.CLIENT.READ,
|
||||
},
|
||||
{
|
||||
path: ROUTES.PLANS,
|
||||
permission: PERMISSIONS.PLAN.READ,
|
||||
},
|
||||
];
|
||||
|
||||
export function getRoutePermission(pathname: string) {
|
||||
return appRoutes.find((route) => route.path === pathname)?.permission;
|
||||
}
|
||||
109
src/config/menu.config.ts
Normal file
109
src/config/menu.config.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Layers,
|
||||
Milestone,
|
||||
Package,
|
||||
Plus,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
Building2,
|
||||
BadgeIndianRupee,
|
||||
Globe,
|
||||
} from 'lucide-react';
|
||||
import type { ComponentType, SVGProps } from 'react';
|
||||
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
|
||||
export type MenuItem = {
|
||||
title: string;
|
||||
path?: string;
|
||||
icon: ComponentType<SVGProps<SVGSVGElement>>;
|
||||
permission?: string;
|
||||
children?: MenuItem[];
|
||||
};
|
||||
|
||||
export const menuItems: MenuItem[] = [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
path: ROUTES.DASHBOARD,
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
title: 'New Analysis',
|
||||
path: ROUTES.UPLOAD,
|
||||
icon: Plus,
|
||||
},
|
||||
{
|
||||
title: 'Project',
|
||||
path: ROUTES.PROJECT,
|
||||
icon: Layers,
|
||||
},
|
||||
{
|
||||
title: 'Package',
|
||||
path: ROUTES.PACKAGE,
|
||||
icon: Package,
|
||||
},
|
||||
{
|
||||
title: 'Segment',
|
||||
path: ROUTES.CHAINAGE,
|
||||
icon: Milestone,
|
||||
},
|
||||
{
|
||||
title: 'Administration',
|
||||
icon: Settings,
|
||||
children: [
|
||||
{
|
||||
title: 'Roles',
|
||||
path: ROUTES.ROLES,
|
||||
icon: ShieldCheck,
|
||||
permission: PERMISSIONS.ROLE.READ,
|
||||
},
|
||||
{
|
||||
title: 'Users',
|
||||
path: ROUTES.USERS,
|
||||
icon: Users,
|
||||
permission: PERMISSIONS.USER.READ,
|
||||
},
|
||||
{
|
||||
title: 'Clients',
|
||||
path: ROUTES.CLIENTS,
|
||||
icon: Building2,
|
||||
permission: PERMISSIONS.CLIENT.READ,
|
||||
},
|
||||
{
|
||||
title: 'Plans',
|
||||
path: ROUTES.PLANS,
|
||||
icon: BadgeIndianRupee,
|
||||
permission: PERMISSIONS.PLAN.READ,
|
||||
},
|
||||
{
|
||||
title: 'Tenants',
|
||||
path: ROUTES.TENANTS,
|
||||
icon: Globe,
|
||||
permission: PERMISSIONS.TENANT.READ,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function filterMenuItems(
|
||||
items: MenuItem[],
|
||||
hasPermission: (permission?: string) => boolean,
|
||||
): MenuItem[] {
|
||||
return items
|
||||
.map((item): MenuItem | null => {
|
||||
if (!hasPermission(item.permission)) return null;
|
||||
|
||||
const children = item.children ? filterMenuItems(item.children, hasPermission) : undefined;
|
||||
|
||||
if (item.children && children?.length === 0 && !item.path) return null;
|
||||
|
||||
return {
|
||||
...item,
|
||||
children,
|
||||
};
|
||||
})
|
||||
.filter((item): item is MenuItem => item !== null);
|
||||
}
|
||||
35
src/constants/permissions.ts
Normal file
35
src/constants/permissions.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export const PERMISSIONS = {
|
||||
ROLE: {
|
||||
CREATE: 'administration.roles.create',
|
||||
READ: 'administration.roles.read',
|
||||
UPDATE: 'administration.roles.update',
|
||||
DELETE: 'administration.roles.delete',
|
||||
},
|
||||
USER: {
|
||||
CREATE: 'administration.users.create',
|
||||
READ: 'administration.users.read',
|
||||
UPDATE: 'administration.users.update',
|
||||
DELETE: 'administration.users.delete',
|
||||
},
|
||||
CLIENT: {
|
||||
CREATE: 'administration.client.create',
|
||||
READ: 'administration.client.read',
|
||||
UPDATE: 'administration.client.update',
|
||||
DELETE: 'administration.client.delete',
|
||||
},
|
||||
PLAN: {
|
||||
CREATE: 'administration.subscriptions.create',
|
||||
READ: 'administration.subscriptions.read',
|
||||
UPDATE: 'administration.subscriptions.update',
|
||||
DELETE: 'administration.subscriptions.delete',
|
||||
},
|
||||
TENANT: {
|
||||
CREATE: 'administration.tenants.create',
|
||||
READ: 'administration.tenants.read',
|
||||
UPDATE: 'administration.tenants.update',
|
||||
DELETE: 'administration.tenants.delete',
|
||||
},
|
||||
LOG: {
|
||||
VIEW: 'log.view',
|
||||
},
|
||||
} as const;
|
||||
46
src/guards/AuthGuard.tsx
Normal file
46
src/guards/AuthGuard.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { AppLoader } from '@/components/AppLoader';
|
||||
import { useAppStore } from '@/store/app.store';
|
||||
import { useAuthStore } from '@/store/auth.store';
|
||||
import { getRoutePermission } from '@/config/app.routes';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export function AuthGuard({ children }: { children: ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const accessToken = useAuthStore((state) => state.accessToken);
|
||||
const isInitialized = useAppStore((state) => state.isInitialized);
|
||||
const user = useAppStore((state) => state.user);
|
||||
const { hasPermission } = usePermissions();
|
||||
const requiredPermission = getRoutePermission(pathname);
|
||||
const canAccessRoute = hasPermission(requiredPermission);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitialized) return;
|
||||
|
||||
if (!accessToken) {
|
||||
router.replace(ROUTES.LOGIN);
|
||||
}
|
||||
}, [accessToken, isInitialized, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitialized || !accessToken || !user || canAccessRoute) return;
|
||||
|
||||
router.replace(ROUTES.ACCESS);
|
||||
}, [accessToken, canAccessRoute, isInitialized, router, user]);
|
||||
|
||||
if (!isInitialized || (accessToken && !user)) {
|
||||
return <AppLoader label="Preparing workspace" />;
|
||||
}
|
||||
|
||||
if (!accessToken || !user || !canAccessRoute) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
30
src/guards/GuestGuard.tsx
Normal file
30
src/guards/GuestGuard.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { AppLoader } from '@/components/AppLoader';
|
||||
import { useAppStore } from '@/store/app.store';
|
||||
import { useAuthStore } from '@/store/auth.store';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export function GuestGuard({ children }: { children: ReactNode }) {
|
||||
const router = useRouter();
|
||||
const accessToken = useAuthStore((state) => state.accessToken);
|
||||
const isInitialized = useAppStore((state) => state.isInitialized);
|
||||
const user = useAppStore((state) => state.user);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialized && accessToken && user) {
|
||||
router.replace(ROUTES.DASHBOARD);
|
||||
}
|
||||
}, [accessToken, isInitialized, router, user]);
|
||||
|
||||
if (!isInitialized) {
|
||||
return <AppLoader label={accessToken ? 'Preparing workspace' : 'Loading'} />;
|
||||
}
|
||||
|
||||
if (isInitialized && accessToken && user) return <AppLoader label="Opening dashboard" />;
|
||||
|
||||
return children;
|
||||
}
|
||||
18
src/guards/PermissionGuard.tsx
Normal file
18
src/guards/PermissionGuard.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { usePermissions, type PermissionInput } from '@/hooks/usePermissions';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type PermissionGuardProps = {
|
||||
children: ReactNode;
|
||||
permissions?: PermissionInput;
|
||||
fallback?: ReactNode;
|
||||
};
|
||||
|
||||
export function PermissionGuard({ children, permissions, fallback = null }: PermissionGuardProps) {
|
||||
const { hasPermission } = usePermissions();
|
||||
|
||||
if (!hasPermission(permissions)) return fallback;
|
||||
|
||||
return children;
|
||||
}
|
||||
3
src/guards/index.ts
Normal file
3
src/guards/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './AuthGuard';
|
||||
export * from './GuestGuard';
|
||||
export * from './PermissionGuard';
|
||||
17
src/hooks/useDebounce.ts
Normal file
17
src/hooks/useDebounce.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useDebounce<TValue>(value: TValue, delayMs = 400): TValue {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delayMs);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [delayMs, value]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
57
src/hooks/useLoginForm.ts
Normal file
57
src/hooks/useLoginForm.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { authService } from '@/services/api/auth.service';
|
||||
import { initializeAuthenticatedApp } from '@/services/initializer.service';
|
||||
import { useAppStore } from '@/store/app.store';
|
||||
import { useAuthStore } from '@/store/auth.store';
|
||||
import type { LoginPayload } from '@/types';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const useLoginForm = () => {
|
||||
const router = useRouter();
|
||||
const setAccessToken = useAuthStore((state) => state.setAccessToken);
|
||||
const setLoading = useAppStore((state) => state.setLoading);
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<LoginPayload>({
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
rememberMe: false,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: LoginPayload) => {
|
||||
try {
|
||||
const loginResponse = await authService.login({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
});
|
||||
const accessToken = loginResponse.access_token;
|
||||
|
||||
if (!accessToken) {
|
||||
toast.error('Login failed: no access token returned');
|
||||
return;
|
||||
}
|
||||
|
||||
setAccessToken(accessToken);
|
||||
setLoading();
|
||||
await initializeAuthenticatedApp();
|
||||
|
||||
toast.success('Login successful');
|
||||
router.replace(ROUTES.DASHBOARD);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Unable to sign in');
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
register,
|
||||
handleSubmit: handleSubmit(onSubmit),
|
||||
errors,
|
||||
isLoading: isSubmitting,
|
||||
};
|
||||
};
|
||||
39
src/hooks/usePermissions.ts
Normal file
39
src/hooks/usePermissions.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useAppStore } from '@/store/app.store';
|
||||
|
||||
export type PermissionInput = string | undefined;
|
||||
|
||||
export function hasPermissionValue({
|
||||
grantedPermissions,
|
||||
permissions,
|
||||
}: {
|
||||
grantedPermissions: string[];
|
||||
permissions: PermissionInput;
|
||||
}) {
|
||||
if (!permissions) return true;
|
||||
|
||||
const normalizedPermissions = new Set(
|
||||
grantedPermissions.map((permission) => permission.toLowerCase()),
|
||||
);
|
||||
|
||||
return normalizedPermissions.has(permissions.toLowerCase());
|
||||
}
|
||||
|
||||
export function usePermissions() {
|
||||
const grantedPermissions = useAppStore((state) => state.permissions);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
grantedPermissions,
|
||||
hasPermission: (permissions: PermissionInput) =>
|
||||
hasPermissionValue({
|
||||
grantedPermissions,
|
||||
permissions,
|
||||
}),
|
||||
}),
|
||||
[grantedPermissions],
|
||||
);
|
||||
}
|
||||
48
src/providers/AppInitializer.tsx
Normal file
48
src/providers/AppInitializer.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { initializeAuthenticatedApp } from '@/services/initializer.service';
|
||||
import { useAppStore } from '@/store/app.store';
|
||||
import { useAuthStore } from '@/store/auth.store';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function AppInitializer({ children }: { children: ReactNode }) {
|
||||
const accessToken = useAuthStore((state) => state.accessToken);
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
const user = useAppStore((state) => state.user);
|
||||
const setLoading = useAppStore((state) => state.setLoading);
|
||||
const setLoaded = useAppStore((state) => state.setLoaded);
|
||||
const setLoadError = useAppStore((state) => state.setLoadError);
|
||||
const clearApp = useAppStore((state) => state.clear);
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
|
||||
const initialize = async () => {
|
||||
if (!accessToken || user) {
|
||||
setLoaded();
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading();
|
||||
|
||||
try {
|
||||
await initializeAuthenticatedApp();
|
||||
} catch {
|
||||
if (isActive) {
|
||||
logout();
|
||||
clearApp();
|
||||
setLoadError();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initialize();
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, [accessToken, clearApp, logout, setLoaded, setLoadError, setLoading, user]);
|
||||
|
||||
return children;
|
||||
}
|
||||
26
src/providers/QueryProvider.tsx
Normal file
26
src/providers/QueryProvider.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function QueryProvider({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
28
src/services/api/auth.service.ts
Normal file
28
src/services/api/auth.service.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { AuthResponseData, LoginPayload, MeResponse, PermissionResponse } from '@/types';
|
||||
import axiosClient, { axiosAuth } from '../axios/axios';
|
||||
|
||||
export const authService = {
|
||||
login: async (payload: LoginPayload): Promise<AuthResponseData> => {
|
||||
const response = await axiosAuth.post<AuthResponseData>('api/auth/login', payload, {
|
||||
withCredentials: true,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
refresh: async (): Promise<AuthResponseData> => {
|
||||
const response = await axiosAuth.post<AuthResponseData>('api/auth/refresh', {}, {
|
||||
withCredentials: true,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
logout: async (): Promise<void> => {
|
||||
await axiosAuth.post('api/auth/logout', {}, { withCredentials: true });
|
||||
},
|
||||
me: async (): Promise<MeResponse> => {
|
||||
const response = await axiosClient.get<MeResponse>('api/auth/me');
|
||||
return response.data;
|
||||
},
|
||||
permissions: async (): Promise<PermissionResponse> => {
|
||||
const response = await axiosClient.get<PermissionResponse>('api/permissions/my-permissions');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
PaginationParams,
|
||||
} from '@/types';
|
||||
|
||||
const CHAINAGES_ENDPOINT = 'biz/api/v1/chainages';
|
||||
|
||||
/**
|
||||
* Chainage Service
|
||||
*/
|
||||
@@ -17,7 +19,7 @@ export const chainageService = {
|
||||
getChainages: async (params?: PaginationParams): Promise<PaginatedResponse<Chainage>> => {
|
||||
const skip = params?.skip ?? 0;
|
||||
const limit = params?.limit ?? 100;
|
||||
const response = await axiosClient.get<PaginatedResponse<Chainage>>(`/chainages/`, {
|
||||
const response = await axiosClient.get<PaginatedResponse<Chainage>>(CHAINAGES_ENDPOINT, {
|
||||
params: { skip, limit },
|
||||
});
|
||||
return response.data;
|
||||
@@ -32,7 +34,7 @@ export const chainageService = {
|
||||
): Promise<PaginatedResponse<Chainage>> => {
|
||||
const skip = params?.skip ?? 0;
|
||||
const limit = params?.limit ?? 100;
|
||||
const response = await axiosClient.get<PaginatedResponse<Chainage>>(`/chainages/`, {
|
||||
const response = await axiosClient.get<PaginatedResponse<Chainage>>(CHAINAGES_ENDPOINT, {
|
||||
params: { package_id: packageId, skip, limit },
|
||||
});
|
||||
return response.data;
|
||||
@@ -42,7 +44,7 @@ export const chainageService = {
|
||||
* Create a new chainage
|
||||
*/
|
||||
createChainage: async (data: ChainageCreate): Promise<Chainage> => {
|
||||
const response = await axiosClient.post<Chainage>('/chainages/', data);
|
||||
const response = await axiosClient.post<Chainage>(CHAINAGES_ENDPOINT, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -50,7 +52,7 @@ export const chainageService = {
|
||||
* Update an existing chainage
|
||||
*/
|
||||
updateChainage: async (chainageId: string, data: ChainageUpdate): Promise<Chainage> => {
|
||||
const response = await axiosClient.put<Chainage>(`/chainages/${chainageId}`, data);
|
||||
const response = await axiosClient.put<Chainage>(`${CHAINAGES_ENDPOINT}/${chainageId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -58,7 +60,9 @@ export const chainageService = {
|
||||
* Delete a chainage
|
||||
*/
|
||||
deleteChainage: async (chainageId: string): Promise<{ message: string }> => {
|
||||
const response = await axiosClient.delete<{ message: string }>(`/chainages/${chainageId}`);
|
||||
const response = await axiosClient.delete<{ message: string }>(
|
||||
`${CHAINAGES_ENDPOINT}/${chainageId}`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
59
src/services/api/client.service.ts
Normal file
59
src/services/api/client.service.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import axiosClient from '../axios/axios';
|
||||
import type { Client, ClientListParams, ClientListResponse, ClientRequest } from '@/types';
|
||||
|
||||
const CLIENTS_ENDPOINT = 'api/clients';
|
||||
|
||||
function toClientPayload(payload: ClientRequest) {
|
||||
return {
|
||||
name: payload.name,
|
||||
email: payload.email,
|
||||
landline_number: payload.landline_number,
|
||||
address: payload.address,
|
||||
gst: payload.gst ?? null,
|
||||
pan: payload.pan ?? null,
|
||||
tan: payload.tan ?? null,
|
||||
contact_name: payload.contact_name,
|
||||
contact_phone_number: payload.contact_phone_number,
|
||||
contact_email: payload.contact_email,
|
||||
is_active: payload.is_active ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
export const clientService = {
|
||||
getClients: async (params?: ClientListParams): Promise<ClientListResponse> => {
|
||||
const response = await axiosClient.get<ClientListResponse>(CLIENTS_ENDPOINT, {
|
||||
params: {
|
||||
skip: params?.skip ?? 0,
|
||||
limit: params?.limit ?? 10,
|
||||
search_term: params?.search_term,
|
||||
name: params?.name,
|
||||
email: params?.email,
|
||||
contact_name: params?.contact_name,
|
||||
is_active: params?.is_active,
|
||||
sort_by: params?.sort_by,
|
||||
sort_order: params?.sort_order,
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getClientById: async (id: number): Promise<Client> => {
|
||||
const response = await axiosClient.get<Client>(`${CLIENTS_ENDPOINT}/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
saveClient: async (payload: ClientRequest): Promise<Client> => {
|
||||
const requestPayload = toClientPayload(payload);
|
||||
const response = payload.id
|
||||
? await axiosClient.put<Client>(`${CLIENTS_ENDPOINT}/${payload.id}`, requestPayload)
|
||||
: await axiosClient.post<Client>(CLIENTS_ENDPOINT, requestPayload);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateClientStatus: async (id: number, isActive: boolean): Promise<Client> => {
|
||||
const response = await axiosClient.patch<Client>(`${CLIENTS_ENDPOINT}/${id}/status`, {
|
||||
is_active: isActive,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -31,7 +31,9 @@ export const detectionService = {
|
||||
};
|
||||
};
|
||||
};
|
||||
}>(`/summary/projects/${project.id}`);
|
||||
}>('biz/api/v1/dashboard/overview', {
|
||||
params: { project_id: project.id },
|
||||
});
|
||||
|
||||
const summary = response.data;
|
||||
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
export * from './project.service';
|
||||
export * from './package.service';
|
||||
export * from './auth.service';
|
||||
export { chainageService } from './chainage.service';
|
||||
export * from './video.service';
|
||||
export * from './detection.service';
|
||||
export * from './session.service';
|
||||
export { projectDataService } from './project.service';
|
||||
export * from './permission.service';
|
||||
export * from './role.service';
|
||||
export * from './user.service';
|
||||
export * from './client.service';
|
||||
export * from './plan.service';
|
||||
export * from './tenant.service';
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
PaginationParams,
|
||||
} from '@/types';
|
||||
|
||||
const PACKAGES_ENDPOINT = 'biz/api/v1/packages';
|
||||
|
||||
/**
|
||||
* Package Service
|
||||
*/
|
||||
@@ -17,7 +19,7 @@ export const packageService = {
|
||||
getPackages: async (params?: PaginationParams): Promise<PaginatedResponse<Package>> => {
|
||||
const skip = params?.skip ?? 0;
|
||||
const limit = params?.limit ?? 100;
|
||||
const response = await axiosClient.get<PaginatedResponse<Package>>(`/packages/`, {
|
||||
const response = await axiosClient.get<PaginatedResponse<Package>>(PACKAGES_ENDPOINT, {
|
||||
params: { skip, limit },
|
||||
});
|
||||
return response.data;
|
||||
@@ -32,7 +34,7 @@ export const packageService = {
|
||||
): Promise<PaginatedResponse<Package>> => {
|
||||
const skip = params?.skip ?? 0;
|
||||
const limit = params?.limit ?? 100;
|
||||
const response = await axiosClient.get<PaginatedResponse<Package>>(`/packages/`, {
|
||||
const response = await axiosClient.get<PaginatedResponse<Package>>(PACKAGES_ENDPOINT, {
|
||||
params: { project_id: projectId, skip, limit },
|
||||
});
|
||||
return response.data;
|
||||
@@ -42,7 +44,7 @@ export const packageService = {
|
||||
* Create a new package
|
||||
*/
|
||||
createPackage: async (data: PackageCreate): Promise<Package> => {
|
||||
const response = await axiosClient.post<Package>('/packages/', data);
|
||||
const response = await axiosClient.post<Package>(PACKAGES_ENDPOINT, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -50,7 +52,7 @@ export const packageService = {
|
||||
* Update an existing package
|
||||
*/
|
||||
updatePackage: async (packageId: string, data: PackageUpdate): Promise<Package> => {
|
||||
const response = await axiosClient.put<Package>(`/packages/${packageId}`, data);
|
||||
const response = await axiosClient.put<Package>(`${PACKAGES_ENDPOINT}/${packageId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -58,7 +60,9 @@ export const packageService = {
|
||||
* Delete a package
|
||||
*/
|
||||
deletePackage: async (packageId: string): Promise<{ message: string }> => {
|
||||
const response = await axiosClient.delete<{ message: string }>(`/packages/${packageId}`);
|
||||
const response = await axiosClient.delete<{ message: string }>(
|
||||
`${PACKAGES_ENDPOINT}/${packageId}`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
16
src/services/api/permission.service.ts
Normal file
16
src/services/api/permission.service.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import axiosClient from '../axios/axios';
|
||||
import type { PermissionResponse, PermissionTreeNode } from '@/types';
|
||||
|
||||
export const permissionService = {
|
||||
getPermissions: async (): Promise<PermissionResponse> => {
|
||||
const response = await axiosClient.get<PermissionResponse>('api/permissions/my-permissions');
|
||||
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;
|
||||
},
|
||||
};
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
PaginationParams,
|
||||
} from '@/types';
|
||||
|
||||
const PROJECTS_ENDPOINT = 'biz/api/v1/projects';
|
||||
const PROJECT_SUMMARY_ENDPOINT = 'biz/api/v1/dashboard/overview';
|
||||
|
||||
/**
|
||||
* Project Service
|
||||
*/
|
||||
@@ -17,7 +20,7 @@ export const projectService = {
|
||||
getProjects: async (params?: PaginationParams): Promise<PaginatedResponse<Project>> => {
|
||||
const skip = params?.skip ?? 0;
|
||||
const limit = params?.limit ?? 100;
|
||||
const response = await axiosClient.get<PaginatedResponse<Project>>(`/projects/`, {
|
||||
const response = await axiosClient.get<PaginatedResponse<Project>>(PROJECTS_ENDPOINT, {
|
||||
params: { skip, limit },
|
||||
});
|
||||
return response.data;
|
||||
@@ -27,7 +30,7 @@ export const projectService = {
|
||||
* Create a new project
|
||||
*/
|
||||
createProject: async (data: ProjectCreate): Promise<Project> => {
|
||||
const response = await axiosClient.post<Project>('/projects/', data);
|
||||
const response = await axiosClient.post<Project>(PROJECTS_ENDPOINT, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -35,7 +38,7 @@ export const projectService = {
|
||||
* Update an existing project
|
||||
*/
|
||||
updateProject: async (projectId: string, data: ProjectUpdate): Promise<Project> => {
|
||||
const response = await axiosClient.put<Project>(`/projects/${projectId}`, data);
|
||||
const response = await axiosClient.put<Project>(`${PROJECTS_ENDPOINT}/${projectId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -43,7 +46,9 @@ export const projectService = {
|
||||
* Delete a project
|
||||
*/
|
||||
deleteProject: async (projectId: string): Promise<{ message: string }> => {
|
||||
const response = await axiosClient.delete<{ message: string }>(`/projects/${projectId}`);
|
||||
const response = await axiosClient.delete<{ message: string }>(
|
||||
`${PROJECTS_ENDPOINT}/${projectId}`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -51,7 +56,9 @@ export const projectService = {
|
||||
* Fetch project summary (detections across packages and chainages)
|
||||
*/
|
||||
getProjectSummary: async (projectId: string): Promise<any> => {
|
||||
const response = await axiosClient.get(`/summary/projects/${projectId}`);
|
||||
const response = await axiosClient.get(PROJECT_SUMMARY_ENDPOINT, {
|
||||
params: { project_id: projectId },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -59,8 +66,8 @@ export const projectService = {
|
||||
* Fetch project summary filtered by video ID
|
||||
*/
|
||||
getProjectSummaryByVideo: async (projectId: string, videoId: string): Promise<any> => {
|
||||
const response = await axiosClient.get(`/summary/projects/${projectId}`, {
|
||||
params: { video_id: videoId },
|
||||
const response = await axiosClient.get(PROJECT_SUMMARY_ENDPOINT, {
|
||||
params: { project_id: projectId, video_id: videoId },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
42
src/services/api/role.service.ts
Normal file
42
src/services/api/role.service.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import axiosClient from '../axios/axios';
|
||||
import type { Role, RoleListParams, RoleListResponse, RoleRequest } from '@/types';
|
||||
|
||||
const ROLES_ENDPOINT = 'api/roles';
|
||||
|
||||
export const roleService = {
|
||||
getRoles: async (params?: RoleListParams): Promise<RoleListResponse> => {
|
||||
const response = await axiosClient.get<RoleListResponse>(ROLES_ENDPOINT, {
|
||||
params: {
|
||||
skip: params?.skip ?? 0,
|
||||
limit: params?.limit ?? 10,
|
||||
search_term: params?.search_term,
|
||||
name: params?.name,
|
||||
display_name: params?.display_name,
|
||||
description: params?.description,
|
||||
effective_status: params?.effective_status,
|
||||
sort_by: params?.sort_by,
|
||||
sort_order: params?.sort_order,
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getRoleById: async (id: number): Promise<Role> => {
|
||||
const response = await axiosClient.get<Role>(`${ROLES_ENDPOINT}/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
saveRole: async (payload: RoleRequest): Promise<Role> => {
|
||||
const response = payload.id
|
||||
? await axiosClient.put<Role>(ROLES_ENDPOINT, payload)
|
||||
: await axiosClient.post<Role>(ROLES_ENDPOINT, payload);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateRoleStatus: async (id: number, isActive: boolean): Promise<Role> => {
|
||||
const response = await axiosClient.patch<Role>(`${ROLES_ENDPOINT}/${id}/status`, {
|
||||
is_active: isActive,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
71
src/services/api/tenant.service.ts
Normal file
71
src/services/api/tenant.service.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import axiosClient from '../axios/axios';
|
||||
import type {
|
||||
Tenant,
|
||||
TenantCreateRequest,
|
||||
TenantListParams,
|
||||
TenantListResponse,
|
||||
TenantUpdateRequest,
|
||||
} from '@/types';
|
||||
|
||||
const TENANTS_ENDPOINT = 'api/superadmin/tenants';
|
||||
|
||||
function toCreatePayload(payload: TenantCreateRequest) {
|
||||
return {
|
||||
name: payload.name,
|
||||
slug: payload.slug,
|
||||
client_id: payload.client_id,
|
||||
plan_id: payload.plan_id,
|
||||
domain: payload.domain || undefined,
|
||||
description: payload.description || undefined,
|
||||
admin: {
|
||||
first_name: payload.admin.first_name,
|
||||
last_name: payload.admin.last_name,
|
||||
email: payload.admin.email,
|
||||
phone_number: payload.admin.phone_number || undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toUpdatePayload(payload: TenantUpdateRequest) {
|
||||
return {
|
||||
name: payload.name,
|
||||
slug: payload.slug,
|
||||
client_id: payload.client_id,
|
||||
plan_id: payload.plan_id,
|
||||
domain: payload.domain || undefined,
|
||||
description: payload.description || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export const tenantService = {
|
||||
getTenants: async (params?: TenantListParams): Promise<TenantListResponse> => {
|
||||
const response = await axiosClient.get<TenantListResponse>(TENANTS_ENDPOINT, {
|
||||
params: {
|
||||
skip: params?.skip ?? 0,
|
||||
limit: params?.limit ?? 10,
|
||||
search_term: params?.search_term,
|
||||
is_active: params?.is_active,
|
||||
sort_by: params?.sort_by,
|
||||
sort_order: params?.sort_order,
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createTenant: async (payload: TenantCreateRequest): Promise<Tenant> => {
|
||||
const response = await axiosClient.post<Tenant>(TENANTS_ENDPOINT, toCreatePayload(payload));
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateTenant: async (payload: TenantUpdateRequest): Promise<Tenant> => {
|
||||
const response = await axiosClient.put<Tenant>(
|
||||
`${TENANTS_ENDPOINT}/${payload.id}`,
|
||||
toUpdatePayload(payload),
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteTenant: async (id: number): Promise<void> => {
|
||||
await axiosClient.delete(`${TENANTS_ENDPOINT}/${id}`);
|
||||
},
|
||||
};
|
||||
41
src/services/api/user.service.ts
Normal file
41
src/services/api/user.service.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import axiosClient from '../axios/axios';
|
||||
import type { AdministrationUser, UserListParams, UserListResponse, UserRequest } from '@/types';
|
||||
|
||||
const USERS_ENDPOINT = 'api/users';
|
||||
|
||||
export const userService = {
|
||||
getUsers: async (params?: UserListParams): Promise<UserListResponse> => {
|
||||
const response = await axiosClient.get<UserListResponse>(USERS_ENDPOINT, {
|
||||
params: {
|
||||
skip: params?.skip ?? 0,
|
||||
limit: params?.limit ?? 10,
|
||||
search_term: params?.search_term,
|
||||
userName: params?.userName,
|
||||
first_name: params?.first_name,
|
||||
phone_number: params?.phone_number,
|
||||
roles: params?.roles,
|
||||
effective_status: params?.effective_status,
|
||||
sort_by: params?.sort_by,
|
||||
sort_order: params?.sort_order,
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
saveUser: async (payload: UserRequest): Promise<AdministrationUser> => {
|
||||
const response = payload.id
|
||||
? await axiosClient.put<AdministrationUser>(USERS_ENDPOINT, payload)
|
||||
: await axiosClient.post<AdministrationUser>(USERS_ENDPOINT, payload);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateUserStatus: async (
|
||||
id: number,
|
||||
status: 'active' | 'inactive',
|
||||
): Promise<AdministrationUser> => {
|
||||
const response = await axiosClient.patch<AdministrationUser>(`${USERS_ENDPOINT}/${id}/status`, {
|
||||
status,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,6 @@
|
||||
import { ENV_CONSTANT } from '@/constants/secrect.constant';
|
||||
import { useAppStore } from '@/store/app.store';
|
||||
import { useAuthStore } from '@/store/auth.store';
|
||||
import axios from 'axios';
|
||||
|
||||
const BASE_URL = ENV_CONSTANT.BASE_API_URL;
|
||||
@@ -6,20 +8,19 @@ const BASE_URL = ENV_CONSTANT.BASE_API_URL;
|
||||
const axiosClient = axios.create({
|
||||
baseURL: BASE_URL,
|
||||
headers: {
|
||||
// "Content-Type": "application/json",
|
||||
'Content-Type': 'application/json',
|
||||
'ngrok-skip-browser-warning': 'true',
|
||||
},
|
||||
});
|
||||
|
||||
// Add request interceptor to inject access token
|
||||
axiosClient.interceptors.request.use(
|
||||
(config) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token && config.headers) {
|
||||
config.headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
@@ -27,18 +28,58 @@ axiosClient.interceptors.request.use(
|
||||
},
|
||||
);
|
||||
|
||||
// Basic response interceptor
|
||||
axiosClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
// Standard error handling can be added here later
|
||||
async (error) => {
|
||||
const originalRequest = error?.config;
|
||||
const status = error?.response?.status;
|
||||
const requestUrl = originalRequest?.url ?? '';
|
||||
const isAuthRoute =
|
||||
requestUrl.includes('api/auth/login') || requestUrl.includes('api/auth/refresh');
|
||||
|
||||
if (status === 401 && originalRequest && !originalRequest._retry && !isAuthRoute) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
const response = await axiosAuth.post(
|
||||
'api/auth/refresh',
|
||||
{},
|
||||
{
|
||||
withCredentials: true,
|
||||
},
|
||||
);
|
||||
const accessToken = response.data?.access_token;
|
||||
|
||||
if (!accessToken) {
|
||||
throw new Error('Refresh failed: no access token');
|
||||
}
|
||||
|
||||
useAuthStore.getState().setAccessToken(accessToken);
|
||||
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
|
||||
|
||||
return axiosClient(originalRequest);
|
||||
} catch (refreshError) {
|
||||
useAuthStore.getState().logout();
|
||||
useAppStore.getState().clear();
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export const axiosAuth = axios.create({
|
||||
baseURL: BASE_URL,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'ngrok-skip-browser-warning': 'true',
|
||||
},
|
||||
});
|
||||
|
||||
export default axiosClient;
|
||||
|
||||
16
src/services/initializer.service.ts
Normal file
16
src/services/initializer.service.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { authService } from '@/services/api/auth.service';
|
||||
import { useAppStore } from '@/store/app.store';
|
||||
import type { UserProfile } from '@/types';
|
||||
|
||||
export async function initializeAuthenticatedApp() {
|
||||
const [{ user, tenant }, permissionsResponse] = await Promise.all([
|
||||
authService.me(),
|
||||
authService.permissions(),
|
||||
]);
|
||||
|
||||
useAppStore.getState().setUserContext({
|
||||
user: user as UserProfile,
|
||||
tenant,
|
||||
permissions: permissionsResponse.granted_permissions ?? [],
|
||||
});
|
||||
}
|
||||
48
src/store/app.store.ts
Normal file
48
src/store/app.store.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { TenantInfo, UserProfile } from '@/types';
|
||||
import { create } from 'zustand';
|
||||
|
||||
export type AppLoadStatus = 'idle' | 'loading' | 'loaded' | 'error';
|
||||
|
||||
interface AppState {
|
||||
user: UserProfile | null;
|
||||
tenant: TenantInfo | null;
|
||||
permissions: string[];
|
||||
loadStatus: AppLoadStatus;
|
||||
isInitialized: boolean;
|
||||
setLoading: () => void;
|
||||
setLoaded: () => void;
|
||||
setLoadError: () => void;
|
||||
setUserContext: (payload: {
|
||||
user: UserProfile;
|
||||
tenant?: TenantInfo | null;
|
||||
permissions?: string[];
|
||||
}) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>()((set) => ({
|
||||
user: null,
|
||||
tenant: null,
|
||||
permissions: [],
|
||||
loadStatus: 'idle',
|
||||
isInitialized: false,
|
||||
setLoading: () => set({ loadStatus: 'loading', isInitialized: false }),
|
||||
setLoaded: () => set({ loadStatus: 'loaded', isInitialized: true }),
|
||||
setLoadError: () => set({ loadStatus: 'error', isInitialized: true }),
|
||||
setUserContext: ({ user, tenant = null, permissions = [] }) =>
|
||||
set({
|
||||
user,
|
||||
tenant,
|
||||
permissions,
|
||||
loadStatus: 'loaded',
|
||||
isInitialized: true,
|
||||
}),
|
||||
clear: () =>
|
||||
set({
|
||||
user: null,
|
||||
tenant: null,
|
||||
permissions: [],
|
||||
loadStatus: 'idle',
|
||||
isInitialized: false,
|
||||
}),
|
||||
}));
|
||||
40
src/store/auth.store.ts
Normal file
40
src/store/auth.store.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
const ACCESS_TOKEN_KEY = 'access_token';
|
||||
|
||||
const getStoredAccessToken = () => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
};
|
||||
|
||||
interface AuthState {
|
||||
accessToken: string | null;
|
||||
isAuthenticated: boolean;
|
||||
setAccessToken: (accessToken: string) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()((set) => ({
|
||||
accessToken: getStoredAccessToken(),
|
||||
isAuthenticated: !!getStoredAccessToken(),
|
||||
setAccessToken: (accessToken) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
|
||||
}
|
||||
|
||||
set({
|
||||
accessToken,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
},
|
||||
logout: () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
}
|
||||
|
||||
set({
|
||||
accessToken: null,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
},
|
||||
}));
|
||||
66
src/types/auth.type.ts
Normal file
66
src/types/auth.type.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { PermissionTreeNode } from './permission';
|
||||
|
||||
export type AuthUser = {
|
||||
id?: string | number;
|
||||
username?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
role?: string | null;
|
||||
roles?: Array<{
|
||||
name: string;
|
||||
display_name: string;
|
||||
is_active: boolean;
|
||||
}>;
|
||||
is_superadmin?: boolean;
|
||||
profile_photo_url?: string | null;
|
||||
organization_id?: number;
|
||||
organization_name?: string;
|
||||
};
|
||||
|
||||
export type UserProfile = {
|
||||
id?: string | number;
|
||||
username?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
image?: string | null;
|
||||
role?: string | null;
|
||||
roles?: AuthUser['roles'];
|
||||
is_superadmin?: boolean;
|
||||
profile_photo_url?: string | null;
|
||||
organization_id?: number;
|
||||
organization_name?: string;
|
||||
};
|
||||
|
||||
export type TenantInfo = {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
domain: string | null;
|
||||
type: string;
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
export type MeResponse = {
|
||||
user: AuthUser;
|
||||
tenant: TenantInfo;
|
||||
};
|
||||
|
||||
export type PermissionResponse = {
|
||||
granted_permissions?: string[];
|
||||
tree?: PermissionTreeNode[];
|
||||
};
|
||||
|
||||
export type LoginPayload = {
|
||||
email: string;
|
||||
password: string;
|
||||
rememberMe?: boolean;
|
||||
};
|
||||
|
||||
export type AuthResponseData = {
|
||||
access_token?: string;
|
||||
token_type?: string;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user