10 Commits

108 changed files with 6271 additions and 230 deletions

7
.cursor/mcp.json Normal file
View File

@@ -0,0 +1,7 @@
{
"mcpServers": {
"dual-graph": {
"url": "http://127.0.0.1:8080/mcp"
}
}
}

1
.gitignore vendored
View File

@@ -50,3 +50,4 @@ coverage/
# Misc # Misc
.vercel .vercel
.dual-graph/

2
next-env.d.ts vendored
View File

@@ -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
View File

@@ -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",

View File

@@ -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",

View 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>
);
}

View 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>
);
}

View 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]);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,47 @@
'use client';
import type { ReactNode } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { DataTable } from '@/components/data-table';
import type { 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,
}}
/>
);
}

View 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,
};
}

View 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,
};
}

View 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,
};
}

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

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

View 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,
};

View File

@@ -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,6 +12,7 @@ const ModulesLayout = ({
children: React.ReactNode; children: React.ReactNode;
}>) => { }>) => {
return ( return (
<AuthGuard>
<SidebarProvider className="bg-transparent overflow-hidden"> <SidebarProvider className="bg-transparent overflow-hidden">
<AppSidebar className="bg-transparent" /> <AppSidebar className="bg-transparent" />
<main className="flex flex-1 flex-col w-full h-screen overflow-hidden bg-transparent"> <main className="flex flex-1 flex-col w-full h-screen overflow-hidden bg-transparent">
@@ -28,6 +30,7 @@ const ModulesLayout = ({
</div> </div>
</main> </main>
</SidebarProvider> </SidebarProvider>
</AuthGuard>
); );
}; };

View File

@@ -0,0 +1,126 @@
'use client';
import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { Edit, RotateCcw } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions';
import { usePermissions } from '@/hooks/usePermissions';
import type { Plan } from '@/types';
function formatDate(value?: string | null) {
if (!value) return '-';
return new Intl.DateTimeFormat('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric',
}).format(new Date(value));
}
function formatPrice(value: string, cycle: string) {
const amount = Number(value);
const price = Number.isFinite(amount) ? amount.toFixed(2) : value;
return `${price} / ${cycle}`;
}
interface UsePlanColumnsParams {
onEdit: (plan: Plan) => void;
onToggleStatus: (plan: Plan) => void;
pendingPlanId?: number;
}
export function usePlanColumns({
onEdit,
onToggleStatus,
pendingPlanId,
}: UsePlanColumnsParams): ColumnDef<Plan>[] {
const { hasPermission } = usePermissions();
const canEdit = hasPermission(PERMISSIONS.PLAN.UPDATE);
const canDelete = hasPermission(PERMISSIONS.PLAN.DELETE);
return useMemo(() => {
const columns: ColumnDef<Plan>[] = [
{
accessorKey: 'name',
header: 'Plan',
cell: ({ row }) => (
<div>
<p className="font-medium">{row.original.name}</p>
<p className="text-xs text-muted-foreground">{row.original.slug}</p>
</div>
),
},
{
accessorKey: 'price',
header: 'Price',
cell: ({ row }) => formatPrice(row.original.price, row.original.billing_cycle),
},
{
accessorKey: 'trial_days',
header: 'Trial',
cell: ({ row }) => `${row.original.trial_days} days`,
},
{
id: 'limits',
header: 'Limits',
cell: ({ row }) => (
<div className="text-xs text-muted-foreground">
<p>{row.original.max_projects} projects</p>
<p>{row.original.max_users} users</p>
</div>
),
},
{
accessorKey: 'created_at',
header: 'Created',
cell: ({ row }) => formatDate(row.original.created_at),
},
{
accessorKey: 'is_active',
header: 'Status',
cell: ({ row }) => (
<div className="flex flex-wrap gap-1.5">
<Badge variant={row.original.is_active ? 'default' : 'secondary'}>
{row.original.is_active ? 'Active' : 'Inactive'}
</Badge>
{row.original.is_custom ? <Badge variant="outline">Custom</Badge> : null}
</div>
),
},
];
if (!canEdit && !canDelete) return columns;
columns.push({
id: 'actions',
header: () => <div className="text-right">Actions</div>,
cell: ({ row }) => {
const plan = row.original;
return (
<div className="flex justify-end gap-2">
{canEdit ? (
<Button variant="outline" size="sm" onClick={() => onEdit(plan)}>
<Edit className="size-4" />
</Button>
) : null}
{canDelete ? (
<Button
variant="outline"
size="sm"
disabled={pendingPlanId === plan.id}
onClick={() => onToggleStatus(plan)}
>
<RotateCcw className="mr-2 size-4" />
{plan.is_active ? 'Deactivate' : 'Activate'}
</Button>
) : null}
</div>
);
},
});
return columns;
}, [canDelete, canEdit, onEdit, onToggleStatus, pendingPlanId]);
}

View File

@@ -0,0 +1,51 @@
'use client';
import { Search } from 'lucide-react';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { PlanStatusFilter } from '../hooks/usePlanFilters';
interface PlanFiltersProps {
searchTerm: string;
statusFilter: PlanStatusFilter;
onSearchChange: (value: string) => void;
onStatusChange: (value: PlanStatusFilter) => void;
}
export function PlanFilters({
searchTerm,
statusFilter,
onSearchChange,
onStatusChange,
}: PlanFiltersProps) {
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-72">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchTerm}
onChange={(event) => onSearchChange(event.target.value)}
placeholder="Search plans"
className="pl-9"
/>
</div>
<Select value={statusFilter} onValueChange={(value) => onStatusChange(value as PlanStatusFilter)}>
<SelectTrigger className="w-full sm:w-40">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
</SelectContent>
</Select>
</div>
);
}

View File

@@ -0,0 +1,237 @@
'use client';
import type { ComponentProps } from 'react';
import type { Control, UseFormRegister } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import { Loader2 } from 'lucide-react';
import { PermissionTree } from '@/components/permission-tree';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import type { PermissionTreeItem } from '@/types';
import type { PlanFormValues } from '../hooks/usePlanForm';
interface PlanSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
planId?: number;
register: UseFormRegister<PlanFormValues>;
control: Control<PlanFormValues>;
onSubmit: ComponentProps<'form'>['onSubmit'];
permissionTree: PermissionTreeItem[];
permissionIds: number[];
onPermissionIdsChange: (ids: number[]) => void;
isPermissionsLoading: boolean;
isSaving: boolean;
}
export function PlanSheet({
open,
onOpenChange,
planId,
register,
control,
onSubmit,
permissionTree,
permissionIds,
onPermissionIdsChange,
isPermissionsLoading,
isSaving,
}: PlanSheetProps) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full overflow-y-auto sm:max-w-3xl">
<SheetHeader>
<SheetTitle>{planId ? 'Edit Plan' : 'Create Plan'}</SheetTitle>
<SheetDescription>
Configure subscription limits, billing, and permission access.
</SheetDescription>
</SheetHeader>
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="plan-name">Name</Label>
<Input
id="plan-name"
placeholder="Starter"
{...register('name', { required: true })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="plan-slug">Slug</Label>
<Input
id="plan-slug"
placeholder="starter"
{...register('slug', { required: true })}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="plan-description">Description</Label>
<textarea
id="plan-description"
placeholder="Basic plan for small teams"
{...register('description', { required: true })}
required
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="plan-price">Price</Label>
<Input
id="plan-price"
type="number"
min="0"
step="0.01"
placeholder="29.99"
{...register('price', { required: true })}
required
/>
</div>
<div className="space-y-2">
<Label>Billing Cycle</Label>
<Controller
control={control}
name="billing_cycle"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Billing cycle" />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Monthly</SelectItem>
<SelectItem value="quarterly">Quarterly</SelectItem>
<SelectItem value="yearly">Yearly</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="plan-trial-days">Trial Days</Label>
<Input
id="plan-trial-days"
type="number"
min="0"
{...register('trial_days', { valueAsNumber: true })}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-4">
<div className="space-y-2">
<Label htmlFor="max-projects">Projects</Label>
<Input
id="max-projects"
type="number"
min="0"
{...register('max_projects', { valueAsNumber: true })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="max-organizations">Organizations</Label>
<Input
id="max-organizations"
type="number"
min="0"
{...register('max_organizations', { valueAsNumber: true })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="max-users">Users</Label>
<Input
id="max-users"
type="number"
min="0"
{...register('max_users', { valueAsNumber: true })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="max-roles">Roles</Label>
<Input
id="max-roles"
type="number"
min="0"
{...register('max_roles', { valueAsNumber: true })}
/>
</div>
</div>
<div className="flex flex-wrap gap-5">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
className="size-4 rounded border-border accent-primary"
{...register('is_active')}
/>
Active
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
className="size-4 rounded border-border accent-primary"
{...register('is_custom')}
/>
Custom plan
</label>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<Label>Permissions</Label>
<span className="text-xs text-muted-foreground">{permissionIds.length} selected</span>
</div>
{isPermissionsLoading ? (
<div className="rounded-md border p-6 text-sm text-muted-foreground">
Loading permissions...
</div>
) : (
<PermissionTree
items={permissionTree}
selectedIds={permissionIds}
onChange={onPermissionIdsChange}
/>
)}
</div>
<SheetFooter className="px-0">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
Cancel
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
{planId ? 'Update Plan' : 'Create Plan'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,47 @@
'use client';
import type { ReactNode } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { DataTable } from '@/components/data-table';
import type { Plan } from '@/types';
interface PlanTableProps {
columns: ColumnDef<Plan>[];
plans: Plan[];
isLoading: boolean;
toolbar: ReactNode;
skip: number;
limit: number;
total: number;
onPageChange: (skip: number) => void;
}
export function PlanTable({
columns,
plans,
isLoading,
toolbar,
skip,
limit,
total,
onPageChange,
}: PlanTableProps) {
return (
<DataTable
title="Plans"
columns={columns}
data={plans}
isLoading={isLoading}
toolbar={toolbar}
emptyTitle="No plans found."
pagination={{
skip,
limit,
totalItems: total,
onPageChange,
onLimitChange: () => undefined,
}}
/>
);
}

View 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,
};
}

View 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,
};
}

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

View 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,
};
}

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

View 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,
};

View 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]);
}

View 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>
);
}

View 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>
);
}

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

View 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,
};
}

View 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,
};
}

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

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

View 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,
};

View 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]);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,47 @@
'use client';
import type { ReactNode } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { DataTable } from '@/components/data-table';
import type { 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,
}}
/>
);
}

View 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,
};
}

View 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,
};
}

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

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

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

View 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,
};

View File

@@ -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>

View 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]);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,47 @@
'use client';
import type { ReactNode } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { DataTable } from '@/components/data-table';
import type { 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,
}}
/>
);
}

View 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,
};
}

View 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,
};
}

View 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,
};
}

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

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

View 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,
};

View File

@@ -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
> >
<QueryProvider>
<AppInitializer>
<div className="relative min-h-screen isolate"> <div className="relative min-h-screen isolate">
<BackgroundGradient /> <BackgroundGradient />
{children} {children}
</div> </div>
<Toaster position="top-center" /> <Toaster position="top-center" />
</AppInitializer>
</QueryProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>

21
src/app/not-found.tsx Normal file
View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -1,71 +1,39 @@
'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>
); );
} }

View File

@@ -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> </div>
</div> {onAddNew ? (
<Button onClick={onAddNew}>
<div className="flex items-center justify-between gap-4"> <Plus className="mr-2 size-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} {addButtonText}
</Button> </Button>
)} */} ) : null}
</div> </div>
)}
{toolbar ? <div>{toolbar}</div> : null}
</div> </div>
); );
}; };

View File

@@ -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>
)} )}

View File

@@ -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>
); );
} }

View File

@@ -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
onSelect={(event) => {
event.preventDefault();
handleLogout();
}}
disabled={isLoggingOut}
>
<LogOut /> <LogOut />
Log out {isLoggingOut ? 'Logging out' : 'Log out'}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View 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
View 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
View 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
View 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);
}

View 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
View 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
View 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;
}

View 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
View File

@@ -0,0 +1,3 @@
export * from './AuthGuard';
export * from './GuestGuard';
export * from './PermissionGuard';

17
src/hooks/useDebounce.ts Normal file
View 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
View 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,
};
};

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

View 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;
}

View 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>
);
}

View 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;
},
};

View File

@@ -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;
}, },
}; };

View 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;
},
};

View File

@@ -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;

View File

@@ -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';

View File

@@ -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;
}, },
}; };

View 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;
},
};

View 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;
},
};

View File

@@ -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;
}, },

View 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;
},
};

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

View 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;
},
};

View File

@@ -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;

View 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
View 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
View 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
View 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