feat(users): implement server actions and audit logging
This commit is contained in:
@@ -1,86 +1,436 @@
|
||||
// Users.tsx - User Command Center - Complete rewrite
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQueryState, parseAsString, parseAsInteger, parseAsArrayOf } from 'nuqs';
|
||||
import { AppLayout } from '@/components/layout/AppLayout';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
LayoutGrid,
|
||||
LayoutList,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
Shield,
|
||||
Users,
|
||||
BriefcaseBusiness,
|
||||
Settings2,
|
||||
ListFilter
|
||||
} from "lucide-react";
|
||||
import { AppLayout } from "@/components/layout/AppLayout";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { InternalTeamTab } from "@/features/users/components/InternalTeamTab";
|
||||
import { PartnerAdminTab } from "@/features/users/components/PartnerAdminTab";
|
||||
import { UserBaseTab } from "@/features/users/components/UserBaseTab";
|
||||
import { RoleMatrix } from "@/features/users/components/RoleMatrix";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger
|
||||
} from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
// Components
|
||||
import { UsersTable } from '@/features/users/components/UsersTable';
|
||||
import { UserFilters } from '@/features/users/components/UserFilters';
|
||||
import { UserMetricsBar } from '@/features/users/components/UserMetricsBar';
|
||||
import { UserInspectorSheet } from '@/features/users/components/UserInspectorSheet';
|
||||
import { CreateUserDialog } from '@/features/users/components/CreateUserDialog';
|
||||
import { SuspensionModal } from '@/features/users/components/SuspensionModal';
|
||||
import { BanModal } from '@/features/users/components/BanModal';
|
||||
import { DeleteConfirmDialog } from '@/features/users/components/DeleteConfirmDialog';
|
||||
import { NotificationComposer } from '@/features/users/components/NotificationComposer';
|
||||
import { BulkActionsDropdown } from '@/features/users/components/BulkActionsDropdown';
|
||||
|
||||
// Data & Types
|
||||
import { mockCrmUsers, mockUserMetrics } from '@/features/users/data/mockUserCrmData';
|
||||
import type { User, SegmentFilters } from '@/lib/types/user';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type ViewMode = 'table' | 'grid';
|
||||
|
||||
export default function Users() {
|
||||
// URL State with nuqs
|
||||
const [searchQuery, setSearchQuery] = useQueryState('q', parseAsString.withDefault(''));
|
||||
const [minSpent, setMinSpent] = useQueryState('minSpent', parseAsInteger);
|
||||
const [maxSpent, setMaxSpent] = useQueryState('maxSpent', parseAsInteger);
|
||||
|
||||
// Local State
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('table');
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [filters, setFilters] = useState<SegmentFilters>({});
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
|
||||
|
||||
// Modal States
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [inspectorOpen, setInspectorOpen] = useState(false);
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [suspensionModalOpen, setSuspensionModalOpen] = useState(false);
|
||||
const [banModalOpen, setBanModalOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [notificationComposerOpen, setNotificationComposerOpen] = useState(false);
|
||||
const [notificationTargetUsers, setNotificationTargetUsers] = useState<User[]>([]);
|
||||
|
||||
// Sync URL params to filters
|
||||
const mergedFilters: SegmentFilters = useMemo(() => ({
|
||||
...filters,
|
||||
search: searchQuery || filters.search,
|
||||
minSpent: minSpent ?? filters.minSpent,
|
||||
maxSpent: maxSpent ?? filters.maxSpent,
|
||||
}), [filters, searchQuery, minSpent, maxSpent]);
|
||||
|
||||
// Active filters count
|
||||
const activeFiltersCount = useMemo(() => {
|
||||
let count = 0;
|
||||
if (mergedFilters.search) count++;
|
||||
if (mergedFilters.status?.length) count++;
|
||||
if (mergedFilters.role?.length) count++;
|
||||
if (mergedFilters.tier?.length) count++;
|
||||
if (mergedFilters.tags?.length) count++;
|
||||
if (mergedFilters.minSpent) count++;
|
||||
if (mergedFilters.maxSpent) count++;
|
||||
return count;
|
||||
}, [mergedFilters]);
|
||||
|
||||
// Filter users
|
||||
const filteredUsers = useMemo(() => {
|
||||
return mockCrmUsers.filter((user) => {
|
||||
// Search filter
|
||||
if (mergedFilters.search) {
|
||||
const query = mergedFilters.search.toLowerCase();
|
||||
if (
|
||||
!user.name.toLowerCase().includes(query) &&
|
||||
!user.email.toLowerCase().includes(query) &&
|
||||
!user.phone.includes(query) &&
|
||||
!user.id.toLowerCase().includes(query)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if (mergedFilters.status?.length && !mergedFilters.status.includes(user.status)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Role filter
|
||||
if (mergedFilters.role?.length && !mergedFilters.role.includes(user.role)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Tier filter
|
||||
if (mergedFilters.tier?.length && !mergedFilters.tier.includes(user.tier)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Spent range filter
|
||||
if (mergedFilters.minSpent && user.totalSpent < mergedFilters.minSpent) {
|
||||
return false;
|
||||
}
|
||||
if (mergedFilters.maxSpent && user.totalSpent > mergedFilters.maxSpent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Tags filter
|
||||
if (mergedFilters.tags?.length) {
|
||||
const userTagIds = user.tags.map((t) => t.id);
|
||||
if (!mergedFilters.tags.some((tagId) => userTagIds.includes(tagId))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [mergedFilters]);
|
||||
|
||||
// Selected users array
|
||||
const selectedUsers = useMemo(
|
||||
() => mockCrmUsers.filter((u) => selectedUserIds.includes(u.id)),
|
||||
[selectedUserIds]
|
||||
);
|
||||
|
||||
// Handlers
|
||||
const handleSelectUser = (user: User) => {
|
||||
setSelectedUser(user);
|
||||
setInspectorOpen(true);
|
||||
};
|
||||
|
||||
const handleEditUser = (user: User) => {
|
||||
setSelectedUser(user);
|
||||
toast.info(`Edit user: ${user.name}`);
|
||||
// In real app, this would open an edit modal
|
||||
};
|
||||
|
||||
const handleSuspendUser = (user: User) => {
|
||||
setSelectedUser(user);
|
||||
setSuspensionModalOpen(true);
|
||||
};
|
||||
|
||||
const handleBanUser = (user: User) => {
|
||||
setSelectedUser(user);
|
||||
setBanModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteUser = (user: User) => {
|
||||
setSelectedUser(user);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSendNotification = (user: User) => {
|
||||
setNotificationTargetUsers([user]);
|
||||
setNotificationComposerOpen(true);
|
||||
};
|
||||
|
||||
const handleBulkNotification = (users: User[]) => {
|
||||
setNotificationTargetUsers(users);
|
||||
setNotificationComposerOpen(true);
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
toast.success('Data refreshed');
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
toast.success(`Exported ${filteredUsers.length} users to CSV`);
|
||||
};
|
||||
|
||||
const handleFiltersChange = (newFilters: SegmentFilters) => {
|
||||
setFilters(newFilters);
|
||||
// Sync to URL
|
||||
if (newFilters.search !== mergedFilters.search) {
|
||||
setSearchQuery(newFilters.search || null);
|
||||
}
|
||||
if (newFilters.minSpent !== mergedFilters.minSpent) {
|
||||
setMinSpent(newFilters.minSpent || null);
|
||||
}
|
||||
if (newFilters.maxSpent !== mergedFilters.maxSpent) {
|
||||
setMaxSpent(newFilters.maxSpent || null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLayout title="Command Center" description="Unified team, partner, and user management.">
|
||||
<div className="space-y-6 container mx-auto p-2 md:p-4">
|
||||
<TooltipProvider>
|
||||
<AppLayout title="User Command Center" description="Comprehensive user management & CRM">
|
||||
<div className="space-y-6 container mx-auto p-2 md:p-4">
|
||||
{/* Metrics Bar */}
|
||||
<UserMetricsBar metrics={mockUserMetrics} />
|
||||
|
||||
<Tabs defaultValue="internal" className="w-full space-y-6">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
{/* Left: Search */}
|
||||
<div className="flex items-center gap-3 flex-1 max-w-md">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search users by name, email, phone or ID..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value || null)}
|
||||
className="pl-9 bg-white/50 border-white/50"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant={filtersOpen ? "secondary" : "outline"}
|
||||
onClick={() => setFiltersOpen(!filtersOpen)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
Filters
|
||||
{activeFiltersCount > 0 && (
|
||||
<Badge variant="secondary" className="ml-1 h-5 w-5 p-0 flex items-center justify-center bg-primary text-primary-foreground rounded-full text-[10px]">
|
||||
{activeFiltersCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Main Navigation Tabs */}
|
||||
<TabsList className="grid grid-cols-3 w-full md:w-[600px] h-11 bg-secondary/50 border border-border/50">
|
||||
<TabsTrigger value="internal" className="data-[state=active]:bg-background data-[state=active]:shadow-sm">
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
Internal Team
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="partners" className="data-[state=active]:bg-background data-[state=active]:shadow-sm">
|
||||
<BriefcaseBusiness className="h-4 w-4 mr-2" />
|
||||
Partners
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="users" className="data-[state=active]:bg-background data-[state=active]:shadow-sm">
|
||||
<Users className="h-4 w-4 mr-2" />
|
||||
User Base
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{/* Right: Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Bulk Actions (only when selected) */}
|
||||
<BulkActionsDropdown
|
||||
selectedUsers={selectedUsers}
|
||||
onClearSelection={() => setSelectedUserIds([])}
|
||||
onSendNotification={handleBulkNotification}
|
||||
onSuspendUsers={(users) => {
|
||||
if (users.length === 1) {
|
||||
handleSuspendUser(users[0]);
|
||||
} else {
|
||||
toast.info(`Suspend ${users.length} users`);
|
||||
}
|
||||
}}
|
||||
onBanUsers={(users) => {
|
||||
if (users.length === 1) {
|
||||
handleBanUser(users[0]);
|
||||
} else {
|
||||
toast.info(`Ban ${users.length} users`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Quick Actions (Role Manager) */}
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" className="gap-2 border-dashed border-primary/40 hover:border-primary text-primary hover:bg-primary/5">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
Manage Roles & Permissions
|
||||
{/* View Toggle */}
|
||||
<div className="flex border rounded-lg overflow-hidden bg-white/50">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn('rounded-none px-3', viewMode === 'table' && 'bg-primary/10')}
|
||||
onClick={() => setViewMode('table')}
|
||||
>
|
||||
<LayoutList className="h-4 w-4" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="min-w-[400px] md:min-w-[800px] sm:w-[80vw] overflow-y-auto">
|
||||
<SheetHeader className="mb-6">
|
||||
<SheetTitle>Role Permissions Matrix</SheetTitle>
|
||||
<SheetDescription>Verify and modify capabilities for each internal system role.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<RoleMatrix />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn('rounded-none px-3', viewMode === 'grid' && 'bg-primary/10')}
|
||||
onClick={() => setViewMode('grid')}
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-[600px] rounded-xl border border-border/40 bg-card/30 p-1">
|
||||
<div className="h-full bg-background/50 rounded-lg p-4 md:p-6 shadow-sm">
|
||||
<TabsContent value="internal" className="m-0 focus-visible:outline-none animate-in fade-in-50 duration-300">
|
||||
<InternalTeamTab />
|
||||
</TabsContent>
|
||||
{/* Refresh */}
|
||||
<Button variant="outline" size="icon" onClick={handleRefresh}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<TabsContent value="partners" className="m-0 focus-visible:outline-none animate-in fade-in-50 duration-300">
|
||||
<PartnerAdminTab />
|
||||
</TabsContent>
|
||||
{/* Export */}
|
||||
<Button variant="outline" size="icon" onClick={handleExport}>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<TabsContent value="users" className="m-0 focus-visible:outline-none animate-in fade-in-50 duration-300">
|
||||
<UserBaseTab />
|
||||
</TabsContent>
|
||||
{/* Create User */}
|
||||
<Button onClick={() => setCreateDialogOpen(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" /> Add User
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex gap-6">
|
||||
{/* Filter Sidebar (collapsible) */}
|
||||
{filtersOpen && (
|
||||
<UserFilters
|
||||
filters={mergedFilters}
|
||||
onFiltersChange={handleFiltersChange}
|
||||
isOpen={true}
|
||||
onToggle={() => setFiltersOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1">
|
||||
{/* Results Count */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-semibold text-foreground">{filteredUsers.length}</span> of{' '}
|
||||
<span className="font-semibold text-foreground">{mockCrmUsers.length}</span> users
|
||||
{selectedUserIds.length > 0 && (
|
||||
<span className="ml-2 text-primary">
|
||||
({selectedUserIds.length} selected)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Table View */}
|
||||
{viewMode === 'table' && (
|
||||
<UsersTable
|
||||
users={filteredUsers}
|
||||
onSelectUser={handleSelectUser}
|
||||
onEditUser={handleEditUser}
|
||||
onSuspendUser={handleSuspendUser}
|
||||
onBanUser={handleBanUser}
|
||||
onDeleteUser={handleDeleteUser}
|
||||
onSendNotification={handleSendNotification}
|
||||
selectedUserIds={selectedUserIds}
|
||||
onSelectionChange={setSelectedUserIds}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Grid View */}
|
||||
{viewMode === 'grid' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{filteredUsers.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
onClick={() => handleSelectUser(user)}
|
||||
className="group relative flex flex-col gap-4 rounded-xl border border-white/40 bg-white/30 p-5 shadow-neu-sm hover:shadow-neu transition-all duration-300 cursor-pointer backdrop-blur-md overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt={user.name}
|
||||
className="h-12 w-12 rounded-full border-2 border-white shadow-sm object-cover"
|
||||
/>
|
||||
<div>
|
||||
<h4 className="font-bold text-foreground leading-tight group-hover:text-primary transition-colors">
|
||||
{user.name}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className={cn(
|
||||
'px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
user.status === 'Active' ? 'bg-emerald-100 text-emerald-700' :
|
||||
user.status === 'Suspended' ? 'bg-orange-100 text-orange-700' :
|
||||
user.status === 'Banned' ? 'bg-red-100 text-red-700' :
|
||||
'bg-slate-100 text-slate-600'
|
||||
)}>
|
||||
{user.status}
|
||||
</span>
|
||||
<span className="font-semibold text-primary">
|
||||
₹{(user.totalSpent / 1000).toFixed(0)}K
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{user.tags.slice(0, 2).map((tag) => (
|
||||
<span key={tag.id} className={cn('text-[10px] px-1.5 py-0.5 rounded-full', tag.color)}>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
{user.tags.length > 2 && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-slate-100 text-slate-600">
|
||||
+{user.tags.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modals & Sheets */}
|
||||
<UserInspectorSheet
|
||||
user={selectedUser}
|
||||
open={inspectorOpen}
|
||||
onOpenChange={setInspectorOpen}
|
||||
onEditUser={handleEditUser}
|
||||
onSendNotification={handleSendNotification}
|
||||
/>
|
||||
|
||||
<CreateUserDialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
onUserCreated={handleRefresh}
|
||||
/>
|
||||
|
||||
<SuspensionModal
|
||||
user={selectedUser}
|
||||
open={suspensionModalOpen}
|
||||
onOpenChange={setSuspensionModalOpen}
|
||||
onSuspended={handleRefresh}
|
||||
/>
|
||||
|
||||
<BanModal
|
||||
user={selectedUser}
|
||||
open={banModalOpen}
|
||||
onOpenChange={setBanModalOpen}
|
||||
onBanned={handleRefresh}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
user={selectedUser}
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
onDeleted={handleRefresh}
|
||||
/>
|
||||
|
||||
<NotificationComposer
|
||||
users={notificationTargetUsers}
|
||||
open={notificationComposerOpen}
|
||||
onOpenChange={setNotificationComposerOpen}
|
||||
onSent={() => setNotificationTargetUsers([])}
|
||||
/>
|
||||
</AppLayout>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user