Implement User CRM: Enhanced Cards, Filters (nuqs), and Details Sheet
This commit is contained in:
159
API_AND_DB_SPEC.md
Normal file
159
API_AND_DB_SPEC.md
Normal 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
45
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" />
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="h-9">Export List</Button>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<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">
|
||||
{/* Filter Builder */}
|
||||
<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 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">
|
||||
<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>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>Filter Users</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => handleBanUser(user.name)} className="text-error">
|
||||
<UserX className="mr-2 h-4 w-4" /> Ban User
|
||||
|
||||
<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>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* --- 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>
|
||||
);
|
||||
}
|
||||
|
||||
135
src/features/users/components/UserCard.tsx
Normal file
135
src/features/users/components/UserCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
179
src/features/users/components/UserDetailSheet.tsx
Normal file
179
src/features/users/components/UserDetailSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user