From c07ebd4ba854872cf9b47c59a2da85776ecf81c1 Mon Sep 17 00:00:00 2001 From: CycroftX Date: Wed, 4 Feb 2026 23:09:11 +0530 Subject: [PATCH] Implement User CRM: Enhanced Cards, Filters (nuqs), and Details Sheet --- API_AND_DB_SPEC.md | 159 +++++++++++ package-lock.json | 45 ++++ package.json | 1 + src/features/users/components/UserBaseTab.tsx | 254 +++++++++++------- src/features/users/components/UserCard.tsx | 135 ++++++++++ .../users/components/UserDetailSheet.tsx | 179 ++++++++++++ src/features/users/data/mockRbacData.ts | 86 +++++- src/main.tsx | 7 +- 8 files changed, 771 insertions(+), 95 deletions(-) create mode 100644 API_AND_DB_SPEC.md create mode 100644 src/features/users/components/UserCard.tsx create mode 100644 src/features/users/components/UserDetailSheet.tsx diff --git a/API_AND_DB_SPEC.md b/API_AND_DB_SPEC.md new file mode 100644 index 0000000..87a1af1 --- /dev/null +++ b/API_AND_DB_SPEC.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 967ad68..050c917 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index b5bda46..7331472 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/features/users/components/UserBaseTab.tsx b/src/features/users/components/UserBaseTab.tsx index 5d15335..36699e3 100644 --- a/src/features/users/components/UserBaseTab.tsx +++ b/src/features/users/components/UserBaseTab.tsx @@ -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(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 (
-
-
-

User Base

- B2C + {/* --- Toolbar --- */} +
+
+

User Base

+ + {filteredUsers.length} Users +
-
-
+
+ {/* Search */} +
- + setSearchQuery(e.target.value || null)} + />
- + + {/* Filter Builder */} + + + + + + Filter Users + + +
+

Status

+
+ {['all', 'Active', 'Banned', 'Suspended'].map(status => ( + setStatusFilter(status === 'all' ? null : status)} + > + {status} + + ))} +
+
+ + + +
+

Min. Spent

+ setMinSpent(e.target.value ? parseInt(e.target.value) : null)} + /> +
+ + + + Clear All Filters + +
+
+ + {/* Sort (Placeholder for now) */} +
-
- - - - User Details - Contact - Bookings - Total Spent - Status - Actions - - - - {mockEndUsers.map((user) => ( - - -
-

{user.name}

-

ID: {user.id}

-
-
- -
-

{user.email}

-

{user.phone}

-
-
- - {user.bookingsCount} - - - {formatCurrency(user.totalSpent)} - - - {user.status === 'Active' ? ( - Active - ) : ( - Banned - )} - - - - - - - - handleImpersonate(user.name)}> - Impersonate - - - Reset 2FA - - - handleBanUser(user.name)} className="text-error"> - Ban User - - - - -
- ))} -
-
-
+ {/* --- Grid View --- */} + {filteredUsers.length > 0 ? ( +
+ {filteredUsers.map(user => ( + + ))} +
+ ) : ( +
+ +

No users found

+

+ Try adjusting your filters or search query to find who you're looking for. +

+ +
+ )} + + {/* --- Detail Sheet --- */} +
); } diff --git a/src/features/users/components/UserCard.tsx b/src/features/users/components/UserCard.tsx new file mode 100644 index 0000000..7878b68 --- /dev/null +++ b/src/features/users/components/UserCard.tsx @@ -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 ( +
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 */} +
+ +
+
+ + + + {user.name.charAt(0)} + + +
+

{user.name}

+
+ + {user.tier} + + {user.status} +
+
+
+ +
+ + + + + + handleAction(e, "Message")}> + Message User + + handleAction(e, "Add Tag")}> + Add CRM Tag + + + {user.status === 'Banned' || user.status === 'Suspended' ? ( + handleAction(e, "Activate")} className="text-success"> + Activate User + + ) : ( + handleAction(e, "Blacklist")} className="text-error"> + Blacklist User + + )} + + +
+
+ +
+
+

Spent

+

{formatCurrency(user.totalSpent)}

+
+
+

Bookings

+

{user.bookingsCount}

+
+
+ +
+ {user.tags.slice(0, 3).map(tag => ( + + {tag} + + ))} + {user.tags.length > 3 && ( + + +{user.tags.length - 3} + + )} +
+ +
+ Joined {new Date(user.joinedAt).getFullYear()} + Active {user.lastLogin} +
+
+ ); +} diff --git a/src/features/users/components/UserDetailSheet.tsx b/src/features/users/components/UserDetailSheet.tsx new file mode 100644 index 0000000..2f1b8d6 --- /dev/null +++ b/src/features/users/components/UserDetailSheet.tsx @@ -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 ; + case 'login': return ; + case 'support': return ; + default: return ; + } + }; + + return ( + + + {/* Header Section */} +
+
+ + {user.status} + +
+ +
+ + + + {user.name.charAt(0)} + + + +
+

{user.name}

+

+ + {user.email} +

+
+ +
+ {user.tags.map(tag => ( + + {tag} + + ))} +
+ +
+
+

Spent

+

{formatCurrency(user.totalSpent)}

+
+
+

Bookings

+

{user.bookingsCount}

+
+
+

Tier

+

{user.tier}

+
+
+
+
+ +
+ {/* Contact Info */} +
+

Contact Details

+
+
+ + {user.email} +
+
+ + {user.phone} +
+
+ + Joined {new Date(user.joinedAt).toLocaleDateString()} +
+
+
+ + + + {/* Notes Section */} +
+
+

Private Notes

+ +
+