From 49770dfe73ff8fd91122651887a5e3d8f7905715 Mon Sep 17 00:00:00 2001 From: CycroftX Date: Wed, 11 Feb 2026 10:06:30 +0530 Subject: [PATCH] feat: Partner Command Center Module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extend types/partner.ts: riskScore, KYCDocument, PartnerEvent, RiskLevel - Extend mockPartnerData.ts: risk scores, 15 KYC docs, 9 partner events, 6th partner - Create lib/actions/partner-governance.ts: KYC verification, event approval, impersonation, 2FA/password reset, suspend/unsuspend - Rewrite PartnerDirectory.tsx: card grid → data table with stats, risk gauge, filter tabs - Rewrite PartnerProfile.tsx: tabs → 3-column layout (Identity | KYC Vault | Event Governance) - Create KYCVaultPanel.tsx: per-doc approve/reject with progress bar and auto-verification - Create EventApprovalQueue.tsx: pending events list with review dialog - Create ImpersonationDialog.tsx: audit-aware confirmation with token generation - Extend prisma/schema.prisma: PartnerProfile, PartnerDoc models, KYC/Event enums - Add partner governance permission scopes to staff.ts --- prisma/schema.prisma | 57 ++ src/components/ui/button.tsx | 2 +- src/data/mockPartnerData.ts | 98 ++- src/features/partners/PartnerDirectory.tsx | 395 +++++++---- src/features/partners/PartnerProfile.tsx | 636 +++++++++++------- .../components/EventApprovalQueue.tsx | 279 ++++++++ .../components/ImpersonationDialog.tsx | 122 ++++ .../partners/components/KYCVaultPanel.tsx | 223 ++++++ src/lib/actions/partner-governance.ts | 287 ++++++++ src/lib/types/staff.ts | 3 + src/types/partner.ts | 64 +- 11 files changed, 1767 insertions(+), 399 deletions(-) create mode 100644 src/features/partners/components/EventApprovalQueue.tsx create mode 100644 src/features/partners/components/ImpersonationDialog.tsx create mode 100644 src/features/partners/components/KYCVaultPanel.tsx create mode 100644 src/lib/actions/partner-governance.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dea7c50..dfc1a48 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -158,3 +158,60 @@ model CampaignAuditLog { createdAt DateTime @default(now()) } + +// ===== PARTNER GOVERNANCE ===== + +enum KYCStatus { + PENDING + VERIFIED + REJECTED +} + +enum KYCDocStatus { + PENDING + APPROVED + REJECTED +} + +enum PartnerEventStatus { + PENDING_REVIEW + LIVE + DRAFT + COMPLETED + CANCELLED + REJECTED +} + +model PartnerProfile { + id String @id @default(cuid()) + userId String @unique // FK to User table + verification KYCStatus @default(PENDING) + riskScore Int @default(0) + + documents PartnerDoc[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model PartnerDoc { + id String @id @default(cuid()) + partnerId String + partner PartnerProfile @relation(fields: [partnerId], references: [id]) + + type String // "PAN", "GST", "AADHAAR", "CANCELLED_CHEQUE", "BUSINESS_REG" + name String + url String + status KYCDocStatus @default(PENDING) + mandatory Boolean @default(true) + + adminNote String? + reviewedBy String? + reviewedAt DateTime? + + uploadedBy String + uploadedAt DateTime @default(now()) + + @@index([partnerId, status]) +} + diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index cdedd4f..027a653 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -32,7 +32,7 @@ const buttonVariants = cva( export interface ButtonProps extends React.ButtonHTMLAttributes, - VariantProps { + VariantProps { asChild?: boolean; } diff --git a/src/data/mockPartnerData.ts b/src/data/mockPartnerData.ts index 8641aa7..4b63931 100644 --- a/src/data/mockPartnerData.ts +++ b/src/data/mockPartnerData.ts @@ -1,5 +1,5 @@ -import { Partner, DealTerm, LedgerEntry, PartnerDocument } from '../types/partner'; -import { subDays, subMonths } from 'date-fns'; +import { Partner, DealTerm, LedgerEntry, PartnerDocument, KYCDocument, PartnerEvent } from '../types/partner'; +import { subDays, subMonths, subHours, addDays } from 'date-fns'; export const mockPartners: Partner[] = [ { @@ -23,6 +23,8 @@ export const mockPartners: Partner[] = [ }, tags: ['Premium', 'Indoor', 'Capacity: 5000'], joinedAt: subMonths(new Date(), 6).toISOString(), + verificationStatus: 'Verified', + riskScore: 12, }, { id: 'p2', @@ -44,6 +46,8 @@ export const mockPartners: Partner[] = [ }, tags: ['Influencer Network', 'Social Media'], joinedAt: subMonths(new Date(), 3).toISOString(), + verificationStatus: 'Verified', + riskScore: 45, }, { id: 'p3', @@ -66,12 +70,14 @@ export const mockPartners: Partner[] = [ tags: ['AV Equipment', 'Lighting'], notes: 'Suspended due to breach of contract on Event #402', joinedAt: subMonths(new Date(), 8).toISOString(), + verificationStatus: 'Rejected', + riskScore: 78, }, { id: 'p4', name: 'Global Sponsors Inc', type: 'Sponsor', - status: 'Invited', + status: 'Active', logo: 'https://ui-avatars.com/api/?name=Global+Sponsors&background=10B981&color=fff', primaryContact: { name: 'Jessica Pearson', @@ -79,15 +85,16 @@ export const mockPartners: Partner[] = [ role: 'Brand Director', }, metrics: { - activeDeals: 0, - totalRevenue: 0, + activeDeals: 3, + totalRevenue: 2200000, openBalance: 0, lastActivity: subDays(new Date(), 5).toISOString(), - eventsCount: 0, + eventsCount: 6, }, tags: ['Corporate', 'High Value'], joinedAt: subDays(new Date(), 5).toISOString(), verificationStatus: 'Verified', + riskScore: 8, }, { id: 'p5', @@ -98,7 +105,7 @@ export const mockPartners: Partner[] = [ primaryContact: { name: 'John Doe', email: 'john@newage.com', - role: 'Owner' + role: 'Owner', }, metrics: { activeDeals: 0, @@ -110,9 +117,76 @@ export const mockPartners: Partner[] = [ tags: ['New'], joinedAt: new Date().toISOString(), verificationStatus: 'Pending', - } + riskScore: 0, + }, + { + id: 'p6', + name: 'VibeCheck Events', + type: 'Promoter', + status: 'Active', + logo: 'https://ui-avatars.com/api/?name=Vibe+Check&background=8B5CF6&color=fff', + primaryContact: { + name: 'Priya Sharma', + email: 'priya@vibecheck.in', + phone: '+91 99887 66554', + role: 'Founder', + }, + metrics: { + activeDeals: 1, + totalRevenue: 320000, + openBalance: 18000, + lastActivity: subDays(new Date(), 1).toISOString(), + eventsCount: 4, + }, + tags: ['College Events', 'Music'], + joinedAt: subDays(new Date(), 3).toISOString(), + verificationStatus: 'Pending', + riskScore: 65, + }, ]; +// ── KYC Documents ─────────────────────────────────────────────────── +export const mockKYCDocuments: KYCDocument[] = [ + // Neon Arena (Verified) — all approved + { id: 'kyc-1', partnerId: 'p1', type: 'PAN', name: 'PAN Card - Neon Arena Pvt Ltd', url: '#', status: 'APPROVED', mandatory: true, reviewedBy: 'Admin', reviewedAt: subMonths(new Date(), 5).toISOString(), uploadedBy: 'Alex Rivera', uploadedAt: subMonths(new Date(), 6).toISOString() }, + { id: 'kyc-2', partnerId: 'p1', type: 'GST', name: 'GST Certificate', url: '#', status: 'APPROVED', mandatory: true, reviewedBy: 'Admin', reviewedAt: subMonths(new Date(), 5).toISOString(), uploadedBy: 'Alex Rivera', uploadedAt: subMonths(new Date(), 6).toISOString() }, + { id: 'kyc-3', partnerId: 'p1', type: 'CANCELLED_CHEQUE', name: 'Cancelled Cheque - HDFC', url: '#', status: 'APPROVED', mandatory: true, reviewedBy: 'Admin', reviewedAt: subMonths(new Date(), 5).toISOString(), uploadedBy: 'Alex Rivera', uploadedAt: subMonths(new Date(), 6).toISOString() }, + // TopTier (Verified) + { id: 'kyc-4', partnerId: 'p2', type: 'PAN', name: 'PAN Card - TopTier Marketing', url: '#', status: 'APPROVED', mandatory: true, reviewedBy: 'Admin', reviewedAt: subMonths(new Date(), 2).toISOString(), uploadedBy: 'Sarah Chen', uploadedAt: subMonths(new Date(), 3).toISOString() }, + { id: 'kyc-5', partnerId: 'p2', type: 'GST', name: 'GST Certificate', url: '#', status: 'APPROVED', mandatory: true, reviewedBy: 'Admin', reviewedAt: subMonths(new Date(), 2).toISOString(), uploadedBy: 'Sarah Chen', uploadedAt: subMonths(new Date(), 3).toISOString() }, + { id: 'kyc-6', partnerId: 'p2', type: 'CANCELLED_CHEQUE', name: 'Cancelled Cheque - ICICI', url: '#', status: 'APPROVED', mandatory: true, reviewedBy: 'Admin', reviewedAt: subMonths(new Date(), 2).toISOString(), uploadedBy: 'Sarah Chen', uploadedAt: subMonths(new Date(), 3).toISOString() }, + // TechFlow (Rejected) + { id: 'kyc-7', partnerId: 'p3', type: 'PAN', name: 'PAN Card - TechFlow', url: '#', status: 'APPROVED', mandatory: true, reviewedBy: 'Admin', reviewedAt: subMonths(new Date(), 7).toISOString(), uploadedBy: 'Mike Ross', uploadedAt: subMonths(new Date(), 8).toISOString() }, + { id: 'kyc-8', partnerId: 'p3', type: 'GST', name: 'GST Certificate', url: '#', status: 'REJECTED', mandatory: true, adminNote: 'GST number expired. Please re-upload a valid certificate.', reviewedBy: 'Admin', reviewedAt: subMonths(new Date(), 1).toISOString(), uploadedBy: 'Mike Ross', uploadedAt: subMonths(new Date(), 8).toISOString() }, + // New Age (Pending) + { id: 'kyc-9', partnerId: 'p5', type: 'PAN', name: 'PAN Card Copy', url: '#', status: 'PENDING', mandatory: true, uploadedBy: 'John Doe', uploadedAt: subDays(new Date(), 1).toISOString() }, + { id: 'kyc-10', partnerId: 'p5', type: 'GST', name: 'GST Registration', url: '#', status: 'PENDING', mandatory: true, uploadedBy: 'John Doe', uploadedAt: subDays(new Date(), 1).toISOString() }, + { id: 'kyc-11', partnerId: 'p5', type: 'CANCELLED_CHEQUE', name: 'Cancelled Cheque', url: '#', status: 'PENDING', mandatory: true, uploadedBy: 'John Doe', uploadedAt: subDays(new Date(), 1).toISOString() }, + { id: 'kyc-12', partnerId: 'p5', type: 'BUSINESS_REG', name: 'Company Registration', url: '#', status: 'PENDING', mandatory: false, uploadedBy: 'John Doe', uploadedAt: subDays(new Date(), 1).toISOString() }, + // VibeCheck (Pending — partial) + { id: 'kyc-13', partnerId: 'p6', type: 'PAN', name: 'PAN Card - Priya Sharma', url: '#', status: 'APPROVED', mandatory: true, reviewedBy: 'Admin', reviewedAt: subDays(new Date(), 2).toISOString(), uploadedBy: 'Priya Sharma', uploadedAt: subDays(new Date(), 3).toISOString() }, + { id: 'kyc-14', partnerId: 'p6', type: 'GST', name: 'GST Certificate', url: '#', status: 'PENDING', mandatory: true, uploadedBy: 'Priya Sharma', uploadedAt: subDays(new Date(), 3).toISOString() }, + { id: 'kyc-15', partnerId: 'p6', type: 'CANCELLED_CHEQUE', name: 'Cancelled Cheque - SBI', url: '#', status: 'PENDING', mandatory: true, uploadedBy: 'Priya Sharma', uploadedAt: subDays(new Date(), 3).toISOString() }, +]; + +// ── Partner Events ────────────────────────────────────────────────── +export const mockPartnerEvents: PartnerEvent[] = [ + // Neon Arena events + { id: 'evt-1', partnerId: 'p1', title: 'Neon Nights NYE 2026', date: addDays(new Date(), 30).toISOString(), time: '20:00', venue: 'Neon Arena - Main Hall', category: 'Music', ticketPrice: 2500, totalTickets: 5000, ticketsSold: 3200, revenue: 8000000, status: 'LIVE', submittedAt: subDays(new Date(), 15).toISOString(), createdAt: subDays(new Date(), 20).toISOString() }, + { id: 'evt-2', partnerId: 'p1', title: 'Tech Conference 2026', date: addDays(new Date(), 45).toISOString(), time: '09:00', venue: 'Neon Arena - Conference Wing', category: 'Technology', ticketPrice: 1500, totalTickets: 800, ticketsSold: 0, revenue: 0, status: 'PENDING_REVIEW', submittedAt: subHours(new Date(), 6).toISOString(), createdAt: subDays(new Date(), 3).toISOString() }, + { id: 'evt-3', partnerId: 'p1', title: 'Summer Music Fest', date: addDays(new Date(), 60).toISOString(), time: '16:00', venue: 'Neon Arena - Open Air', category: 'Music', ticketPrice: 1800, totalTickets: 10000, ticketsSold: 0, revenue: 0, status: 'DRAFT', submittedAt: subDays(new Date(), 1).toISOString(), createdAt: subDays(new Date(), 5).toISOString() }, + // TopTier events + { id: 'evt-4', partnerId: 'p2', title: 'Influencer Meetup Mumbai', date: addDays(new Date(), 10).toISOString(), time: '18:00', venue: 'The Grand Ballroom', category: 'Networking', ticketPrice: 500, totalTickets: 300, ticketsSold: 280, revenue: 140000, status: 'LIVE', submittedAt: subDays(new Date(), 20).toISOString(), createdAt: subDays(new Date(), 25).toISOString() }, + { id: 'evt-5', partnerId: 'p2', title: 'Creator Economy Summit', date: addDays(new Date(), 25).toISOString(), time: '10:00', venue: 'Convention Center Hall B', category: 'Business', ticketPrice: 3000, totalTickets: 500, ticketsSold: 0, revenue: 0, status: 'PENDING_REVIEW', submittedAt: subHours(new Date(), 12).toISOString(), createdAt: subDays(new Date(), 4).toISOString() }, + // VibeCheck events + { id: 'evt-6', partnerId: 'p6', title: 'College Beats Festival', date: addDays(new Date(), 15).toISOString(), time: '17:00', venue: 'University Grounds', category: 'Music', ticketPrice: 200, totalTickets: 2000, ticketsSold: 0, revenue: 0, status: 'PENDING_REVIEW', submittedAt: subHours(new Date(), 3).toISOString(), createdAt: subDays(new Date(), 2).toISOString() }, + { id: 'evt-7', partnerId: 'p6', title: 'Stand-Up Comedy Night', date: addDays(new Date(), 8).toISOString(), time: '20:00', venue: 'The Laughing Bar', category: 'Comedy', ticketPrice: 350, totalTickets: 150, ticketsSold: 120, revenue: 42000, status: 'LIVE', submittedAt: subDays(new Date(), 10).toISOString(), createdAt: subDays(new Date(), 12).toISOString() }, + // Completed / Cancelled + { id: 'evt-8', partnerId: 'p1', title: 'New Year Bash 2025', date: subMonths(new Date(), 2).toISOString(), time: '21:00', venue: 'Neon Arena', category: 'Music', ticketPrice: 2000, totalTickets: 5000, ticketsSold: 4800, revenue: 9600000, status: 'COMPLETED', submittedAt: subMonths(new Date(), 4).toISOString(), createdAt: subMonths(new Date(), 5).toISOString() }, + { id: 'evt-9', partnerId: 'p3', title: 'AV Tech Expo (Cancelled)', date: subDays(new Date(), 10).toISOString(), time: '10:00', venue: 'Exhibition Centre', category: 'Technology', ticketPrice: 0, totalTickets: 500, ticketsSold: 0, revenue: 0, status: 'CANCELLED', submittedAt: subMonths(new Date(), 2).toISOString(), createdAt: subMonths(new Date(), 3).toISOString() }, +]; + +// ── Legacy Deal Terms ─────────────────────────────────────────────── export const mockDealTerms: DealTerm[] = [ { id: 'dt1', @@ -140,9 +214,10 @@ export const mockDealTerms: DealTerm[] = [ effectiveFrom: subMonths(new Date(), 3).toISOString(), status: 'Active', version: 2, - } + }, ]; +// ── Legacy Ledger ─────────────────────────────────────────────────── export const mockLedger: LedgerEntry[] = [ { id: 'le1', @@ -176,9 +251,10 @@ export const mockLedger: LedgerEntry[] = [ currency: 'INR', createdAt: subDays(new Date(), 1).toISOString(), status: 'Pending', - } + }, ]; +// ── Legacy Documents ──────────────────────────────────────────────── export const mockDocuments: PartnerDocument[] = [ { id: 'doc1', @@ -230,5 +306,5 @@ export const mockDocuments: PartnerDocument[] = [ status: 'Pending', uploadedBy: 'John Doe', uploadedAt: subDays(new Date(), 1).toISOString(), - } + }, ]; diff --git a/src/features/partners/PartnerDirectory.tsx b/src/features/partners/PartnerDirectory.tsx index cb69cd9..4835e3d 100644 --- a/src/features/partners/PartnerDirectory.tsx +++ b/src/features/partners/PartnerDirectory.tsx @@ -1,160 +1,303 @@ import { useState, useMemo } from 'react'; -import { Search, Filter, Plus, Check } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; import { AppLayout } from '@/components/layout/AppLayout'; -import { PartnerCard } from './components/PartnerCard'; import { mockPartners } from '@/data/mockPartnerData'; -import { AddPartnerSheet } from './components/AddPartnerSheet'; -import { Input } from '@/components/ui/input'; +import { mockPartnerEvents } from '@/data/mockPartnerData'; +import { Partner, getRiskLevel } from '@/types/partner'; +import { StatusBadge, TypeBadge } from './components/PartnerBadges'; import { Button } from '@/components/ui/button'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; import { DropdownMenu, - DropdownMenuCheckboxItem, DropdownMenuContent, - DropdownMenuLabel, + DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, - DropdownMenuItem -} from "@/components/ui/dropdown-menu"; -import { cn } from '@/lib/utils'; // Assuming cn exists +} from '@/components/ui/dropdown-menu'; +import { + Search, + Plus, + MoreHorizontal, + Eye, + ShieldCheck, + Ban, + UserCheck, + AlertTriangle, + TrendingUp, + Users, + Clock, + CalendarPlus, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { subDays } from 'date-fns'; + +function RiskGauge({ score }: { score: number }) { + const level = getRiskLevel(score); + const color = + level === 'low' ? 'text-success' : + level === 'medium' ? 'text-warning' : + 'text-destructive'; + const bg = + level === 'low' ? 'bg-success/10' : + level === 'medium' ? 'bg-warning/10' : + 'bg-destructive/10'; + + return ( +
+
+ + {score} + +
+ ); +} + +function VerificationBadge({ status }: { status: Partner['verificationStatus'] }) { + if (status === 'Verified') return ( + + Verified + + ); + if (status === 'Rejected') return ( + + Rejected + + ); + return ( + + Pending + + ); +} export default function PartnerDirectory() { + const navigate = useNavigate(); const [searchQuery, setSearchQuery] = useState(''); - const [statusFilters, setStatusFilters] = useState([]); - const [activeTab, setActiveTab] = useState("all"); + const [activeTab, setActiveTab] = useState('all'); - const allStatuses = ['Active', 'Invited', 'Suspended']; + const oneWeekAgo = subDays(new Date(), 7); + + // Count pending events per partner + const pendingEventsMap = useMemo(() => { + const map: Record = {}; + mockPartnerEvents.filter(e => e.status === 'PENDING_REVIEW').forEach(e => { + map[e.partnerId] = (map[e.partnerId] || 0) + 1; + }); + return map; + }, []); const filteredPartners = useMemo(() => { - let result = mockPartners; + let partners = [...mockPartners]; - // 1. Filter by Tab (KYC status) - if (activeTab === 'pending_kyc') { - result = result.filter(p => p.verificationStatus === 'Pending'); - } - - // 2. Filter by Search - if (searchQuery) { - const lowerQuery = searchQuery.toLowerCase(); - result = result.filter(partner => - partner.name.toLowerCase().includes(lowerQuery) || - partner.type.toLowerCase().includes(lowerQuery) || - partner.primaryContact.name.toLowerCase().includes(lowerQuery) + // Search + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase(); + partners = partners.filter(p => + p.name.toLowerCase().includes(q) || + p.primaryContact.name.toLowerCase().includes(q) || + p.primaryContact.email.toLowerCase().includes(q) ); } - // 3. Filter by Status - if (statusFilters.length > 0) { - result = result.filter(p => statusFilters.includes(p.status)); + // Tabs + switch (activeTab) { + case 'pending_kyc': + partners = partners.filter(p => p.verificationStatus === 'Pending'); + break; + case 'high_risk': + partners = partners.filter(p => p.riskScore > 60); + break; + case 'new_this_week': + partners = partners.filter(p => new Date(p.joinedAt) >= oneWeekAgo); + break; } - return result; - }, [mockPartners, searchQuery, statusFilters, activeTab]); + return partners; + }, [searchQuery, activeTab, oneWeekAgo]); - const toggleStatusFilter = (status: string) => { - setStatusFilters(current => - current.includes(status) - ? current.filter(s => s !== status) - : [...current, status] - ); - }; + const stats = useMemo(() => ({ + total: mockPartners.length, + active: mockPartners.filter(p => p.status === 'Active').length, + pendingKYC: mockPartners.filter(p => p.verificationStatus === 'Pending').length, + highRisk: mockPartners.filter(p => p.riskScore > 60).length, + }), []); return ( -
-
-

Partner Management

-

Manage your relationships with venues, promoters, sponsors, and vendors.

+ {/* Stats Row */} +
+
+
+ Total Partners +
+

{stats.total}

- -
-
- - setSearchQuery(e.target.value)} - /> -
- -
- - - - - - Filter by Status - - {allStatuses.map(status => ( - toggleStatusFilter(status)} - > - {status} - - ))} - {statusFilters.length > 0 && ( - <> - - setStatusFilters([])} - className="justify-center text-error font-medium" - > - Clear Filters - - - )} - - - - - - +
+
+ Active
+

{stats.active}

- - - - All Partners - - Pending KYC - {mockPartners.filter(p => p.verificationStatus === 'Pending').length > 0 && ( - - {mockPartners.filter(p => p.verificationStatus === 'Pending').length} - - )} - - - - - {/* Render grid... handled below */} - - - {/* Render grid... handled below */} - - - - {filteredPartners.length > 0 ? ( -
- {filteredPartners.map(partner => ( - - ))} +
+
+ Pending KYC
- ) : ( -
-

No partners found

-

Try adjusting your search or filters

+

{stats.pendingKYC}

+
+
+
+ High Risk
- )} +

{stats.highRisk}

+
+ + {/* Toolbar */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+ +
+ + {/* Tabs + Table */} + + + + All {mockPartners.length} + + + Pending KYC {stats.pendingKYC} + + + High Risk {stats.highRisk} + + + New This Week + + + + {/* Shared table for all tabs */} +
+ + + + Partner + Verification + Active Events + Revenue + Risk Score + Status + + + + + {filteredPartners.length > 0 ? ( + filteredPartners.map(partner => ( + navigate(`/partners/${partner.id}`)} + > + +
+
+ {partner.logo ? ( + {partner.name} + ) : ( + {partner.name.substring(0, 2)} + )} +
+
+

{partner.name}

+

{partner.primaryContact.email}

+
+
+
+ + + + +
+ {partner.metrics.eventsCount} + {(pendingEventsMap[partner.id] || 0) > 0 && ( + + {pendingEventsMap[partner.id]} pending + + )} +
+
+ + ₹{partner.metrics.totalRevenue.toLocaleString()} + + +
+ +
+
+ + + + e.stopPropagation()}> + + + + + + navigate(`/partners/${partner.id}`)}> + View Details + + + View Events + + + {partner.status === 'Suspended' ? ( + + Revoke Suspension + + ) : ( + + Suspend Partner + + )} + + + +
+ )) + ) : ( + + + +

No partners found

+

Try adjusting your search or filters

+
+
+ )} +
+
+
+
); } diff --git a/src/features/partners/PartnerProfile.tsx b/src/features/partners/PartnerProfile.tsx index 7542af0..0070f03 100644 --- a/src/features/partners/PartnerProfile.tsx +++ b/src/features/partners/PartnerProfile.tsx @@ -1,277 +1,397 @@ -import { useParams } from 'react-router-dom'; +import { useParams, useNavigate } from 'react-router-dom'; import { AppLayout } from '@/components/layout/AppLayout'; -import { mockPartners, mockDealTerms, mockLedger, mockDocuments } from '@/data/mockPartnerData'; +import { mockPartners, mockDealTerms, mockLedger, mockKYCDocuments, mockPartnerEvents } from '@/data/mockPartnerData'; +import { getRiskLevel } from '@/types/partner'; import { StatusBadge, TypeBadge } from './components/PartnerBadges'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { KYCVaultPanel } from './components/KYCVaultPanel'; +import { EventApprovalQueue } from './components/EventApprovalQueue'; +import { ImpersonationDialog } from './components/ImpersonationDialog'; import { Button } from '@/components/ui/button'; -import { Calendar, Download, Edit, FileText, Mail, Phone, ExternalLink, Wallet } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@/components/ui/accordion'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; +import { + Mail, + Phone, + ArrowLeft, + LogIn, + KeyRound, + ShieldOff, + Ban, + UserCheck, + Calendar, + Wallet, + TrendingUp, + AlertTriangle, + ExternalLink, + FileSignature, + DollarSign, +} from 'lucide-react'; +import { toast } from 'sonner'; import { cn } from '@/lib/utils'; +import { + resetPartner2FA, + resetPartnerPassword, + suspendPartner, + unsuspendPartner, +} from '@/lib/actions/partner-governance'; +import { useState } from 'react'; export default function PartnerProfile() { const { id } = useParams<{ id: string }>(); - // In a real app, fetch data based on ID - const partner = mockPartners.find(p => p.id === id) || mockPartners[0]; - const dealTerms = mockDealTerms.filter(dt => dt.partnerId === partner.id); - const ledger = mockLedger.filter(l => l.partnerId === partner.id); - const documents = mockDocuments.filter(d => d.partnerId === partner.id); + const navigate = useNavigate(); + const partner = mockPartners.find(p => p.id === id); - if (!partner) return
Partner not found
; + const [partnerStatus, setPartnerStatus] = useState(partner?.status || 'Active'); + + if (!partner) { + return ( + +
+

Partner not found.

+ +
+
+ ); + } + + const kycDocs = mockKYCDocuments.filter(d => d.partnerId === partner.id); + const partnerEvents = mockPartnerEvents.filter(e => e.partnerId === partner.id); + const dealTerms = mockDealTerms.filter(d => d.partnerId === partner.id); + const ledger = mockLedger.filter(l => l.partnerId === partner.id); + const riskLevel = getRiskLevel(partner.riskScore); + + const handleReset2FA = async () => { + const result = await resetPartner2FA(partner.id); + if (result.success) toast.success(result.message); + else toast.error(result.message); + }; + + const handleResetPassword = async () => { + const result = await resetPartnerPassword(partner.id); + if (result.success) toast.success(result.message); + else toast.error(result.message); + }; + + const handleSuspend = async () => { + const result = await suspendPartner(partner.id, 'Suspended by admin from profile page'); + if (result.success) { + toast.success(result.message); + setPartnerStatus('Suspended'); + } else { + toast.error(result.message); + } + }; + + const handleUnsuspend = async () => { + const result = await unsuspendPartner(partner.id); + if (result.success) { + toast.success(result.message); + setPartnerStatus('Active'); + } else { + toast.error(result.message); + } + }; return ( -
- {/* Header Profile */} -
-
- -
-
- {partner.logo ? ( - {partner.name} - ) : ( - {partner.name.substring(0, 2)} - )} -
- -
-
-
-

- {partner.name} - -

-
- - Joined {new Date(partner.joinedAt).toLocaleDateString()} -
-
-
- - -
-
- -
-
-
- {partner.primaryContact.name.substring(0, 2)} -
-
-

{partner.primaryContact.name}

-

{partner.primaryContact.role}

-
-
-
- {partner.primaryContact.email} - {partner.primaryContact.phone && ( - {partner.primaryContact.phone} - )} -
-
+ {/* Header */} +
+ +
+
+ {partner.logo ? ( + {partner.name} + ) : ( + {partner.name.substring(0, 2)} + )} +
+
+

{partner.name}

+
+ +
- - {/* Quick Stats */} -
-
-
-

Total Revenue

-

₹{partner.metrics.totalRevenue.toLocaleString()}

-
-
- -
-
-
-
-

Open Balance

-

₹{partner.metrics.openBalance.toLocaleString()}

-
-
- -
-
-
-
-

Active Deals

-

{partner.metrics.activeDeals}

-
-
- -
-
-
-
-

Events

-

{partner.metrics.eventsCount}

-
-
- -
-
-
- - {/* Tabs Content */} -
- -
- - Overview - Assignments - Deal Terms - Financials - Documents - -
- -
- -
-
-

Partner Details

-
- Legal Name - {partner.companyDetails?.legalName || partner.name} - Tax ID - {partner.companyDetails?.taxId || '-'} - Website - {partner.companyDetails?.website || '-'} - Address - {partner.companyDetails?.address || '-'} -
-
-
-

Tags & Notes

-
- {partner.tags.map(tag => ( - {tag} - ))} -
-
- {partner.notes || "No notes added for this partner."} -
-
-
-
- - -
-

Ledger & Settlements

- -
-
- - - - - - - - - - - - {ledger.map(entry => ( - - - - - - - - ))} - -
DateDescriptionTypeAmountStatus
{new Date(entry.createdAt).toLocaleDateString()} -
{entry.description}
- {entry.referenceId &&
Ref: {entry.referenceId}
} -
{entry.type} - {entry.amount < 0 ? '-' : '+'}₹{Math.abs(entry.amount).toLocaleString()} - - {entry.status} -
- {ledger.length === 0 &&
No transactions found
} -
-
- - -
-

Contracts & Documents

- -
-
- {documents.map(doc => ( -
-
- -
-
-

{doc.name}

-

{doc.type} • {doc.status}

-

Uploaded {new Date(doc.uploadedAt).toLocaleDateString()}

-
- -
- ))} -
-
- - -
-
-

Active Deal Terms

- -
- {dealTerms.map(term => ( -
-
-
-

- {term.name} - v{term.version} -

-

Effective from {new Date(term.effectiveFrom).toLocaleDateString()}

-
- {term.status} -
- -
-
- Type - {term.type} -
-
- Parameters - - {term.type === 'RevenueShare' ? `${term.params.percentage}% Share` : - term.type === 'CommissionPerTicket' ? `₹${term.params.amount} per ticket` : 'Custom'} - -
-
-
- ))} -
-
- -
- -

No event assignments yet

-

Assign this partner to an upcoming event

- -
-
-
-
-
+ + {/* 3-Column Layout */} +
+ + {/* ── Column 1: Identity & Stats ───────────────────────────── */} +
+ {/* Contact Card */} +
+

Contact

+
+
+
+ {partner.primaryContact.name.substring(0, 2)} +
+
+

{partner.primaryContact.name}

+

{partner.primaryContact.role || 'Contact'}

+
+
+
+ {partner.primaryContact.email} +
+ {partner.primaryContact.phone && ( +
+ {partner.primaryContact.phone} +
+ )} +
+ Joined {new Date(partner.joinedAt).toLocaleDateString()} +
+
+
+ + {/* Quick Stats */} +
+

Stats

+
+
+

Revenue

+

₹{partner.metrics.totalRevenue.toLocaleString()}

+
+
+

Events

+

{partner.metrics.eventsCount}

+
+
+

Open Bal.

+

₹{partner.metrics.openBalance.toLocaleString()}

+
+
+

Risk

+

{partner.riskScore}

+
+
+
+ + {/* Admin Actions */} +
+

Admin Actions

+ + + + + + + + + + + + Reset Password + + This will send a password reset email to {partner.primaryContact.email}. This action is logged. + + + + Cancel + Send Reset Email + + + + + + + + + + + Reset Two-Factor Authentication + + This will remove {partner.name}'s 2FA setup. They will be required to re-enroll on their next login. This action is logged. + + + + Cancel + Reset 2FA + + + + + {partnerStatus === 'Suspended' ? ( + + ) : ( + + + + + + + Suspend Partner + + This will suspend {partner.name}'s account. They will be unable to access their dashboard or manage events. This action is logged. + + + + Cancel + + Suspend + + + + + )} +
+ + {/* Deal Terms & Finance Accordion */} + + + + + Deal Terms + {dealTerms.length} + + + + {dealTerms.length > 0 ? ( +
+ {dealTerms.map(dt => ( +
+
+

{dt.name}

+ + {dt.status} + +
+

+ {dt.type} • {dt.params.percentage ? `${dt.params.percentage}%` : `₹${dt.params.amount}`} +

+
+ ))} +
+ ) : ( +

No deals configured

+ )} +
+
+ + + + Finance Ledger + {ledger.length} + + + + {ledger.length > 0 ? ( +
+ {ledger.map(entry => ( +
+
+

{entry.description}

+

{new Date(entry.createdAt).toLocaleDateString()} • {entry.type}

+
+

= 0 ? 'text-success' : 'text-destructive')}> + {entry.amount >= 0 ? '+' : ''}₹{Math.abs(entry.amount).toLocaleString()} +

+
+ ))} +
+ ) : ( +

No ledger entries

+ )} +
+
+
+
+ + {/* ── Column 2: KYC Vault ──────────────────────────────────── */} +
+

+ KYC & Compliance +

+ +
+ + {/* ── Column 3: Event Governance ──────────────────────────── */} +
+

+ Event Governance +

+ +
+
+ + {/* Tags */} + {partner.tags && partner.tags.length > 0 && ( +
+ {partner.tags.map(tag => ( + {tag} + ))} +
+ )} + + {/* Notes */} + {partner.notes && ( +
+

+ Notes +

+

{partner.notes}

+
+ )} ); } - -function Plus({ className }: { className?: string }) { - return -} diff --git a/src/features/partners/components/EventApprovalQueue.tsx b/src/features/partners/components/EventApprovalQueue.tsx new file mode 100644 index 0000000..92d5e2c --- /dev/null +++ b/src/features/partners/components/EventApprovalQueue.tsx @@ -0,0 +1,279 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { PartnerEvent } from '@/types/partner'; +import { approvePartnerEvent, rejectPartnerEvent } from '@/lib/actions/partner-governance'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Textarea } from '@/components/ui/textarea'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { + Calendar, + MapPin, + Ticket, + CheckCircle2, + XCircle, + Clock, + Eye, + DollarSign, +} from 'lucide-react'; +import { toast } from 'sonner'; +import { cn } from '@/lib/utils'; + +interface EventApprovalQueueProps { + partnerId: string; + events: PartnerEvent[]; + onEventUpdated?: () => void; +} + +const EVENT_STATUS_STYLES: Record = { + PENDING_REVIEW: 'bg-warning/10 text-warning border-warning/20', + LIVE: 'bg-success/10 text-success border-success/20', + DRAFT: 'bg-muted text-muted-foreground border-border', + COMPLETED: 'bg-primary/10 text-primary border-primary/20', + CANCELLED: 'bg-destructive/10 text-destructive border-destructive/20', + REJECTED: 'bg-destructive/10 text-destructive border-destructive/20', +}; + +export function EventApprovalQueue({ partnerId, events, onEventUpdated }: EventApprovalQueueProps) { + const [eventList, setEventList] = useState(events); + const [reviewingEvent, setReviewingEvent] = useState(null); + const [rejectionReason, setRejectionReason] = useState(''); + const [processing, setProcessing] = useState(false); + + const pendingEvents = eventList.filter(e => e.status === 'PENDING_REVIEW'); + const otherEvents = eventList.filter(e => e.status !== 'PENDING_REVIEW'); + + const handleApprove = useCallback(async (eventId: string) => { + setProcessing(true); + try { + const result = await approvePartnerEvent(eventId); + if (result.success) { + toast.success(result.message); + setEventList(prev => + prev.map(e => e.id === eventId ? { ...e, status: 'LIVE' as const } : e) + ); + setReviewingEvent(null); + onEventUpdated?.(); + } else { + toast.error(result.message); + } + } catch { + toast.error('Failed to approve event.'); + } finally { + setProcessing(false); + } + }, [onEventUpdated]); + + const handleReject = useCallback(async (eventId: string) => { + if (!rejectionReason.trim()) { + toast.error('Please provide a reason for rejection.'); + return; + } + setProcessing(true); + try { + const result = await rejectPartnerEvent(eventId, rejectionReason); + if (result.success) { + toast.success(result.message); + setEventList(prev => + prev.map(e => e.id === eventId ? { ...e, status: 'REJECTED' as const, rejectionReason } : e) + ); + setReviewingEvent(null); + setRejectionReason(''); + onEventUpdated?.(); + } else { + toast.error(result.message); + } + } catch { + toast.error('Failed to reject event.'); + } finally { + setProcessing(false); + } + }, [rejectionReason, onEventUpdated]); + + const EventCard = ({ event, showActions }: { event: PartnerEvent; showActions: boolean }) => ( +
+
+
+

{event.title}

+
+ + + {new Date(event.date).toLocaleDateString()} + + + + {event.venue} + +
+
+ + {event.status.replace('_', ' ')} + +
+ +
+ + + {event.ticketsSold}/{event.totalTickets} sold + + {event.ticketPrice > 0 && ( + + + ₹{event.ticketPrice} + + )} + {event.category && ( + {event.category} + )} +
+ + {showActions && event.status === 'PENDING_REVIEW' && ( +
+ + +
+ )} + + {event.status === 'REJECTED' && event.rejectionReason && ( +

+ Rejected: {event.rejectionReason} +

+ )} +
+ ); + + return ( +
+ {/* Pending Section */} +
+
+

+ Pending Approval +

+ {pendingEvents.length > 0 && ( + + {pendingEvents.length} pending + + )} +
+ {pendingEvents.length > 0 ? ( +
+ {pendingEvents.map(event => ( + + ))} +
+ ) : ( +
+ + No events pending review +
+ )} +
+ + {/* Other Events */} + {otherEvents.length > 0 && ( +
+

+ All Events +

+
+ {otherEvents.map(event => ( + + ))} +
+
+ )} + + {/* Review Dialog */} + { if (!open) { setReviewingEvent(null); setRejectionReason(''); } }}> + + + Review Event + + Review and approve or decline this event submission. + + + + {reviewingEvent && ( +
+
+

{reviewingEvent.title}

+
+
+ + {new Date(reviewingEvent.date).toLocaleDateString()} + {reviewingEvent.time && ` at ${reviewingEvent.time}`} +
+
+ + {reviewingEvent.venue} +
+
+ + {reviewingEvent.totalTickets} tickets +
+
+ + ₹{reviewingEvent.ticketPrice} each +
+
+ {reviewingEvent.category && ( + {reviewingEvent.category} + )} +
+ +
+ +