Files
eventify_command_center/src/pages/Users.tsx

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