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",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
|
"nuqs": "^2.8.8",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -2532,6 +2533,12 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/@swc/core": {
|
||||||
"version": "1.13.2",
|
"version": "1.13.2",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.2.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.2.tgz",
|
||||||
@@ -6207,6 +6214,43 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/nwsapi": {
|
||||||
"version": "2.2.23",
|
"version": "2.2.23",
|
||||||
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz",
|
||||||
"integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==",
|
"integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@remix-run/router": "1.23.0",
|
"@remix-run/router": "1.23.0",
|
||||||
"react-router": "6.30.1"
|
"react-router": "6.30.1"
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
|
"nuqs": "^2.8.8",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "^18.3.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 {
|
import {
|
||||||
Table,
|
Search,
|
||||||
TableBody,
|
Filter,
|
||||||
TableCell,
|
ArrowUpDown,
|
||||||
TableHead,
|
Check
|
||||||
TableHeader,
|
} from "lucide-react";
|
||||||
TableRow,
|
import { useQueryState, parseAsString, parseAsInteger } from 'nuqs';
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -15,106 +14,181 @@ import {
|
|||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuCheckboxItem
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { mockEndUsers } from "@/features/users/data/mockRbacData";
|
import { mockEndUsers, EndUser } from "@/features/users/data/mockRbacData";
|
||||||
import { toast } from "sonner";
|
import { UserCard } from "./UserCard";
|
||||||
import { formatCurrency } from "@/data/mockData";
|
import { UserDetailSheet } from "./UserDetailSheet";
|
||||||
|
|
||||||
export function UserBaseTab() {
|
export function UserBaseTab() {
|
||||||
const handleBanUser = (name: string) => {
|
// --- State (URL Params) ---
|
||||||
toast.success(`User ${name} has been banned.`);
|
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) => {
|
const clearFilters = () => {
|
||||||
toast.info(`Impersonating ${name}... (Dev Mode Only)`);
|
setSearchQuery(null);
|
||||||
// In a real app this would store a token and redirect
|
setMinSpent(null);
|
||||||
|
setStatusFilter(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const activeFiltersCount = [
|
||||||
|
searchQuery ? 1 : 0,
|
||||||
|
minSpent !== null ? 1 : 0,
|
||||||
|
statusFilter !== 'all' ? 1 : 0
|
||||||
|
].reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
{/* --- Toolbar --- */}
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
||||||
<h3 className="text-lg font-bold text-foreground">User Base</h3>
|
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||||
<Badge variant="outline" className="text-xs border-green-200 text-green-700 bg-green-50">B2C</Badge>
|
<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>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col sm:flex-row gap-3 w-full sm:w-auto">
|
||||||
<div className="relative w-72">
|
{/* 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" />
|
<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
|
||||||
</div>
|
placeholder="Search name, email..."
|
||||||
<Button variant="outline" size="sm" className="h-9">Export List</Button>
|
className="pl-9 h-9 bg-white/50 border-border/50 focus:bg-white transition-all"
|
||||||
</div>
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value || null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl border border-border overflow-hidden bg-card">
|
{/* Filter Builder */}
|
||||||
<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>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-foreground">
|
<Button variant="outline" size="sm" className="h-9 gap-2 bg-white/50">
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<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>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
<DropdownMenuItem onClick={() => handleImpersonate(user.name)}>
|
<DropdownMenuLabel>Filter Users</DropdownMenuLabel>
|
||||||
<Eye className="mr-2 h-4 w-4" /> Impersonate
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<KeyRound className="mr-2 h-4 w-4" /> Reset 2FA
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
<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>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
{/* Sort (Placeholder for now) */}
|
||||||
))}
|
<Button variant="ghost" size="sm" className="h-9 w-9 p-0">
|
||||||
</TableBody>
|
<ArrowUpDown className="h-4 w-4 text-muted-foreground" />
|
||||||
</Table>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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' },
|
{ 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 {
|
export interface EndUser {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -90,11 +98,81 @@ export interface EndUser {
|
|||||||
bookingsCount: number;
|
bookingsCount: number;
|
||||||
totalSpent: number;
|
totalSpent: number;
|
||||||
lastLogin: string;
|
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[] = [
|
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: 'u1',
|
||||||
{ id: 'u3', name: 'Dave Fraud', email: 'dave@suspicious.com', phone: '+91 7654321098', bookingsCount: 0, totalSpent: 0, lastLogin: 'Never', status: 'Banned' },
|
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 { createRoot } from "react-dom/client";
|
||||||
|
import { NuqsAdapter } from 'nuqs/adapters/react-router';
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(<App />);
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<NuqsAdapter>
|
||||||
|
<App />
|
||||||
|
</NuqsAdapter>
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user