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
|
# Misc
|
||||||
.vercel
|
.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" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <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
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// 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-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@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",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -29,10 +31,12 @@
|
|||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
|
"react-hook-form": "^7.79.0",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
"sonner": "^1.7.4",
|
"sonner": "^1.7.4",
|
||||||
"tailwind-merge": "^3.3.1"
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.9",
|
"@tailwindcss/postcss": "^4.1.9",
|
||||||
@@ -41,6 +45,7 @@
|
|||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
"baseline-browser-mapping": "^2.10.37",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-config-next": "^16.1.7",
|
"eslint-config-next": "^16.1.7",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
@@ -3416,6 +3421,59 @@
|
|||||||
"tailwindcss": "4.2.1"
|
"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": {
|
"node_modules/@tanstack/react-table": {
|
||||||
"version": "8.21.3",
|
"version": "8.21.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
|
||||||
@@ -4531,9 +4589,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.8",
|
"version": "2.10.37",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz",
|
||||||
"integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==",
|
"integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -8523,6 +8581,22 @@
|
|||||||
"react": "^19.2.0"
|
"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": {
|
"node_modules/react-is": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||||
@@ -10111,6 +10185,35 @@
|
|||||||
"zod": "^3.25.0 || ^4.0.0"
|
"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": {
|
"node_modules/@next/swc-win32-x64-msvc": {
|
||||||
"version": "16.0.10",
|
"version": "16.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.10.tgz",
|
"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-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@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",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -41,10 +43,12 @@
|
|||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
|
"react-hook-form": "^7.79.0",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
"sonner": "^1.7.4",
|
"sonner": "^1.7.4",
|
||||||
"tailwind-merge": "^3.3.1"
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.9",
|
"@tailwindcss/postcss": "^4.1.9",
|
||||||
@@ -53,6 +57,7 @@
|
|||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
"baseline-browser-mapping": "^2.10.37",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-config-next": "^16.1.7",
|
"eslint-config-next": "^16.1.7",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"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 { ModeToggle } from '@/components/mode-toogle';
|
||||||
import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
|
import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { AuthGuard } from '@/guards';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
const ModulesLayout = ({
|
const ModulesLayout = ({
|
||||||
@@ -11,23 +12,25 @@ const ModulesLayout = ({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) => {
|
}>) => {
|
||||||
return (
|
return (
|
||||||
<SidebarProvider className="bg-transparent overflow-hidden">
|
<AuthGuard>
|
||||||
<AppSidebar className="bg-transparent" />
|
<SidebarProvider className="bg-transparent overflow-hidden">
|
||||||
<main className="flex flex-1 flex-col w-full h-screen overflow-hidden bg-transparent">
|
<AppSidebar className="bg-transparent" />
|
||||||
<div className="flex-1 overflow-auto">
|
<main className="flex flex-1 flex-col w-full h-screen overflow-hidden bg-transparent">
|
||||||
{/* Centering container wrapper */}
|
<div className="flex-1 overflow-auto">
|
||||||
<div className="max-w-380 mx-auto w-full flex flex-col min-h-full">
|
{/* Centering container wrapper */}
|
||||||
<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">
|
<div className="max-w-380 mx-auto w-full flex flex-col min-h-full">
|
||||||
<SidebarTrigger />
|
<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">
|
||||||
<ModeToggle />
|
<SidebarTrigger />
|
||||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
<ModeToggle />
|
||||||
<BreadcrumbBasic />
|
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||||
|
<BreadcrumbBasic />
|
||||||
|
</div>
|
||||||
|
<div className="p-6 flex-1">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 flex-1">{children}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
</main>
|
</SidebarProvider>
|
||||||
</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="flex-1 container mx-auto px-6 py-6 max-w-340 flex flex-col">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Processing Analysis"
|
title="Uploading Analysis"
|
||||||
description={`Real-time analysis progress for video ID: ${videoId}`}
|
description={`Real-time analysis progress for video ID: ${videoId}`}
|
||||||
icon={TrendingUp}
|
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]">
|
<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="flex flex-col items-center justify-center w-full max-w-2xl space-y-10">
|
||||||
<div className="text-center space-y-4">
|
<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">
|
<p className="text-sm font-mono text-muted-foreground tracking-widest">
|
||||||
ID: {videoId}
|
ID: {videoId}
|
||||||
</p>
|
</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 type { Metadata } from 'next';
|
||||||
import { Geist, Geist_Mono } from 'next/font/google';
|
import { Geist, Geist_Mono } from 'next/font/google';
|
||||||
import './globals.css';
|
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 { Toaster } from '@/components/ui/sonner';
|
||||||
import { BackgroundGradient } from '@/components/background-gradient';
|
import { BackgroundGradient } from '@/components/background-gradient';
|
||||||
|
|
||||||
@@ -34,11 +36,15 @@ export default function RootLayout({
|
|||||||
enableSystem
|
enableSystem
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
<div className="relative min-h-screen isolate">
|
<QueryProvider>
|
||||||
<BackgroundGradient />
|
<AppInitializer>
|
||||||
{children}
|
<div className="relative min-h-screen isolate">
|
||||||
</div>
|
<BackgroundGradient />
|
||||||
<Toaster position="top-center" />
|
{children}
|
||||||
|
</div>
|
||||||
|
<Toaster position="top-center" />
|
||||||
|
</AppInitializer>
|
||||||
|
</QueryProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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';
|
'use client';
|
||||||
import * as React from 'react';
|
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 { 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 {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -12,50 +14,26 @@ import {
|
|||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
|
SidebarMenuSub,
|
||||||
|
SidebarMenuSubButton,
|
||||||
|
SidebarMenuSubItem,
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
SidebarGroupLabel,
|
SidebarGroupLabel,
|
||||||
} from '@/components/ui/sidebar';
|
} from '@/components/ui/sidebar';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { useAppStore } from '@/store/app.store';
|
||||||
// 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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
const pathname = usePathname();
|
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 (
|
return (
|
||||||
<Sidebar collapsible="icon" {...props}>
|
<Sidebar collapsible="icon" {...props}>
|
||||||
@@ -73,23 +51,72 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{data.navMain.map((item) => (
|
{navItems.map((item) => (
|
||||||
<SidebarMenuItem key={item.title}>
|
<SidebarNavItem key={item.title} item={item} pathname={pathname} />
|
||||||
<SidebarMenuButton asChild tooltip={item.title} isActive={pathname === item.url}>
|
|
||||||
<Link href={item.url}>
|
|
||||||
{item.icon && <item.icon />}
|
|
||||||
<span>{item.title}</span>
|
|
||||||
</Link>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
))}
|
))}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<NavUser user={data.user} />
|
<NavUser user={user} />
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
<SidebarRail />
|
<SidebarRail />
|
||||||
</Sidebar>
|
</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';
|
'use client';
|
||||||
import { Table } from '@tanstack/react-table';
|
import { Table } from '@tanstack/react-table';
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { Button } from '@/components/ui/button';
|
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 (
|
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 justify-between gap-3 text-sm text-muted-foreground">
|
||||||
<div className="flex items-center gap-6">
|
<span>
|
||||||
<div className="flex items-center space-x-2">
|
Showing {rows} of {totalItems}
|
||||||
<p className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider">
|
</span>
|
||||||
Rows per page
|
<div className="flex gap-2">
|
||||||
</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">
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
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()}
|
onClick={() => table.previousPage()}
|
||||||
disabled={!table.getCanPreviousPage()}
|
disabled={!table.getCanPreviousPage()}
|
||||||
>
|
>
|
||||||
<span className="sr-only">Go to previous page</span>
|
Previous
|
||||||
<ChevronLeft className="h-4 w-4 opacity-70" />
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
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()}
|
onClick={() => table.nextPage()}
|
||||||
disabled={!table.getCanNextPage()}
|
disabled={!table.getCanNextPage()}
|
||||||
>
|
>
|
||||||
<span className="sr-only">Go to next page</span>
|
Next
|
||||||
<ChevronRight className="h-4 w-4 opacity-70" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,45 +1,40 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import SearchBar from './SearchBar';
|
import { Plus } from 'lucide-react';
|
||||||
import { FolderPlus, Settings2, SlidersHorizontal } from 'lucide-react';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
|
|
||||||
interface TopHeaderProps {
|
interface TopHeaderProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
itemCount?: number;
|
itemCount?: number;
|
||||||
onAddNew?: () => void;
|
onAddNew?: () => void;
|
||||||
addButtonText?: string;
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-1 p-3 w-full">
|
<div className="space-y-4">
|
||||||
{/* Connected Summary Block */}
|
{(title || onAddNew) && (
|
||||||
<div className="w-full bg-muted/10 border border-border/50 rounded-lg p-4 flex items-center">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<div className="flex items-center gap-2 text-muted-foreground font-semibold tracking-tight">
|
<div>
|
||||||
<span className="text-base text-foreground/80">Total {title || 'Items'} :</span>
|
{title ? <h2 className="text-base font-semibold">{title}</h2> : null}
|
||||||
<span className="text-primary font-bold text-lg">{itemCount || 0}</span>
|
<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>
|
)}
|
||||||
|
{toolbar ? <div>{toolbar}</div> : null}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,21 +2,20 @@
|
|||||||
import { flexRender } from '@tanstack/react-table';
|
import { flexRender } from '@tanstack/react-table';
|
||||||
import { TableHead, TableHeader as ShadTableHeader, TableRow } from '@/components/ui/table';
|
import { TableHead, TableHeader as ShadTableHeader, TableRow } from '@/components/ui/table';
|
||||||
import { Table } from '@tanstack/react-table';
|
import { Table } from '@tanstack/react-table';
|
||||||
import { ChevronDown, ChevronsUpDown } from 'lucide-react';
|
|
||||||
|
|
||||||
const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
|
const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
|
||||||
return (
|
return (
|
||||||
<ShadTableHeader>
|
<ShadTableHeader className="sticky top-0 z-10 bg-card">
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{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) => {
|
{headerGroup.headers.map((header) => {
|
||||||
return (
|
return (
|
||||||
<TableHead
|
<TableHead
|
||||||
key={header.id}
|
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 : (
|
{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())}
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13,12 +13,11 @@ import {
|
|||||||
import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table';
|
import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Edit3, MoreHorizontal, Trash2 } from 'lucide-react';
|
import { Edit3, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
import TopHeader from './Header';
|
import TopHeader from './Header';
|
||||||
import TableHeader from './TableHeader';
|
import TableHeader from './TableHeader';
|
||||||
import { TableFooter } from './Footer';
|
import { TableFooter } from './Footer';
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
export interface DataTableProps<TData, TValue> {
|
export interface DataTableProps<TData, TValue> {
|
||||||
columns: ColumnDef<TData, TValue>[];
|
columns: ColumnDef<TData, TValue>[];
|
||||||
@@ -29,6 +28,9 @@ export interface DataTableProps<TData, TValue> {
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
onEdit?: (item: TData) => void;
|
onEdit?: (item: TData) => void;
|
||||||
onDelete?: (item: TData) => void;
|
onDelete?: (item: TData) => void;
|
||||||
|
toolbar?: React.ReactNode;
|
||||||
|
emptyTitle?: string;
|
||||||
|
emptyDescription?: string;
|
||||||
pagination?: {
|
pagination?: {
|
||||||
skip: number;
|
skip: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
@@ -47,6 +49,9 @@ export function DataTable<TData, TValue>({
|
|||||||
isLoading = false,
|
isLoading = false,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
toolbar,
|
||||||
|
emptyTitle = 'No results found.',
|
||||||
|
emptyDescription = 'Try adjusting your filters or search terms.',
|
||||||
pagination,
|
pagination,
|
||||||
}: DataTableProps<TData, TValue>) {
|
}: DataTableProps<TData, TValue>) {
|
||||||
const [rowSelection, setRowSelection] = React.useState({});
|
const [rowSelection, setRowSelection] = React.useState({});
|
||||||
@@ -58,32 +63,34 @@ export function DataTable<TData, TValue>({
|
|||||||
if (onEdit || onDelete) {
|
if (onEdit || onDelete) {
|
||||||
cols.push({
|
cols.push({
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
header: () => <div className="text-right px-4">Action</div>,
|
header: () => <div className="text-right">Actions</div>,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const item = row.original;
|
const item = row.original;
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end gap-3 px-4">
|
<div className="flex justify-end gap-2">
|
||||||
{onEdit && (
|
{onEdit && (
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onEdit(item);
|
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" />
|
<Edit3 className="size-4" />
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{onDelete && (
|
{onDelete && (
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDelete(item);
|
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" />
|
<Trash2 className="size-4 text-destructive" />
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -126,31 +133,28 @@ export function DataTable<TData, TValue>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border/50 bg-muted/10 p-1 space-y-6">
|
<section className="space-y-4 rounded-lg border bg-card p-4">
|
||||||
<div className="bg-background/40 dark:bg-background/60 backdrop-blur-xl rounded-xl border border-border/50 overflow-hidden transition-all duration-300">
|
|
||||||
<TopHeader
|
<TopHeader
|
||||||
title={title}
|
title={onAddNew ? title : undefined}
|
||||||
itemCount={data.length}
|
itemCount={pagination?.totalItems ?? data.length}
|
||||||
onAddNew={onAddNew}
|
onAddNew={onAddNew}
|
||||||
addButtonText={addButtonText}
|
addButtonText={addButtonText}
|
||||||
|
toolbar={toolbar}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="px-6 py-2">
|
<div>
|
||||||
<Table
|
<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"
|
className="border-separate border-spacing-0"
|
||||||
>
|
>
|
||||||
<TableHeader table={table} />
|
<TableHeader table={table} />
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
Array.from({ length: 5 }).map((_, idx) => (
|
Array.from({ length: 5 }).map((_, idx) => (
|
||||||
<TableRow
|
<TableRow key={idx}>
|
||||||
key={idx}
|
|
||||||
className="border-b border-border/30 last:border-0 hover:bg-muted/5"
|
|
||||||
>
|
|
||||||
{columns.map((_, colIdx) => (
|
{columns.map((_, colIdx) => (
|
||||||
<TableCell key={colIdx} className="px-6 py-6 border-b border-border/30">
|
<TableCell key={colIdx}>
|
||||||
<Skeleton className="h-4 w-full max-w-[140px] opacity-20" />
|
<Skeleton className="h-4 w-full max-w-[140px]" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -160,13 +164,9 @@ export function DataTable<TData, TValue>({
|
|||||||
<TableRow
|
<TableRow
|
||||||
key={row.id}
|
key={row.id}
|
||||||
data-state={row.getIsSelected() && 'selected'}
|
data-state={row.getIsSelected() && 'selected'}
|
||||||
className="group hover:bg-muted/10 transition-colors"
|
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<TableCell
|
<TableCell key={cell.id}>
|
||||||
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"
|
|
||||||
>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
@@ -174,12 +174,10 @@ export function DataTable<TData, TValue>({
|
|||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<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">
|
<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-sm font-medium">{emptyTitle}</p>
|
||||||
<p className="text-xs opacity-60 font-medium">
|
<p className="text-xs">{emptyDescription}</p>
|
||||||
Try adjusting your filters or search terms.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -187,8 +185,7 @@ export function DataTable<TData, TValue>({
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
<TableFooter table={table} />
|
<TableFooter table={table} totalItems={pagination?.totalItems ?? data.length} />
|
||||||
</div>
|
</section>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { BadgeCheck, Bell, ChevronsUpDown, CreditCard, LogOut, Sparkles } from 'lucide-react';
|
import { BadgeCheck, Bell, ChevronsUpDown, CreditCard, LogOut, Sparkles } from 'lucide-react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +19,11 @@ import {
|
|||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from '@/components/ui/sidebar';
|
} 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({
|
export function NavUser({
|
||||||
user,
|
user,
|
||||||
@@ -29,6 +35,27 @@ export function NavUser({
|
|||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
const { isMobile } = useSidebar();
|
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 (
|
return (
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
@@ -91,9 +118,15 @@ export function NavUser({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem
|
||||||
<LogOut />
|
onSelect={(event) => {
|
||||||
Log out
|
event.preventDefault();
|
||||||
|
handleLogout();
|
||||||
|
}}
|
||||||
|
disabled={isLoggingOut}
|
||||||
|
>
|
||||||
|
<LogOut />
|
||||||
|
{isLoggingOut ? 'Logging out' : 'Log out'}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</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,
|
PaginationParams,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
|
|
||||||
|
const CHAINAGES_ENDPOINT = 'biz/api/v1/chainages';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chainage Service
|
* Chainage Service
|
||||||
*/
|
*/
|
||||||
@@ -17,7 +19,7 @@ export const chainageService = {
|
|||||||
getChainages: async (params?: PaginationParams): Promise<PaginatedResponse<Chainage>> => {
|
getChainages: async (params?: PaginationParams): Promise<PaginatedResponse<Chainage>> => {
|
||||||
const skip = params?.skip ?? 0;
|
const skip = params?.skip ?? 0;
|
||||||
const limit = params?.limit ?? 100;
|
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 },
|
params: { skip, limit },
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -32,7 +34,7 @@ export const chainageService = {
|
|||||||
): Promise<PaginatedResponse<Chainage>> => {
|
): Promise<PaginatedResponse<Chainage>> => {
|
||||||
const skip = params?.skip ?? 0;
|
const skip = params?.skip ?? 0;
|
||||||
const limit = params?.limit ?? 100;
|
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 },
|
params: { package_id: packageId, skip, limit },
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -42,7 +44,7 @@ export const chainageService = {
|
|||||||
* Create a new chainage
|
* Create a new chainage
|
||||||
*/
|
*/
|
||||||
createChainage: async (data: ChainageCreate): Promise<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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -50,7 +52,7 @@ export const chainageService = {
|
|||||||
* Update an existing chainage
|
* Update an existing chainage
|
||||||
*/
|
*/
|
||||||
updateChainage: async (chainageId: string, data: ChainageUpdate): Promise<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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -58,7 +60,9 @@ export const chainageService = {
|
|||||||
* Delete a chainage
|
* Delete a chainage
|
||||||
*/
|
*/
|
||||||
deleteChainage: async (chainageId: string): Promise<{ message: string }> => {
|
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;
|
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;
|
const summary = response.data;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
export * from './project.service';
|
export * from './project.service';
|
||||||
export * from './package.service';
|
export * from './package.service';
|
||||||
|
export * from './auth.service';
|
||||||
export { chainageService } from './chainage.service';
|
export { chainageService } from './chainage.service';
|
||||||
export * from './video.service';
|
export * from './video.service';
|
||||||
export * from './detection.service';
|
export * from './detection.service';
|
||||||
export * from './session.service';
|
export * from './session.service';
|
||||||
export { projectDataService } from './project.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,
|
PaginationParams,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
|
|
||||||
|
const PACKAGES_ENDPOINT = 'biz/api/v1/packages';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Package Service
|
* Package Service
|
||||||
*/
|
*/
|
||||||
@@ -17,7 +19,7 @@ export const packageService = {
|
|||||||
getPackages: async (params?: PaginationParams): Promise<PaginatedResponse<Package>> => {
|
getPackages: async (params?: PaginationParams): Promise<PaginatedResponse<Package>> => {
|
||||||
const skip = params?.skip ?? 0;
|
const skip = params?.skip ?? 0;
|
||||||
const limit = params?.limit ?? 100;
|
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 },
|
params: { skip, limit },
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -32,7 +34,7 @@ export const packageService = {
|
|||||||
): Promise<PaginatedResponse<Package>> => {
|
): Promise<PaginatedResponse<Package>> => {
|
||||||
const skip = params?.skip ?? 0;
|
const skip = params?.skip ?? 0;
|
||||||
const limit = params?.limit ?? 100;
|
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 },
|
params: { project_id: projectId, skip, limit },
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -42,7 +44,7 @@ export const packageService = {
|
|||||||
* Create a new package
|
* Create a new package
|
||||||
*/
|
*/
|
||||||
createPackage: async (data: PackageCreate): Promise<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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -50,7 +52,7 @@ export const packageService = {
|
|||||||
* Update an existing package
|
* Update an existing package
|
||||||
*/
|
*/
|
||||||
updatePackage: async (packageId: string, data: PackageUpdate): Promise<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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -58,7 +60,9 @@ export const packageService = {
|
|||||||
* Delete a package
|
* Delete a package
|
||||||
*/
|
*/
|
||||||
deletePackage: async (packageId: string): Promise<{ message: string }> => {
|
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;
|
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,
|
PaginationParams,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
|
|
||||||
|
const PROJECTS_ENDPOINT = 'biz/api/v1/projects';
|
||||||
|
const PROJECT_SUMMARY_ENDPOINT = 'biz/api/v1/dashboard/overview';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Project Service
|
* Project Service
|
||||||
*/
|
*/
|
||||||
@@ -17,7 +20,7 @@ export const projectService = {
|
|||||||
getProjects: async (params?: PaginationParams): Promise<PaginatedResponse<Project>> => {
|
getProjects: async (params?: PaginationParams): Promise<PaginatedResponse<Project>> => {
|
||||||
const skip = params?.skip ?? 0;
|
const skip = params?.skip ?? 0;
|
||||||
const limit = params?.limit ?? 100;
|
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 },
|
params: { skip, limit },
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -27,7 +30,7 @@ export const projectService = {
|
|||||||
* Create a new project
|
* Create a new project
|
||||||
*/
|
*/
|
||||||
createProject: async (data: ProjectCreate): Promise<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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -35,7 +38,7 @@ export const projectService = {
|
|||||||
* Update an existing project
|
* Update an existing project
|
||||||
*/
|
*/
|
||||||
updateProject: async (projectId: string, data: ProjectUpdate): Promise<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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -43,7 +46,9 @@ export const projectService = {
|
|||||||
* Delete a project
|
* Delete a project
|
||||||
*/
|
*/
|
||||||
deleteProject: async (projectId: string): Promise<{ message: string }> => {
|
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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -51,7 +56,9 @@ export const projectService = {
|
|||||||
* Fetch project summary (detections across packages and chainages)
|
* Fetch project summary (detections across packages and chainages)
|
||||||
*/
|
*/
|
||||||
getProjectSummary: async (projectId: string): Promise<any> => {
|
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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -59,8 +66,8 @@ export const projectService = {
|
|||||||
* Fetch project summary filtered by video ID
|
* Fetch project summary filtered by video ID
|
||||||
*/
|
*/
|
||||||
getProjectSummaryByVideo: async (projectId: string, videoId: string): Promise<any> => {
|
getProjectSummaryByVideo: async (projectId: string, videoId: string): Promise<any> => {
|
||||||
const response = await axiosClient.get(`/summary/projects/${projectId}`, {
|
const response = await axiosClient.get(PROJECT_SUMMARY_ENDPOINT, {
|
||||||
params: { video_id: videoId },
|
params: { project_id: projectId, video_id: videoId },
|
||||||
});
|
});
|
||||||
return response.data;
|
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 { ENV_CONSTANT } from '@/constants/secrect.constant';
|
||||||
|
import { useAppStore } from '@/store/app.store';
|
||||||
|
import { useAuthStore } from '@/store/auth.store';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
const BASE_URL = ENV_CONSTANT.BASE_API_URL;
|
const BASE_URL = ENV_CONSTANT.BASE_API_URL;
|
||||||
@@ -6,20 +8,19 @@ const BASE_URL = ENV_CONSTANT.BASE_API_URL;
|
|||||||
const axiosClient = axios.create({
|
const axiosClient = axios.create({
|
||||||
baseURL: BASE_URL,
|
baseURL: BASE_URL,
|
||||||
headers: {
|
headers: {
|
||||||
// "Content-Type": "application/json",
|
'Content-Type': 'application/json',
|
||||||
'ngrok-skip-browser-warning': 'true',
|
'ngrok-skip-browser-warning': 'true',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add request interceptor to inject access token
|
|
||||||
axiosClient.interceptors.request.use(
|
axiosClient.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
if (typeof window !== 'undefined') {
|
const token = useAuthStore.getState().accessToken;
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
if (token && config.headers) {
|
if (token && config.headers) {
|
||||||
config.headers['Authorization'] = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
@@ -27,18 +28,58 @@ axiosClient.interceptors.request.use(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Basic response interceptor
|
|
||||||
axiosClient.interceptors.response.use(
|
axiosClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
async (error) => {
|
||||||
// Standard error handling can be added here later
|
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);
|
return Promise.reject(error);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export const axiosAuth = axios.create({
|
export const axiosAuth = axios.create({
|
||||||
baseURL: BASE_URL,
|
baseURL: BASE_URL,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'ngrok-skip-browser-warning': 'true',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default axiosClient;
|
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