Implement User CRM: Enhanced Cards, Filters (nuqs), and Details Sheet

This commit is contained in:
CycroftX
2026-02-04 23:09:11 +05:30
parent 06dec50c9e
commit c07ebd4ba8
8 changed files with 771 additions and 95 deletions

159
API_AND_DB_SPEC.md Normal file
View File

@@ -0,0 +1,159 @@
# Eventify Command Center - API & Database Specification
This document outlines the required API endpoints and Database schema to support the current features of the Eventify Command Center (Admin Panel).
---
## 🏗 Database Schema
### 1. User Management (RBAC)
**`admin_users`** (Internal Staff)
| Column | Type | Description |
| :--- | :--- | :--- |
| `id` | UUID | Primary Key |
| `email` | VARCHAR | Unique email |
| `password_hash` | VARCHAR | Hashed password |
| `full_name` | VARCHAR | Display name |
| `role_id` | UUID | FK to `roles` |
| `status` | ENUM | 'Active', 'Inactive' |
| `last_active_at` | TIMESTAMP | Last login time |
**`roles`**
| Column | Type | Description |
| :--- | :--- | :--- |
| `id` | UUID | Primary Key |
| `name` | VARCHAR | e.g. "Super Admin", "Content Moderator" |
| `description` | TEXT | |
| `is_system` | BOOLEAN | If true, cannot be deleted |
**`permissions`**
| Column | Type | Description |
| :--- | :--- | :--- |
| `id` | VARCHAR | Primary Key (e.g. `manage_partners`) |
| `name` | VARCHAR | Human readable name |
| `group` | VARCHAR | e.g. "Finance", "Users" |
**`role_permissions`** (Junction Table)
| Column | Type | Description |
| :--- | :--- | :--- |
| `role_id` | UUID | FK to `roles` |
| `permission_id` | VARCHAR | FK to `permissions` |
---
### 2. Partner Management
**`partners`** (Organizations)
| Column | Type | Description |
| :--- | :--- | :--- |
| `id` | UUID | Primary Key |
| `name` | VARCHAR | Business Name |
| `type` | ENUM | 'Venue', 'Promoter', 'Sponsor', 'Vendor' |
| `status` | ENUM | 'Active', 'Invited', 'Suspended' |
| `logo_url` | VARCHAR | |
| `verification_status` | ENUM | 'Pending', 'Verified', 'Rejected' |
| `total_revenue` | DECIMAL | Cache field for performance |
| `open_balance` | DECIMAL | Amount owed to/by partner |
| `joined_at` | TIMESTAMP | |
**`partner_contacts`**
| Column | Type | Description |
| :--- | :--- | :--- |
| `id` | UUID | Primary Key |
| `partner_id` | UUID | FK to `partners` |
| `name` | VARCHAR | |
| `email` | VARCHAR | |
| `phone` | VARCHAR | |
| `is_primary` | BOOLEAN | |
**`partner_documents`** (KYC)
| Column | Type | Description |
| :--- | :--- | :--- |
| `id` | UUID | Primary Key |
| `partner_id` | UUID | FK to `partners` |
| `type` | ENUM | 'Company_Reg', 'PAN', 'Cheque', 'Other' |
| `file_url` | VARCHAR | S3/Blob URL |
| `status` | ENUM | 'Pending', 'Verified', 'Rejected' |
| `uploaded_at` | TIMESTAMP | |
| `verified_at` | TIMESTAMP | |
---
### 3. End Users (B2C)
**`end_users`**
| Column | Type | Description |
| :--- | :--- | :--- |
| `id` | UUID | Primary Key |
| `email` | VARCHAR | |
| `phone` | VARCHAR | |
| `full_name` | VARCHAR | |
| `status` | ENUM | 'Active', 'Banned' |
| `total_spent` | DECIMAL | Lifetime value |
| `created_at` | TIMESTAMP | |
---
### 4. Operations & Logs
**`audit_logs`**
| Column | Type | Description |
| :--- | :--- | :--- |
| `id` | UUID | Primary Key |
| `actor_id` | UUID | FK to `admin_users` |
| `action` | VARCHAR | e.g. "APPROVED_KYC" |
| `target_resource` | VARCHAR | e.g. "partner_123" |
| `details` | JSONB | Metadata about changes |
| `created_at` | TIMESTAMP | |
**`notifications`**
| Column | Type | Description |
| :--- | :--- | :--- |
| `id` | UUID | Primary Key |
| `recipient_id` | UUID | FK to `admin_users` |
| `type` | ENUM | 'Critical', 'Info', 'Success' |
| `title` | VARCHAR | |
| `message` | TEXT | |
| `is_read` | BOOLEAN | |
| `created_at` | TIMESTAMP | |
---
## 🔌 API Endpoints
### Authentication
- `POST /api/v1/auth/login` - Admin login (returns JWT)
- `POST /api/v1/auth/logout` - Invalidate session
- `GET /api/v1/auth/me` - Get current admin profile & permissions
### Dashboard
- `GET /api/v1/dashboard/metrics` - Aggregate stats (revenue, active partners, etc.)
- `GET /api/v1/dashboard/revenue-chart` - Data for the main revenue graph
- `GET /api/v1/dashboard/activity` - Recent system activity feed
### Partner Management
- `GET /api/v1/partners` - List partners (Supports filtering by status, type, search)
- `POST /api/v1/partners` - Invite/Create new partner
- `GET /api/v1/partners/:id` - Get full partner profile
- `GET /api/v1/partners/:id/documents` - List KYC documents
- `PATCH /api/v1/partners/:id/status` - Suspend/Activate partner
- `POST /api/v1/partners/:id/kyc/review` - Approve/Reject specific documents
### User Management (Command Center)
- **Internal Team**
- `GET /api/v1/admin/users` - List internal staff
- `POST /api/v1/admin/users` - Create staff account
- `PATCH /api/v1/admin/users/:id/role` - Assign role
- `DELETE /api/v1/admin/users/:id` - Revoke access
- **Roles & Permissions**
- `GET /api/v1/admin/roles` - List available roles
- `PUT /api/v1/admin/roles/:id/permissions` - Update permission matrix for a role
- **End Users**
- `GET /api/v1/users` - List B2C users
- `POST /api/v1/users/:id/ban` - Ban a user
- `POST /api/v1/users/:id/reset-2fa` - Reset 2FA
### Financials (Placeholder)
- `GET /api/v1/financials/entries` - List ledger entries
- `POST /api/v1/financials/payouts` - Trigger batch payouts

45
package-lock.json generated
View File

@@ -46,6 +46,7 @@
"input-otp": "^1.4.2",
"lucide-react": "^0.462.0",
"next-themes": "^0.3.0",
"nuqs": "^2.8.8",
"react": "^18.3.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
@@ -2532,6 +2533,12 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
"license": "MIT"
},
"node_modules/@swc/core": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.2.tgz",
@@ -6207,6 +6214,43 @@
"node": ">=0.10.0"
}
},
"node_modules/nuqs": {
"version": "2.8.8",
"resolved": "https://registry.npmjs.org/nuqs/-/nuqs-2.8.8.tgz",
"integrity": "sha512-LF5sw9nWpHyPWzMMu9oho3r9C5DvkpmBIg4LQN78sexIzGaeRx8DWr0uy3YiFx5i2QGZN1Qqcb+OAtEVRa2bnA==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "1.0.0"
},
"funding": {
"url": "https://github.com/sponsors/franky47"
},
"peerDependencies": {
"@remix-run/react": ">=2",
"@tanstack/react-router": "^1",
"next": ">=14.2.0",
"react": ">=18.2.0 || ^19.0.0-0",
"react-router": "^5 || ^6 || ^7",
"react-router-dom": "^5 || ^6 || ^7"
},
"peerDependenciesMeta": {
"@remix-run/react": {
"optional": true
},
"@tanstack/react-router": {
"optional": true
},
"next": {
"optional": true
},
"react-router": {
"optional": true
},
"react-router-dom": {
"optional": true
}
}
},
"node_modules/nwsapi": {
"version": "2.2.23",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz",
@@ -6815,6 +6859,7 @@
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz",
"integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@remix-run/router": "1.23.0",
"react-router": "6.30.1"

View File

@@ -51,6 +51,7 @@
"input-otp": "^1.4.2",
"lucide-react": "^0.462.0",
"next-themes": "^0.3.0",
"nuqs": "^2.8.8",
"react": "^18.3.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",

View File

@@ -1,13 +1,12 @@
import { Search, MoreHorizontal, UserX, KeyRound, Eye } from "lucide-react";
import { useState, useMemo } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
Search,
Filter,
ArrowUpDown,
Check
} from "lucide-react";
import { useQueryState, parseAsString, parseAsInteger } from 'nuqs';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@@ -15,106 +14,181 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuCheckboxItem
} from "@/components/ui/dropdown-menu";
import { mockEndUsers } from "@/features/users/data/mockRbacData";
import { toast } from "sonner";
import { formatCurrency } from "@/data/mockData";
import { mockEndUsers, EndUser } from "@/features/users/data/mockRbacData";
import { UserCard } from "./UserCard";
import { UserDetailSheet } from "./UserDetailSheet";
export function UserBaseTab() {
const handleBanUser = (name: string) => {
toast.success(`User ${name} has been banned.`);
// --- State (URL Params) ---
const [searchQuery, setSearchQuery] = useQueryState('q', parseAsString.withDefault(''));
const [minSpent, setMinSpent] = useQueryState('minSpent', parseAsInteger);
const [statusFilter, setStatusFilter] = useQueryState('status', parseAsString.withDefault('all'));
// --- Local State ---
const [selectedUser, setSelectedUser] = useState<EndUser | null>(null);
const [isSheetOpen, setIsSheetOpen] = useState(false);
// --- Filtering Logic ---
const filteredUsers = useMemo(() => {
return mockEndUsers.filter(user => {
// Search
const searchLower = searchQuery.toLowerCase();
const matchesSearch =
user.name.toLowerCase().includes(searchLower) ||
user.email.toLowerCase().includes(searchLower) ||
user.phone.includes(searchLower);
if (!matchesSearch) return false;
// Min Spent
if (minSpent !== null && user.totalSpent < minSpent) return false;
// Status
if (statusFilter !== 'all' && user.status !== statusFilter) return false;
return true;
});
}, [searchQuery, minSpent, statusFilter]);
// --- Handlers ---
const handleCardClick = (user: EndUser) => {
setSelectedUser(user);
setIsSheetOpen(true);
};
const handleImpersonate = (name: string) => {
toast.info(`Impersonating ${name}... (Dev Mode Only)`);
// In a real app this would store a token and redirect
const clearFilters = () => {
setSearchQuery(null);
setMinSpent(null);
setStatusFilter(null);
};
const activeFiltersCount = [
searchQuery ? 1 : 0,
minSpent !== null ? 1 : 0,
statusFilter !== 'all' ? 1 : 0
].reduce((a, b) => a + b, 0);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-lg font-bold text-foreground">User Base</h3>
<Badge variant="outline" className="text-xs border-green-200 text-green-700 bg-green-50">B2C</Badge>
{/* --- Toolbar --- */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 bg-background/50 p-4 rounded-xl border border-border/50 shadow-sm backdrop-blur-sm">
<div className="flex items-center gap-3 w-full sm:w-auto">
<h3 className="text-lg font-bold text-foreground whitespace-nowrap">User Base</h3>
<Badge variant="outline" className="text-xs border-green-200 text-green-700 bg-green-50">
{filteredUsers.length} Users
</Badge>
</div>
<div className="flex gap-2">
<div className="relative w-72">
<div className="flex flex-col sm:flex-row gap-3 w-full sm:w-auto">
{/* Search */}
<div className="relative w-full sm:w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search emails, phone, IDs..." className="pl-9 h-9 bg-secondary/50 border-transparent focus:border-primary" />
<Input
placeholder="Search name, email..."
className="pl-9 h-9 bg-white/50 border-border/50 focus:bg-white transition-all"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value || null)}
/>
</div>
<Button variant="outline" size="sm" className="h-9">Export List</Button>
{/* Filter Builder */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9 gap-2 bg-white/50">
<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>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Filter Users</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="p-2">
<p className="text-xs font-medium text-muted-foreground mb-2">Status</p>
<div className="flex flex-wrap gap-1">
{['all', 'Active', 'Banned', 'Suspended'].map(status => (
<Badge
key={status}
variant={statusFilter === status ? 'default' : 'outline'}
className="cursor-pointer"
onClick={() => setStatusFilter(status === 'all' ? null : status)}
>
{status}
</Badge>
))}
</div>
</div>
<DropdownMenuSeparator />
<div className="p-2">
<p className="text-xs font-medium text-muted-foreground mb-2">Min. Spent</p>
<Input
type="number"
placeholder="e.g. 1000"
className="h-8 text-xs"
value={minSpent || ''}
onChange={e => setMinSpent(e.target.value ? parseInt(e.target.value) : null)}
/>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem
className="justify-center text-error font-medium cursor-pointer"
onClick={clearFilters}
>
Clear All Filters
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Sort (Placeholder for now) */}
<Button variant="ghost" size="sm" className="h-9 w-9 p-0">
<ArrowUpDown className="h-4 w-4 text-muted-foreground" />
</Button>
</div>
</div>
<div className="rounded-xl border border-border overflow-hidden bg-card">
<Table>
<TableHeader>
<TableRow className="bg-secondary/30 hover:bg-secondary/30">
<TableHead>User Details</TableHead>
<TableHead>Contact</TableHead>
<TableHead className="text-center">Bookings</TableHead>
<TableHead className="text-right">Total Spent</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mockEndUsers.map((user) => (
<TableRow key={user.id} className="hover:bg-secondary/10">
<TableCell>
<div>
<p className="font-medium text-foreground">{user.name}</p>
<p className="text-xs text-muted-foreground font-mono">ID: {user.id}</p>
</div>
</TableCell>
<TableCell>
<div className="text-sm">
<p>{user.email}</p>
<p className="text-muted-foreground">{user.phone}</p>
</div>
</TableCell>
<TableCell className="text-center">
<Badge variant="secondary">{user.bookingsCount}</Badge>
</TableCell>
<TableCell className="text-right font-medium">
{formatCurrency(user.totalSpent)}
</TableCell>
<TableCell>
{user.status === 'Active' ? (
<Badge variant="outline" className="border-success/30 text-success bg-success/5">Active</Badge>
) : (
<Badge variant="destructive">Banned</Badge>
)}
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-foreground">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleImpersonate(user.name)}>
<Eye className="mr-2 h-4 w-4" /> Impersonate
</DropdownMenuItem>
<DropdownMenuItem>
<KeyRound className="mr-2 h-4 w-4" /> Reset 2FA
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleBanUser(user.name)} className="text-error">
<UserX className="mr-2 h-4 w-4" /> Ban User
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* --- Grid View --- */}
{filteredUsers.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 animate-in fade-in-50 duration-500">
{filteredUsers.map(user => (
<UserCard
key={user.id}
user={user}
onClick={handleCardClick}
/>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-20 text-center border-2 border-dashed border-border/50 rounded-xl bg-secondary/10">
<Search className="h-10 w-10 text-muted-foreground/50 mb-4" />
<h3 className="text-lg font-semibold">No users found</h3>
<p className="text-muted-foreground max-w-sm mt-1">
Try adjusting your filters or search query to find who you're looking for.
</p>
<Button variant="link" onClick={clearFilters} className="mt-4">
Clear all filters
</Button>
</div>
)}
{/* --- Detail Sheet --- */}
<UserDetailSheet
user={selectedUser}
open={isSheetOpen}
onOpenChange={setIsSheetOpen}
/>
</div>
);
}

View File

@@ -0,0 +1,135 @@
import {
MoreHorizontal,
Mail,
Ban,
Tag,
UserCheck
} from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { EndUser } from "@/features/users/data/mockRbacData";
import { formatCurrency, formatRelativeTime } from "@/data/mockData";
import { toast } from "sonner";
interface UserCardProps {
user: EndUser;
onClick: (user: EndUser) => void;
}
export function UserCard({ user, onClick }: UserCardProps) {
const handleAction = (e: React.MouseEvent, action: string) => {
e.stopPropagation();
toast.info(`${action} action triggered for ${user.name}`);
};
const getTierColor = (tier: string) => {
switch (tier) {
case 'Gold': return 'bg-yellow-500/10 text-yellow-600 border-yellow-200';
case 'Silver': return 'bg-slate-300/20 text-slate-600 border-slate-300';
case 'Bronze': return 'bg-orange-700/10 text-orange-700 border-orange-200';
default: return 'bg-secondary text-muted-foreground';
}
};
return (
<div
onClick={() => onClick(user)}
className="group relative flex flex-col gap-4 rounded-xl border border-white/40 bg-white/20 p-5 shadow-neu-sm hover:shadow-neu transition-all duration-300 cursor-pointer backdrop-blur-md overflow-hidden"
>
{/* Hover Gradient Overlay */}
<div className="absolute inset-0 bg-gradient-to-br from-white/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
<div className="relative flex justify-between items-start">
<div className="flex gap-3">
<Avatar className="h-12 w-12 border-2 border-white shadow-sm">
<AvatarImage src={user.avatarUrl} />
<AvatarFallback className="bg-primary/10 text-primary font-bold">
{user.name.charAt(0)}
</AvatarFallback>
</Avatar>
<div>
<h4 className="font-bold text-foreground leading-tight group-hover:text-primary transition-colors">{user.name}</h4>
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className={`text-[10px] h-5 px-1.5 ${getTierColor(user.tier)}`}>
{user.tier}
</Badge>
<span className="text-xs text-muted-foreground">{user.status}</span>
</div>
</div>
</div>
<div className="z-10">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-white/40"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={(e) => handleAction(e, "Message")}>
<Mail className="mr-2 h-4 w-4" /> Message User
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => handleAction(e, "Add Tag")}>
<Tag className="mr-2 h-4 w-4" /> Add CRM Tag
</DropdownMenuItem>
<DropdownMenuSeparator />
{user.status === 'Banned' || user.status === 'Suspended' ? (
<DropdownMenuItem onClick={(e) => handleAction(e, "Activate")} className="text-success">
<UserCheck className="mr-2 h-4 w-4" /> Activate User
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={(e) => handleAction(e, "Blacklist")} className="text-error">
<Ban className="mr-2 h-4 w-4" /> Blacklist User
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="relative grid grid-cols-2 gap-2 py-2 border-t border-white/30 border-b">
<div className="text-center p-2 rounded-lg bg-white/30">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-semibold">Spent</p>
<p className="text-sm font-bold text-foreground">{formatCurrency(user.totalSpent)}</p>
</div>
<div className="text-center p-2 rounded-lg bg-white/30">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-semibold">Bookings</p>
<p className="text-sm font-bold text-foreground">{user.bookingsCount}</p>
</div>
</div>
<div className="relative flex flex-wrap gap-1.5 min-h-[1.5rem]">
{user.tags.slice(0, 3).map(tag => (
<span key={tag} className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-secondary/50 text-secondary-foreground border border-white/20">
{tag}
</span>
))}
{user.tags.length > 3 && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-secondary/30 text-muted-foreground">
+{user.tags.length - 3}
</span>
)}
</div>
<div className="relative flex items-center justify-between text-xs text-muted-foreground mt-auto pt-1">
<span>Joined {new Date(user.joinedAt).getFullYear()}</span>
<span>Active {user.lastLogin}</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,179 @@
import { useState } from "react";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from "@/components/ui/sheet";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
import {
Mail,
Phone,
Calendar,
MapPin,
CreditCard,
Ticket,
LogIn,
MessageSquare,
Save
} from "lucide-react";
import { EndUser, Interaction } from "@/features/users/data/mockRbacData";
import { formatCurrency } from "@/data/mockData";
import { toast } from "sonner";
interface UserDetailSheetProps {
user: EndUser | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function UserDetailSheet({ user, open, onOpenChange }: UserDetailSheetProps) {
const [notes, setNotes] = useState(user?.notes || "");
if (!user) return null;
const handleSaveNotes = () => {
toast.success("Notes saved successfully");
};
const getInteractionIcon = (type: Interaction['type']) => {
switch (type) {
case 'booking': return <Ticket className="h-4 w-4 text-blue-500" />;
case 'login': return <LogIn className="h-4 w-4 text-slate-500" />;
case 'support': return <MessageSquare className="h-4 w-4 text-orange-500" />;
default: return <Calendar className="h-4 w-4" />;
}
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-[400px] sm:w-[540px] overflow-y-auto p-0 border-l border-border/50 shadow-2xl">
{/* Header Section */}
<div className="relative bg-secondary/30 p-6 border-b border-border/50">
<div className="absolute top-4 right-4">
<Badge variant="outline" className="bg-background/50 backdrop-blur-sm">
{user.status}
</Badge>
</div>
<div className="flex flex-col items-center text-center gap-4">
<Avatar className="h-24 w-24 border-4 border-background shadow-lg">
<AvatarImage src={user.avatarUrl} />
<AvatarFallback className="text-2xl bg-primary/10 text-primary font-bold">
{user.name.charAt(0)}
</AvatarFallback>
</Avatar>
<div>
<h2 className="text-2xl font-bold tracking-tight">{user.name}</h2>
<p className="text-muted-foreground flex items-center justify-center gap-2 mt-1">
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
{user.email}
</p>
</div>
<div className="flex gap-2">
{user.tags.map(tag => (
<Badge key={tag} variant="secondary" className="px-3 py-1 bg-white/50 hover:bg-white/80 transition-colors">
{tag}
</Badge>
))}
</div>
<div className="grid grid-cols-3 gap-4 w-full mt-4">
<div className="bg-background/80 rounded-xl p-3 text-center shadow-sm">
<p className="text-xs text-muted-foreground font-semibold uppercase">Spent</p>
<p className="font-bold text-lg text-primary">{formatCurrency(user.totalSpent)}</p>
</div>
<div className="bg-background/80 rounded-xl p-3 text-center shadow-sm">
<p className="text-xs text-muted-foreground font-semibold uppercase">Bookings</p>
<p className="font-bold text-lg text-foreground">{user.bookingsCount}</p>
</div>
<div className="bg-background/80 rounded-xl p-3 text-center shadow-sm">
<p className="text-xs text-muted-foreground font-semibold uppercase">Tier</p>
<p className="font-bold text-lg text-orange-600">{user.tier}</p>
</div>
</div>
</div>
</div>
<div className="p-6 space-y-8">
{/* Contact Info */}
<div className="space-y-4">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Contact Details</h3>
<div className="grid gap-3">
<div className="flex items-center gap-3 text-sm p-3 rounded-lg bg-secondary/20">
<Mail className="h-4 w-4 text-muted-foreground" />
<span>{user.email}</span>
</div>
<div className="flex items-center gap-3 text-sm p-3 rounded-lg bg-secondary/20">
<Phone className="h-4 w-4 text-muted-foreground" />
<span>{user.phone}</span>
</div>
<div className="flex items-center gap-3 text-sm p-3 rounded-lg bg-secondary/20">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span>Joined {new Date(user.joinedAt).toLocaleDateString()}</span>
</div>
</div>
</div>
<Separator />
{/* Notes Section */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Private Notes</h3>
<Button size="sm" variant="ghost" onClick={handleSaveNotes} className="h-8 gap-2">
<Save className="h-3 w-3" /> Save
</Button>
</div>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Add internal notes about this user..."
className="min-h-[100px] bg-yellow-50/50 border-yellow-200/50 focus-visible:ring-yellow-400/30"
/>
</div>
<Separator />
{/* Timeline */}
<div className="space-y-4">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Activity Timeline</h3>
<div className="relative pl-6 space-y-6 before:absolute before:left-[11px] before:top-2 before:bottom-2 before:w-[2px] before:bg-border/50">
{user.interactions.length > 0 ? (
user.interactions.map((interaction) => (
<div key={interaction.id} className="relative">
<div className="absolute -left-[29px] top-1 h-6 w-6 rounded-full border-4 border-background bg-secondary flex items-center justify-center">
{getInteractionIcon(interaction.type)}
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="font-medium text-sm">{interaction.description}</p>
<span className="text-xs text-muted-foreground">{interaction.date}</span>
</div>
{interaction.status && (
<Badge variant="outline" className="w-fit text-[10px] capitalize">
{interaction.status}
</Badge>
)}
</div>
</div>
))
) : (
<p className="text-sm text-muted-foreground italic">No recent activity recorded.</p>
)}
</div>
</div>
</div>
</SheetContent>
</Sheet>
);
}

View File

@@ -82,6 +82,14 @@ export const mockPartnerUsers: PartnerUser[] = [
{ id: 'p3', name: 'Bob Promoter', email: 'bob@toptier.com', partnerName: 'TopTier Promoters', role: 'Partner Admin', isVerified: false, commissionOverride: 5.0, status: 'Active' },
];
export interface Interaction {
id: string;
type: 'booking' | 'login' | 'support';
description: string;
date: string;
status?: 'attended' | 'no-show' | 'resolved';
}
export interface EndUser {
id: string;
name: string;
@@ -90,11 +98,81 @@ export interface EndUser {
bookingsCount: number;
totalSpent: number;
lastLogin: string;
status: 'Active' | 'Banned';
status: 'Active' | 'Banned' | 'Suspended';
tier: 'Gold' | 'Silver' | 'Bronze';
tags: string[];
notes?: string;
interactions: Interaction[];
avatarUrl?: string;
joinedAt: string;
}
export const mockEndUsers: EndUser[] = [
{ id: 'u1', name: 'Alice Walker', email: 'alice@gmail.com', phone: '+91 9876543210', bookingsCount: 5, totalSpent: 12500, lastLogin: '1h ago', status: 'Active' },
{ id: 'u2', name: 'Charlie Brown', email: 'charlie@yahoo.com', phone: '+91 8765432109', bookingsCount: 1, totalSpent: 500, lastLogin: '3d ago', status: 'Active' },
{ id: 'u3', name: 'Dave Fraud', email: 'dave@suspicious.com', phone: '+91 7654321098', bookingsCount: 0, totalSpent: 0, lastLogin: 'Never', status: 'Banned' },
{
id: 'u1',
name: 'Alice Walker',
email: 'alice@gmail.com',
phone: '+91 9876543210',
bookingsCount: 5,
totalSpent: 12500,
lastLogin: '1h ago',
status: 'Active',
tier: 'Gold',
tags: ['VIP', 'Big Spender'],
notes: "Always prefers front row seats. Send birthday discount.",
joinedAt: '2024-01-15',
interactions: [
{ id: 'i1', type: 'booking', description: 'Tech Summit 2024', date: '2025-02-01', status: 'attended' },
{ id: 'i2', type: 'login', description: 'Logged in from iPhone', date: '2025-02-03' }
]
},
{
id: 'u2',
name: 'Charlie Brown',
email: 'charlie@yahoo.com',
phone: '+91 8765432109',
bookingsCount: 1,
totalSpent: 500,
lastLogin: '3d ago',
status: 'Active',
tier: 'Bronze',
tags: ['New User'],
joinedAt: '2024-12-10',
interactions: [
{ id: 'i3', type: 'booking', description: 'Comedy Night', date: '2025-01-20', status: 'no-show' }
]
},
{
id: 'u3',
name: 'Dave Fraud',
email: 'dave@suspicious.com',
phone: '+91 7654321098',
bookingsCount: 0,
totalSpent: 0,
lastLogin: 'Never',
status: 'Banned',
tier: 'Bronze',
tags: ['Risk'],
notes: "Multiple failed payment attempts using different cards.",
joinedAt: '2024-11-05',
interactions: []
},
{
id: 'u4',
name: 'Sarah Jenkins',
email: 'sarah.j@outlook.com',
phone: '+91 9988776655',
bookingsCount: 12,
totalSpent: 45000,
lastLogin: '5m ago',
status: 'Active',
tier: 'Gold',
tags: ['Influencer', 'Early Adopter'],
notes: "Verified tech influencer on Instagram. Comp tickets for TechConf.",
joinedAt: '2023-06-20',
interactions: [
{ id: 'i4', type: 'booking', description: 'Tech Summit 2024', date: '2025-02-01', status: 'attended' },
{ id: 'i5', type: 'support', description: 'Ticket refund request', date: '2024-12-15', status: 'resolved' }
]
}
];

View File

@@ -1,5 +1,10 @@
import { createRoot } from "react-dom/client";
import { NuqsAdapter } from 'nuqs/adapters/react-router';
import App from "./App.tsx";
import "./index.css";
createRoot(document.getElementById("root")!).render(<App />);
createRoot(document.getElementById("root")!).render(
<NuqsAdapter>
<App />
</NuqsAdapter>
);