437 lines
16 KiB
TypeScript
437 lines
16 KiB
TypeScript
// 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';
|
|
|
|
// 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 (
|
|
<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} />
|
|
|
|
{/* 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>
|
|
|
|
{/* 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`);
|
|
}
|
|
}}
|
|
/>
|
|
|
|
{/* 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>
|
|
<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>
|
|
|
|
{/* Refresh */}
|
|
<Button variant="outline" size="icon" onClick={handleRefresh}>
|
|
<RefreshCw className="h-4 w-4" />
|
|
</Button>
|
|
|
|
{/* Export */}
|
|
<Button variant="outline" size="icon" onClick={handleExport}>
|
|
<Download className="h-4 w-4" />
|
|
</Button>
|
|
|
|
{/* Create User */}
|
|
<Button onClick={() => setCreateDialogOpen(true)} className="gap-2">
|
|
<Plus className="h-4 w-4" /> Add User
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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>
|
|
);
|
|
}
|