feat: Partner Command Center Module

- 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
This commit is contained in:
CycroftX
2026-02-11 10:06:30 +05:30
parent 04e2db6571
commit 49770dfe73
11 changed files with 1767 additions and 399 deletions

View File

@@ -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])
}

View File

@@ -32,7 +32,7 @@ const buttonVariants = cva(
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}

View File

@@ -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(),
}
},
];

View File

@@ -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 (
<div className="flex items-center gap-2">
<div className={cn('h-2 w-2 rounded-full', color.replace('text-', 'bg-'))} />
<span className={cn('text-xs font-semibold px-1.5 py-0.5 rounded', bg, color)}>
{score}
</span>
</div>
);
}
function VerificationBadge({ status }: { status: Partner['verificationStatus'] }) {
if (status === 'Verified') return (
<Badge variant="outline" className="bg-success/10 text-success border-success/20 gap-1 text-xs">
<ShieldCheck className="h-3 w-3" /> Verified
</Badge>
);
if (status === 'Rejected') return (
<Badge variant="outline" className="bg-destructive/10 text-destructive border-destructive/20 gap-1 text-xs">
<Ban className="h-3 w-3" /> Rejected
</Badge>
);
return (
<Badge variant="outline" className="bg-warning/10 text-warning border-warning/20 gap-1 text-xs">
<Clock className="h-3 w-3" /> Pending
</Badge>
);
}
export default function PartnerDirectory() {
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState('');
const [statusFilters, setStatusFilters] = useState<string[]>([]);
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<string, number> = {};
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 (
<AppLayout title="Partners">
<div className="p-6 max-w-[1700px] mx-auto space-y-8">
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight">Partner Management</h1>
<p className="text-muted-foreground">Manage your relationships with venues, promoters, sponsors, and vendors.</p>
{/* Stats Row */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="neu-card p-4">
<div className="flex items-center gap-2 text-muted-foreground text-xs mb-1">
<Users className="h-3.5 w-3.5" /> Total Partners
</div>
<p className="text-2xl font-bold">{stats.total}</p>
</div>
<div className="flex flex-col sm:flex-row gap-4 justify-between items-center">
<div className="relative w-full sm:w-96">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search partners..."
className="pl-10 bg-secondary border-border/50 focus:border-accent"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="flex gap-2 w-full sm:w-auto">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className={cn("gap-2", statusFilters.length > 0 && "text-accent border-accent")}>
<Filter className="h-4 w-4" />
Filter {statusFilters.length > 0 && `(${statusFilters.length})`}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Filter by Status</DropdownMenuLabel>
<DropdownMenuSeparator />
{allStatuses.map(status => (
<DropdownMenuCheckboxItem
key={status}
checked={statusFilters.includes(status)}
onCheckedChange={() => toggleStatusFilter(status)}
>
{status}
</DropdownMenuCheckboxItem>
))}
{statusFilters.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => setStatusFilters([])}
className="justify-center text-error font-medium"
>
Clear Filters
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<AddPartnerSheet>
<Button className="gap-2 bg-accent text-white hover:bg-accent/90 shadow-lg shadow-accent/20">
<Plus className="h-4 w-4" />
Add Partner
</Button>
</AddPartnerSheet>
<div className="neu-card p-4">
<div className="flex items-center gap-2 text-muted-foreground text-xs mb-1">
<UserCheck className="h-3.5 w-3.5" /> Active
</div>
<p className="text-2xl font-bold text-success">{stats.active}</p>
</div>
<Tabs defaultValue="all" value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full max-w-[400px] grid-cols-2">
<TabsTrigger value="all">All Partners</TabsTrigger>
<TabsTrigger value="pending_kyc">
Pending KYC
{mockPartners.filter(p => p.verificationStatus === 'Pending').length > 0 && (
<span className="ml-2 bg-warning/20 text-warning px-1.5 py-0.5 rounded-full text-[10px]">
{mockPartners.filter(p => p.verificationStatus === 'Pending').length}
</span>
)}
</TabsTrigger>
</TabsList>
<TabsContent value="all" className="mt-6">
{/* Render grid... handled below */}
</TabsContent>
<TabsContent value="pending_kyc" className="mt-6">
{/* Render grid... handled below */}
</TabsContent>
</Tabs>
{filteredPartners.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filteredPartners.map(partner => (
<PartnerCard key={partner.id} partner={partner} />
))}
<div className="neu-card p-4">
<div className="flex items-center gap-2 text-muted-foreground text-xs mb-1">
<Clock className="h-3.5 w-3.5" /> Pending KYC
</div>
) : (
<div className="text-center py-20 bg-card/20 rounded-xl border border-dashed border-border/50">
<h3 className="text-lg font-medium text-foreground">No partners found</h3>
<p className="text-muted-foreground mt-2">Try adjusting your search or filters</p>
<p className="text-2xl font-bold text-warning">{stats.pendingKYC}</p>
</div>
<div className="neu-card p-4">
<div className="flex items-center gap-2 text-muted-foreground text-xs mb-1">
<AlertTriangle className="h-3.5 w-3.5" /> High Risk
</div>
)}
<p className="text-2xl font-bold text-destructive">{stats.highRisk}</p>
</div>
</div>
{/* Toolbar */}
<div className="flex flex-col sm:flex-row gap-3 mb-6">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by name, email, or contact..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<Button className="gap-2 shrink-0">
<Plus className="h-4 w-4" /> Add Partner
</Button>
</div>
{/* Tabs + Table */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="mb-4">
<TabsTrigger value="all" className="gap-1.5">
All <Badge variant="secondary" className="text-[10px] h-5 px-1.5 ml-1">{mockPartners.length}</Badge>
</TabsTrigger>
<TabsTrigger value="pending_kyc" className="gap-1.5">
Pending KYC <Badge variant="secondary" className="text-[10px] h-5 px-1.5 ml-1 bg-warning/10 text-warning">{stats.pendingKYC}</Badge>
</TabsTrigger>
<TabsTrigger value="high_risk" className="gap-1.5">
High Risk <Badge variant="secondary" className="text-[10px] h-5 px-1.5 ml-1 bg-destructive/10 text-destructive">{stats.highRisk}</Badge>
</TabsTrigger>
<TabsTrigger value="new_this_week" className="gap-1.5">
<CalendarPlus className="h-3.5 w-3.5" /> New This Week
</TabsTrigger>
</TabsList>
{/* Shared table for all tabs */}
<div className="neu-card overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[250px]">Partner</TableHead>
<TableHead>Verification</TableHead>
<TableHead className="text-center">Active Events</TableHead>
<TableHead className="text-right">Revenue</TableHead>
<TableHead className="text-center">Risk Score</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[50px]" />
</TableRow>
</TableHeader>
<TableBody>
{filteredPartners.length > 0 ? (
filteredPartners.map(partner => (
<TableRow
key={partner.id}
className="cursor-pointer hover:bg-secondary/30"
onClick={() => navigate(`/partners/${partner.id}`)}
>
<TableCell>
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-lg bg-secondary flex items-center justify-center overflow-hidden border border-border/50 shrink-0">
{partner.logo ? (
<img src={partner.logo} alt={partner.name} className="h-full w-full object-cover" />
) : (
<span className="text-xs font-bold text-muted-foreground">{partner.name.substring(0, 2)}</span>
)}
</div>
<div>
<p className="font-medium text-sm">{partner.name}</p>
<p className="text-xs text-muted-foreground">{partner.primaryContact.email}</p>
</div>
</div>
</TableCell>
<TableCell>
<VerificationBadge status={partner.verificationStatus} />
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
<span className="font-medium">{partner.metrics.eventsCount}</span>
{(pendingEventsMap[partner.id] || 0) > 0 && (
<Badge variant="outline" className="bg-warning/10 text-warning border-warning/20 text-[9px] h-4 px-1">
{pendingEventsMap[partner.id]} pending
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-right font-medium">
{partner.metrics.totalRevenue.toLocaleString()}
</TableCell>
<TableCell>
<div className="flex justify-center">
<RiskGauge score={partner.riskScore} />
</div>
</TableCell>
<TableCell>
<StatusBadge status={partner.status} />
</TableCell>
<TableCell onClick={e => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate(`/partners/${partner.id}`)}>
<Eye className="h-4 w-4 mr-2" /> View Details
</DropdownMenuItem>
<DropdownMenuItem>
<TrendingUp className="h-4 w-4 mr-2" /> View Events
</DropdownMenuItem>
<DropdownMenuSeparator />
{partner.status === 'Suspended' ? (
<DropdownMenuItem className="text-success">
<UserCheck className="h-4 w-4 mr-2" /> Revoke Suspension
</DropdownMenuItem>
) : (
<DropdownMenuItem className="text-destructive">
<Ban className="h-4 w-4 mr-2" /> Suspend Partner
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={7} className="text-center py-12">
<Users className="h-10 w-10 mx-auto mb-3 text-muted-foreground/30" />
<p className="text-muted-foreground text-sm">No partners found</p>
<p className="text-muted-foreground text-xs mt-1">Try adjusting your search or filters</p>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</Tabs>
</AppLayout>
);
}

View File

@@ -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 <div>Partner not found</div>;
const [partnerStatus, setPartnerStatus] = useState(partner?.status || 'Active');
if (!partner) {
return (
<AppLayout title="Partner Not Found">
<div className="flex flex-col items-center justify-center py-20">
<p className="text-lg text-muted-foreground mb-4">Partner not found.</p>
<Button onClick={() => navigate('/partners')}>
<ArrowLeft className="h-4 w-4 mr-2" /> Back to Partners
</Button>
</div>
</AppLayout>
);
}
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 (
<AppLayout title={partner.name}>
<div className="p-6 max-w-[1600px] mx-auto space-y-6">
{/* Header Profile */}
<div className="relative overflow-hidden rounded-2xl bg-card border border-border/50 shadow-lg group">
<div className="absolute inset-0 bg-gradient-to-r from-accent/5 to-transparent opacity-50" />
<div className="relative p-8 flex flex-col md:flex-row gap-8 items-start">
<div className="h-28 w-28 rounded-2xl bg-secondary flex items-center justify-center overflow-hidden border-2 border-border shadow-2xl">
{partner.logo ? (
<img src={partner.logo} alt={partner.name} className="h-full w-full object-cover" />
) : (
<span className="text-3xl font-bold text-muted-foreground">{partner.name.substring(0, 2)}</span>
)}
</div>
<div className="flex-1 space-y-4">
<div className="flex justify-between items-start">
<div>
<h1 className="text-3xl font-bold tracking-tight text-foreground flex items-center gap-3">
{partner.name}
<StatusBadge status={partner.status} />
</h1>
<div className="flex items-center gap-4 mt-2 text-muted-foreground">
<TypeBadge type={partner.type} />
<span className="flex items-center gap-1 text-sm"><Calendar className="h-4 w-4" /> Joined {new Date(partner.joinedAt).toLocaleDateString()}</span>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" className="gap-2"><Edit className="h-4 w-4" /> Edit Profile</Button>
<Button className="bg-accent text-white gap-2"><Wallet className="h-4 w-4" /> New Settlement</Button>
</div>
</div>
<div className="flex flex-wrap gap-6 pt-4 border-t border-border/30">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-full bg-secondary/50 flex items-center justify-center text-primary">
<span className="text-xs font-bold">{partner.primaryContact.name.substring(0, 2)}</span>
</div>
<div>
<p className="text-sm font-medium">{partner.primaryContact.name}</p>
<p className="text-xs text-muted-foreground">{partner.primaryContact.role}</p>
</div>
</div>
<div className="flex items-center gap-4 text-sm text-foreground/80 bg-secondary/30 px-4 py-2 rounded-lg border border-border/30">
<a href={`mailto:${partner.primaryContact.email}`} className="flex items-center gap-2 hover:text-accent"><Mail className="h-4 w-4" /> {partner.primaryContact.email}</a>
{partner.primaryContact.phone && (
<span className="flex items-center gap-2 border-l border-border pl-4"><Phone className="h-4 w-4" /> {partner.primaryContact.phone}</span>
)}
</div>
</div>
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<Button variant="ghost" size="icon" onClick={() => navigate('/partners')} className="shrink-0">
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="h-12 w-12 rounded-xl bg-secondary flex items-center justify-center overflow-hidden border border-border/50 shrink-0">
{partner.logo ? (
<img src={partner.logo} alt={partner.name} className="h-full w-full object-cover" />
) : (
<span className="text-lg font-bold text-muted-foreground">{partner.name.substring(0, 2)}</span>
)}
</div>
<div className="min-w-0">
<h1 className="text-xl font-bold truncate">{partner.name}</h1>
<div className="flex items-center gap-2 mt-0.5">
<TypeBadge type={partner.type} />
<StatusBadge status={partnerStatus as any} />
</div>
</div>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="neu-card p-4 flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Total Revenue</p>
<p className="text-2xl font-bold mt-1">{partner.metrics.totalRevenue.toLocaleString()}</p>
</div>
<div className="h-10 w-10 rounded-full bg-success/10 flex items-center justify-center text-success">
<Wallet className="h-5 w-5" />
</div>
</div>
<div className="neu-card p-4 flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Open Balance</p>
<p className="text-2xl font-bold mt-1">{partner.metrics.openBalance.toLocaleString()}</p>
</div>
<div className="h-10 w-10 rounded-full bg-warning/10 flex items-center justify-center text-warning">
<Wallet className="h-5 w-5" />
</div>
</div>
<div className="neu-card p-4 flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Active Deals</p>
<p className="text-2xl font-bold mt-1">{partner.metrics.activeDeals}</p>
</div>
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<FileText className="h-5 w-5" />
</div>
</div>
<div className="neu-card p-4 flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Events</p>
<p className="text-2xl font-bold mt-1">{partner.metrics.eventsCount}</p>
</div>
<div className="h-10 w-10 rounded-full bg-accent/10 flex items-center justify-center text-accent">
<Calendar className="h-5 w-5" />
</div>
</div>
</div>
{/* Tabs Content */}
<div className="neu-card min-h-[500px]">
<Tabs defaultValue="overview" className="w-full">
<div className="border-b border-border/40 px-6 pt-4">
<TabsList className="bg-transparent h-auto p-0 gap-6">
<TabsTrigger value="overview" className="tab-trigger pb-4 rounded-none data-[state=active]:border-b-2 data-[state=active]:border-accent data-[state=active]:bg-transparent">Overview</TabsTrigger>
<TabsTrigger value="assignments" className="tab-trigger pb-4 rounded-none data-[state=active]:border-b-2 data-[state=active]:border-accent data-[state=active]:bg-transparent">Assignments</TabsTrigger>
<TabsTrigger value="terms" className="tab-trigger pb-4 rounded-none data-[state=active]:border-b-2 data-[state=active]:border-accent data-[state=active]:bg-transparent">Deal Terms</TabsTrigger>
<TabsTrigger value="finance" className="tab-trigger pb-4 rounded-none data-[state=active]:border-b-2 data-[state=active]:border-accent data-[state=active]:bg-transparent">Financials</TabsTrigger>
<TabsTrigger value="docs" className="tab-trigger pb-4 rounded-none data-[state=active]:border-b-2 data-[state=active]:border-accent data-[state=active]:bg-transparent">Documents</TabsTrigger>
</TabsList>
</div>
<div className="p-6">
<TabsContent value="overview" className="space-y-6">
<div className="grid grid-cols-2 gap-8">
<div className="space-y-4">
<h3 className="font-semibold text-lg">Partner Details</h3>
<div className="grid grid-cols-2 gap-y-4 text-sm">
<span className="text-muted-foreground">Legal Name</span>
<span>{partner.companyDetails?.legalName || partner.name}</span>
<span className="text-muted-foreground">Tax ID</span>
<span>{partner.companyDetails?.taxId || '-'}</span>
<span className="text-muted-foreground">Website</span>
<a href={partner.companyDetails?.website} target="_blank" className="text-accent hover:underline flex items-center gap-1">{partner.companyDetails?.website || '-'} <ExternalLink className="h-3 w-3" /></a>
<span className="text-muted-foreground">Address</span>
<span>{partner.companyDetails?.address || '-'}</span>
</div>
</div>
<div className="space-y-4">
<h3 className="font-semibold text-lg">Tags & Notes</h3>
<div className="flex flex-wrap gap-2">
{partner.tags.map(tag => (
<Badge key={tag} variant="secondary" className="px-3 py-1">{tag}</Badge>
))}
</div>
<div className="p-4 bg-secondary/30 rounded-lg text-sm text-balance">
{partner.notes || "No notes added for this partner."}
</div>
</div>
</div>
</TabsContent>
<TabsContent value="finance">
<div className="flex justify-between items-center mb-6">
<h3 className="font-semibold text-lg">Ledger & Settlements</h3>
<Button variant="outline" size="sm" className="gap-2"><Download className="h-4 w-4" /> Export CSV</Button>
</div>
<div className="border border-border/50 rounded-lg overflow-hidden">
<table className="w-full text-sm text-left">
<thead className="bg-secondary/50 text-muted-foreground">
<tr>
<th className="p-3">Date</th>
<th className="p-3">Description</th>
<th className="p-3">Type</th>
<th className="p-3 text-right">Amount</th>
<th className="p-3 text-center">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-border/30">
{ledger.map(entry => (
<tr key={entry.id} className="hover:bg-accent/5">
<td className="p-3">{new Date(entry.createdAt).toLocaleDateString()}</td>
<td className="p-3">
<div className="font-medium">{entry.description}</div>
{entry.referenceId && <div className="text-xs text-muted-foreground">Ref: {entry.referenceId}</div>}
</td>
<td className="p-3"><Badge variant="outline">{entry.type}</Badge></td>
<td className={cn("p-3 text-right font-medium", entry.amount < 0 ? "text-error" : "text-success")}>
{entry.amount < 0 ? '-' : '+'}{Math.abs(entry.amount).toLocaleString()}
</td>
<td className="p-3 text-center">
<span className={cn("text-xs px-2 py-1 rounded-full border",
entry.status === 'Cleared' ? 'bg-success/10 border-success/20 text-success' :
entry.status === 'Pending' ? 'bg-warning/10 border-warning/20 text-warning' : 'bg-muted border-border'
)}>{entry.status}</span>
</td>
</tr>
))}
</tbody>
</table>
{ledger.length === 0 && <div className="p-8 text-center text-muted-foreground">No transactions found</div>}
</div>
</TabsContent>
<TabsContent value="docs">
<div className="flex justify-between items-center mb-6">
<h3 className="font-semibold text-lg">Contracts & Documents</h3>
<Button variant="outline" size="sm" className="gap-2"><Plus className="h-4 w-4" /> Upload Document</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{documents.map(doc => (
<div key={doc.id} className="p-4 border border-border/30 rounded-lg bg-card/50 flex items-start gap-3 hover:border-accent/40 transition-colors">
<div className="h-10 w-10 bg-secondary rounded-lg flex items-center justify-center text-muted-foreground">
<FileText className="h-5 w-5" />
</div>
<div className="flex-1 overflow-hidden">
<p className="font-medium truncate">{doc.name}</p>
<p className="text-xs text-muted-foreground capitalize">{doc.type} {doc.status}</p>
<p className="text-xs text-muted-foreground mt-1">Uploaded {new Date(doc.uploadedAt).toLocaleDateString()}</p>
</div>
<Button variant="ghost" size="icon" className="h-8 w-8"><Download className="h-4 w-4" /></Button>
</div>
))}
</div>
</TabsContent>
<TabsContent value="terms">
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="font-semibold text-lg">Active Deal Terms</h3>
<Button size="sm">Add New Term</Button>
</div>
{dealTerms.map(term => (
<div key={term.id} className="p-4 border border-border/50 rounded-xl bg-gradient-to-br from-card to-secondary/30">
<div className="flex justify-between items-start mb-2">
<div>
<h4 className="font-bold flex items-center gap-2">
{term.name}
<Badge variant="secondary" className="text-xs font-normal">v{term.version}</Badge>
</h4>
<p className="text-sm text-muted-foreground mt-1">Effective from {new Date(term.effectiveFrom).toLocaleDateString()}</p>
</div>
<Badge variant="outline" className="bg-success/5 border-success/20 text-success">{term.status}</Badge>
</div>
<div className="mt-4 p-3 bg-secondary/50 rounded-lg text-sm grid grid-cols-2 gap-4">
<div>
<span className="text-muted-foreground block text-xs uppercase">Type</span>
<span className="font-medium">{term.type}</span>
</div>
<div>
<span className="text-muted-foreground block text-xs uppercase">Parameters</span>
<span className="font-medium">
{term.type === 'RevenueShare' ? `${term.params.percentage}% Share` :
term.type === 'CommissionPerTicket' ? `${term.params.amount} per ticket` : 'Custom'}
</span>
</div>
</div>
</div>
))}
</div>
</TabsContent>
<TabsContent value="assignments">
<div className="p-8 text-center border-2 border-dashed border-border/50 rounded-xl">
<Calendar className="h-12 w-12 text-muted-foreground mx-auto mb-4 opacity-50" />
<h3 className="text-lg font-medium">No event assignments yet</h3>
<p className="text-muted-foreground mb-4">Assign this partner to an upcoming event</p>
<Button>Assign to Event</Button>
</div>
</TabsContent>
</div>
</Tabs>
</div>
</div>
{/* 3-Column Layout */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* ── Column 1: Identity & Stats ───────────────────────────── */}
<div className="space-y-4">
{/* Contact Card */}
<div className="neu-card p-5 space-y-4">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">Contact</h3>
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
<span className="text-xs font-bold text-primary">{partner.primaryContact.name.substring(0, 2)}</span>
</div>
<div>
<p className="text-sm font-medium">{partner.primaryContact.name}</p>
<p className="text-xs text-muted-foreground">{partner.primaryContact.role || 'Contact'}</p>
</div>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Mail className="h-3.5 w-3.5" /> {partner.primaryContact.email}
</div>
{partner.primaryContact.phone && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Phone className="h-3.5 w-3.5" /> {partner.primaryContact.phone}
</div>
)}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar className="h-3.5 w-3.5" /> Joined {new Date(partner.joinedAt).toLocaleDateString()}
</div>
</div>
</div>
{/* Quick Stats */}
<div className="neu-card p-5">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-3">Stats</h3>
<div className="grid grid-cols-2 gap-3">
<div className="p-3 rounded-lg bg-secondary/30 border border-border/30">
<p className="text-xs text-muted-foreground flex items-center gap-1"><Wallet className="h-3 w-3" /> Revenue</p>
<p className="font-bold text-lg mt-1">{partner.metrics.totalRevenue.toLocaleString()}</p>
</div>
<div className="p-3 rounded-lg bg-secondary/30 border border-border/30">
<p className="text-xs text-muted-foreground flex items-center gap-1"><TrendingUp className="h-3 w-3" /> Events</p>
<p className="font-bold text-lg mt-1">{partner.metrics.eventsCount}</p>
</div>
<div className="p-3 rounded-lg bg-secondary/30 border border-border/30">
<p className="text-xs text-muted-foreground flex items-center gap-1"><DollarSign className="h-3 w-3" /> Open Bal.</p>
<p className="font-bold text-lg mt-1">{partner.metrics.openBalance.toLocaleString()}</p>
</div>
<div className={cn(
'p-3 rounded-lg border',
riskLevel === 'low' ? 'bg-success/5 border-success/20' :
riskLevel === 'medium' ? 'bg-warning/5 border-warning/20' :
'bg-destructive/5 border-destructive/20'
)}>
<p className="text-xs text-muted-foreground flex items-center gap-1"><AlertTriangle className="h-3 w-3" /> Risk</p>
<p className={cn(
'font-bold text-lg mt-1',
riskLevel === 'low' ? 'text-success' :
riskLevel === 'medium' ? 'text-warning' :
'text-destructive'
)}>{partner.riskScore}</p>
</div>
</div>
</div>
{/* Admin Actions */}
<div className="neu-card p-5 space-y-3">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">Admin Actions</h3>
<ImpersonationDialog partnerId={partner.id} partnerName={partner.name}>
<Button variant="outline" className="w-full justify-start gap-2 h-9 text-sm">
<LogIn className="h-4 w-4 text-warning" /> Login as Partner
<ExternalLink className="h-3 w-3 ml-auto text-muted-foreground" />
</Button>
</ImpersonationDialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" className="w-full justify-start gap-2 h-9 text-sm">
<KeyRound className="h-4 w-4 text-blue-400" /> Reset Password
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reset Password</AlertDialogTitle>
<AlertDialogDescription>
This will send a password reset email to {partner.primaryContact.email}. This action is logged.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleResetPassword}>Send Reset Email</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" className="w-full justify-start gap-2 h-9 text-sm">
<ShieldOff className="h-4 w-4 text-orange-400" /> Reset 2FA
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reset Two-Factor Authentication</AlertDialogTitle>
<AlertDialogDescription>
This will remove {partner.name}'s 2FA setup. They will be required to re-enroll on their next login. This action is logged.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleReset2FA}>Reset 2FA</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{partnerStatus === 'Suspended' ? (
<Button
variant="outline"
className="w-full justify-start gap-2 h-9 text-sm text-success border-success/30 hover:bg-success/10"
onClick={handleUnsuspend}
>
<UserCheck className="h-4 w-4" /> Revoke Suspension
</Button>
) : (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" className="w-full justify-start gap-2 h-9 text-sm text-destructive border-destructive/30 hover:bg-destructive/10">
<Ban className="h-4 w-4" /> Suspend Partner
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Suspend Partner</AlertDialogTitle>
<AlertDialogDescription>
This will suspend {partner.name}'s account. They will be unable to access their dashboard or manage events. This action is logged.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleSuspend} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Suspend
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
{/* Deal Terms & Finance Accordion */}
<Accordion type="multiple" className="neu-card overflow-hidden">
<AccordionItem value="deals" className="border-b-0 px-5">
<AccordionTrigger className="text-sm font-semibold hover:no-underline">
<span className="flex items-center gap-2">
<FileSignature className="h-4 w-4 text-muted-foreground" /> Deal Terms
<Badge variant="secondary" className="text-[10px] h-4 px-1">{dealTerms.length}</Badge>
</span>
</AccordionTrigger>
<AccordionContent>
{dealTerms.length > 0 ? (
<div className="space-y-2">
{dealTerms.map(dt => (
<div key={dt.id} className="p-3 rounded-lg bg-secondary/20 border border-border/30">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">{dt.name}</p>
<Badge variant="outline" className={cn('text-[10px]',
dt.status === 'Active' ? 'bg-success/10 text-success border-success/20' :
dt.status === 'Draft' ? 'bg-muted text-muted-foreground' :
'bg-warning/10 text-warning border-warning/20'
)}>
{dt.status}
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-1">
{dt.type} {dt.params.percentage ? `${dt.params.percentage}%` : `${dt.params.amount}`}
</p>
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground text-center py-3">No deals configured</p>
)}
</AccordionContent>
</AccordionItem>
<AccordionItem value="finance" className="border-b-0 px-5">
<AccordionTrigger className="text-sm font-semibold hover:no-underline">
<span className="flex items-center gap-2">
<Wallet className="h-4 w-4 text-muted-foreground" /> Finance Ledger
<Badge variant="secondary" className="text-[10px] h-4 px-1">{ledger.length}</Badge>
</span>
</AccordionTrigger>
<AccordionContent>
{ledger.length > 0 ? (
<div className="space-y-2">
{ledger.map(entry => (
<div key={entry.id} className="p-3 rounded-lg bg-secondary/20 border border-border/30 flex items-center justify-between">
<div>
<p className="text-sm font-medium">{entry.description}</p>
<p className="text-xs text-muted-foreground">{new Date(entry.createdAt).toLocaleDateString()} {entry.type}</p>
</div>
<p className={cn('font-semibold text-sm', entry.amount >= 0 ? 'text-success' : 'text-destructive')}>
{entry.amount >= 0 ? '+' : ''}{Math.abs(entry.amount).toLocaleString()}
</p>
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground text-center py-3">No ledger entries</p>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
{/* ── Column 2: KYC Vault ──────────────────────────────────── */}
<div className="neu-card p-5">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-4">
KYC & Compliance
</h3>
<KYCVaultPanel
partnerId={partner.id}
partnerName={partner.name}
verificationStatus={partner.verificationStatus}
documents={kycDocs}
/>
</div>
{/* ── Column 3: Event Governance ──────────────────────────── */}
<div className="neu-card p-5">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-4">
Event Governance
</h3>
<EventApprovalQueue
partnerId={partner.id}
events={partnerEvents}
/>
</div>
</div>
{/* Tags */}
{partner.tags && partner.tags.length > 0 && (
<div className="mt-6 flex items-center gap-2 flex-wrap">
{partner.tags.map(tag => (
<Badge key={tag} variant="secondary" className="text-xs">{tag}</Badge>
))}
</div>
)}
{/* Notes */}
{partner.notes && (
<div className="mt-4 p-4 bg-warning/5 border border-warning/20 rounded-lg">
<p className="text-sm text-warning font-medium flex items-center gap-2">
<AlertTriangle className="h-4 w-4" /> Notes
</p>
<p className="text-sm text-muted-foreground mt-1">{partner.notes}</p>
</div>
)}
</AppLayout>
);
}
function Plus({ className }: { className?: string }) {
return <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><path d="M5 12h14" /><path d="M12 5v14" /></svg>
}

View File

@@ -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<string, string> = {
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<PartnerEvent | null>(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 }) => (
<div className="p-3 rounded-lg border border-border/50 bg-card/50 hover:border-border transition-colors space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="font-medium text-sm truncate">{event.title}</p>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{new Date(event.date).toLocaleDateString()}
</span>
<span className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
{event.venue}
</span>
</div>
</div>
<Badge variant="outline" className={cn('text-[10px] shrink-0', EVENT_STATUS_STYLES[event.status])}>
{event.status.replace('_', ' ')}
</Badge>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Ticket className="h-3 w-3" />
{event.ticketsSold}/{event.totalTickets} sold
</span>
{event.ticketPrice > 0 && (
<span className="flex items-center gap-1">
<DollarSign className="h-3 w-3" />
{event.ticketPrice}
</span>
)}
{event.category && (
<Badge variant="secondary" className="text-[10px] h-4 px-1">{event.category}</Badge>
)}
</div>
{showActions && event.status === 'PENDING_REVIEW' && (
<div className="flex gap-2 pt-1">
<Button
size="sm"
variant="outline"
className="flex-1 text-xs gap-1.5 h-7"
onClick={() => setReviewingEvent(event)}
>
<Eye className="h-3 w-3" /> Review
</Button>
<Button
size="sm"
className="flex-1 text-xs gap-1.5 h-7 bg-success hover:bg-success/90 text-white"
onClick={() => handleApprove(event.id)}
>
<CheckCircle2 className="h-3 w-3" /> Approve
</Button>
</div>
)}
{event.status === 'REJECTED' && event.rejectionReason && (
<p className="text-xs text-destructive/80 italic pt-1">
Rejected: {event.rejectionReason}
</p>
)}
</div>
);
return (
<div className="space-y-4">
{/* Pending Section */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
Pending Approval
</h4>
{pendingEvents.length > 0 && (
<Badge variant="outline" className="bg-warning/10 text-warning border-warning/20 text-[10px]">
{pendingEvents.length} pending
</Badge>
)}
</div>
{pendingEvents.length > 0 ? (
<div className="space-y-2">
{pendingEvents.map(event => (
<EventCard key={event.id} event={event} showActions={true} />
))}
</div>
) : (
<div className="text-center py-6 text-muted-foreground text-sm border border-dashed border-border/50 rounded-lg">
<Clock className="h-8 w-8 mx-auto mb-2 opacity-30" />
No events pending review
</div>
)}
</div>
{/* Other Events */}
{otherEvents.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
All Events
</h4>
<div className="space-y-2">
{otherEvents.map(event => (
<EventCard key={event.id} event={event} showActions={false} />
))}
</div>
</div>
)}
{/* Review Dialog */}
<Dialog open={!!reviewingEvent} onOpenChange={(open) => { if (!open) { setReviewingEvent(null); setRejectionReason(''); } }}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Review Event</DialogTitle>
<DialogDescription>
Review and approve or decline this event submission.
</DialogDescription>
</DialogHeader>
{reviewingEvent && (
<div className="space-y-4">
<div className="p-4 bg-secondary/30 rounded-lg space-y-3">
<h3 className="font-semibold">{reviewingEvent.title}</h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<Calendar className="h-4 w-4" />
{new Date(reviewingEvent.date).toLocaleDateString()}
{reviewingEvent.time && ` at ${reviewingEvent.time}`}
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<MapPin className="h-4 w-4" />
{reviewingEvent.venue}
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Ticket className="h-4 w-4" />
{reviewingEvent.totalTickets} tickets
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<DollarSign className="h-4 w-4" />
{reviewingEvent.ticketPrice} each
</div>
</div>
{reviewingEvent.category && (
<Badge variant="secondary">{reviewingEvent.category}</Badge>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Rejection Reason (if declining)</label>
<Textarea
placeholder="Explain what needs to be fixed..."
value={rejectionReason}
onChange={e => setRejectionReason(e.target.value)}
className="min-h-[80px] resize-none"
/>
</div>
</div>
)}
<DialogFooter className="gap-2">
<Button
variant="destructive"
className="gap-1.5"
onClick={() => reviewingEvent && handleReject(reviewingEvent.id)}
disabled={processing}
>
<XCircle className="h-4 w-4" /> Decline
</Button>
<Button
className="bg-success hover:bg-success/90 text-white gap-1.5"
onClick={() => reviewingEvent && handleApprove(reviewingEvent.id)}
disabled={processing}
>
<CheckCircle2 className="h-4 w-4" /> Approve & Go Live
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,122 @@
'use client';
import { useState } from 'react';
import { generateImpersonationToken } from '@/lib/actions/partner-governance';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
DialogTrigger,
} from '@/components/ui/dialog';
import { AlertTriangle, ExternalLink, LogIn, Shield } from 'lucide-react';
import { toast } from 'sonner';
interface ImpersonationDialogProps {
partnerId: string;
partnerName: string;
children: React.ReactNode;
}
export function ImpersonationDialog({ partnerId, partnerName, children }: ImpersonationDialogProps) {
const [open, setOpen] = useState(false);
const [acknowledged, setAcknowledged] = useState(false);
const [processing, setProcessing] = useState(false);
const handleImpersonate = async () => {
if (!acknowledged) {
toast.error('Please acknowledge the audit warning.');
return;
}
setProcessing(true);
try {
const result = await generateImpersonationToken(partnerId);
if (result.success && result.redirectUrl) {
toast.success(result.message);
window.open(result.redirectUrl, '_blank');
setOpen(false);
setAcknowledged(false);
} else {
toast.error(result.message);
}
} catch {
toast.error('Failed to create impersonation session.');
} finally {
setProcessing(false);
}
};
return (
<Dialog open={open} onOpenChange={(o) => { setOpen(o); if (!o) setAcknowledged(false); }}>
<DialogTrigger asChild>
{children}
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LogIn className="h-5 w-5 text-warning" />
Login as Partner
</DialogTitle>
<DialogDescription>
You are about to impersonate <strong>{partnerName}</strong>'s account.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex gap-3 p-3 bg-warning/5 border border-warning/20 rounded-lg">
<AlertTriangle className="h-5 w-5 text-warning shrink-0 mt-0.5" />
<div className="text-sm space-y-1">
<p className="font-medium text-warning">Security Notice</p>
<p className="text-muted-foreground text-xs">
This will generate a short-lived impersonation token and open the Partner Dashboard
in a new tab. All actions performed during impersonation are logged and attributed
to your admin account.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-secondary/30 rounded-lg">
<Shield className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
<div className="text-xs text-muted-foreground space-y-1.5">
<p>This action will:</p>
<ul className="list-disc list-inside space-y-0.5 ml-1">
<li>Create a time-limited session token</li>
<li>Log this action in the audit trail</li>
<li>Open the partner dashboard in a new tab</li>
<li>Auto-expire after 30 minutes of inactivity</li>
</ul>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="audit-ack"
checked={acknowledged}
onCheckedChange={(checked) => setAcknowledged(checked === true)}
/>
<label htmlFor="audit-ack" className="text-sm font-medium leading-none cursor-pointer">
I understand this action is logged for audit purposes
</label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button
onClick={handleImpersonate}
disabled={!acknowledged || processing}
className="gap-2 bg-warning hover:bg-warning/90 text-warning-foreground"
>
<ExternalLink className="h-4 w-4" />
{processing ? 'Creating Session...' : 'Open Partner Dashboard'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,223 @@
'use client';
import { useState, useCallback } from 'react';
import { KYCDocument, KYCDocStatus } from '@/types/partner';
import { verifyPartnerDocument, getPartnerKYCStatus } from '@/lib/actions/partner-governance';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Textarea } from '@/components/ui/textarea';
import { Progress } from '@/components/ui/progress';
import {
FileText,
CheckCircle2,
XCircle,
Clock,
Eye,
ShieldCheck,
ShieldAlert,
ShieldX,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
interface KYCVaultPanelProps {
partnerId: string;
partnerName: string;
verificationStatus: 'Pending' | 'Verified' | 'Rejected';
documents: KYCDocument[];
onDocumentVerified?: () => void;
}
const STATUS_ICON: Record<KYCDocStatus, React.ReactNode> = {
APPROVED: <CheckCircle2 className="h-4 w-4 text-success" />,
REJECTED: <XCircle className="h-4 w-4 text-destructive" />,
PENDING: <Clock className="h-4 w-4 text-warning" />,
};
const STATUS_BADGE: Record<KYCDocStatus, string> = {
APPROVED: 'bg-success/10 text-success border-success/20',
REJECTED: 'bg-destructive/10 text-destructive border-destructive/20',
PENDING: 'bg-warning/10 text-warning border-warning/20',
};
export function KYCVaultPanel({
partnerId,
partnerName,
verificationStatus,
documents,
onDocumentVerified,
}: KYCVaultPanelProps) {
const [docs, setDocs] = useState(documents);
const [expandedDoc, setExpandedDoc] = useState<string | null>(null);
const [rejectionReasons, setRejectionReasons] = useState<Record<string, string>>({});
const [processing, setProcessing] = useState<string | null>(null);
const [vStatus, setVStatus] = useState(verificationStatus);
const mandatoryDocs = docs.filter(d => d.mandatory);
const approvedMandatory = mandatoryDocs.filter(d => d.status === 'APPROVED');
const completionPercent = mandatoryDocs.length > 0
? Math.round((approvedMandatory.length / mandatoryDocs.length) * 100)
: 0;
const handleVerify = useCallback(async (docId: string, status: 'APPROVED' | 'REJECTED') => {
if (status === 'REJECTED' && !rejectionReasons[docId]?.trim()) {
toast.error('Please provide a reason for rejection.');
return;
}
setProcessing(docId);
try {
const result = await verifyPartnerDocument(
docId,
status,
status === 'REJECTED' ? rejectionReasons[docId] : undefined
);
if (result.success) {
toast.success(result.message);
setDocs(prev =>
prev.map(d => d.id === docId ? { ...d, status, reviewedBy: 'Current Admin', reviewedAt: new Date().toISOString(), ...(status === 'REJECTED' ? { adminNote: rejectionReasons[docId] } : {}) } : d)
);
setExpandedDoc(null);
if (result.autoVerified) {
setVStatus('Verified');
}
onDocumentVerified?.();
} else {
toast.error(result.message);
}
} catch {
toast.error('Failed to process document.');
} finally {
setProcessing(null);
}
}, [rejectionReasons, onDocumentVerified]);
const overallIcon = vStatus === 'Verified'
? <ShieldCheck className="h-5 w-5 text-success" />
: vStatus === 'Rejected'
? <ShieldX className="h-5 w-5 text-destructive" />
: <ShieldAlert className="h-5 w-5 text-warning" />;
const overallBg = vStatus === 'Verified'
? 'bg-success/5 border-success/20'
: vStatus === 'Rejected'
? 'bg-destructive/5 border-destructive/20'
: 'bg-warning/5 border-warning/20';
return (
<div className="space-y-4">
{/* Overall Status Banner */}
<div className={cn('flex items-center gap-3 p-4 rounded-xl border', overallBg)}>
{overallIcon}
<div className="flex-1">
<p className="font-semibold text-sm">
{vStatus === 'Verified' ? 'Fully Verified' : vStatus === 'Rejected' ? 'Verification Failed' : 'Needs Review'}
</p>
<p className="text-xs text-muted-foreground">
{approvedMandatory.length}/{mandatoryDocs.length} mandatory documents approved
</p>
</div>
<Badge variant="outline" className={cn('text-xs', vStatus === 'Verified' ? 'bg-success/10 text-success border-success/20' : vStatus === 'Rejected' ? 'bg-destructive/10 text-destructive' : 'bg-warning/10 text-warning border-warning/20')}>
{vStatus}
</Badge>
</div>
{/* Progress Bar */}
<div className="space-y-1.5">
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Completion</span>
<span className="font-medium">{completionPercent}%</span>
</div>
<Progress value={completionPercent} className="h-2" />
</div>
{/* Documents List */}
<div className="space-y-2">
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">Documents</h4>
{docs.map(doc => {
const isExpanded = expandedDoc === doc.id;
const isProcessing = processing === doc.id;
return (
<div key={doc.id} className="rounded-lg border border-border/50 bg-card/50 overflow-hidden">
{/* Doc Row */}
<div
className="flex items-center gap-3 p-3 cursor-pointer hover:bg-secondary/30 transition-colors"
onClick={() => setExpandedDoc(isExpanded ? null : doc.id)}
>
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{doc.name}</p>
<p className="text-xs text-muted-foreground">
{doc.type} {doc.mandatory && '• Required'}
</p>
</div>
<div className="flex items-center gap-2">
{STATUS_ICON[doc.status]}
<Badge variant="outline" className={cn('text-[10px] h-5 px-1.5', STATUS_BADGE[doc.status])}>
{doc.status}
</Badge>
{doc.status === 'PENDING' && (
isExpanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />
)}
</div>
</div>
{/* Expanded Actions */}
{isExpanded && doc.status === 'PENDING' && (
<div className="px-3 pb-3 pt-1 border-t border-border/30 space-y-3">
<Button variant="ghost" size="sm" className="gap-2 text-xs w-full justify-start text-muted-foreground hover:text-foreground">
<Eye className="h-3.5 w-3.5" /> Preview Document
</Button>
<Textarea
placeholder="Rejection reason (required if rejecting)..."
className="text-xs min-h-[60px] resize-none"
value={rejectionReasons[doc.id] || ''}
onChange={e =>
setRejectionReasons(prev => ({ ...prev, [doc.id]: e.target.value }))
}
/>
<div className="flex gap-2">
<Button
size="sm"
className="flex-1 bg-success hover:bg-success/90 text-white gap-1.5 text-xs"
onClick={(e) => { e.stopPropagation(); handleVerify(doc.id, 'APPROVED'); }}
disabled={isProcessing}
>
<CheckCircle2 className="h-3.5 w-3.5" /> Approve
</Button>
<Button
size="sm"
variant="destructive"
className="flex-1 gap-1.5 text-xs"
onClick={(e) => { e.stopPropagation(); handleVerify(doc.id, 'REJECTED'); }}
disabled={isProcessing}
>
<XCircle className="h-3.5 w-3.5" /> Reject
</Button>
</div>
</div>
)}
{/* Rejected Note */}
{doc.status === 'REJECTED' && doc.adminNote && (
<div className="px-3 pb-3 pt-1 border-t border-border/30">
<p className="text-xs text-destructive/80 italic">
Reason: {doc.adminNote}
</p>
</div>
)}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,287 @@
/**
* Partner Governance — Server Actions
*
* KYC verification, event approval, account management, and lifecycle actions.
* Currently backed by in-memory mock data. Replace with API calls when backend is ready.
*/
import { mockPartners, mockKYCDocuments, mockPartnerEvents } from '@/data/mockPartnerData';
import type { KYCDocument, KYCDocStatus, PartnerEvent, Partner } from '@/types/partner';
// ═══════════════════════════════════════════════════════════════════
// KYC WORKFLOW
// ═══════════════════════════════════════════════════════════════════
/**
* Get KYC status for a partner, including docs and completion %.
*/
export async function getPartnerKYCStatus(partnerId: string) {
const docs = mockKYCDocuments.filter(d => d.partnerId === partnerId);
const mandatoryDocs = docs.filter(d => d.mandatory);
const approvedMandatory = mandatoryDocs.filter(d => d.status === 'APPROVED');
const completionPercent = mandatoryDocs.length > 0
? Math.round((approvedMandatory.length / mandatoryDocs.length) * 100)
: 0;
const isFullyVerified = mandatoryDocs.length > 0 && approvedMandatory.length === mandatoryDocs.length;
return {
success: true,
partnerId,
documents: docs,
completionPercent,
isFullyVerified,
totalDocs: docs.length,
mandatoryCount: mandatoryDocs.length,
approvedCount: approvedMandatory.length,
};
}
/**
* Verify (approve/reject) a single KYC document.
* If all mandatory docs become APPROVED, auto-upgrade partner to Verified.
*/
export async function verifyPartnerDocument(
docId: string,
status: 'APPROVED' | 'REJECTED',
rejectionReason?: string
): Promise<{ success: boolean; message: string; autoVerified?: boolean }> {
const doc = mockKYCDocuments.find(d => d.id === docId);
if (!doc) {
return { success: false, message: 'Document not found.' };
}
// Update document status
(doc as any).status = status;
(doc as any).reviewedBy = 'Current Admin';
(doc as any).reviewedAt = new Date().toISOString();
if (status === 'REJECTED' && rejectionReason) {
(doc as any).adminNote = rejectionReason;
}
console.log(`[AUDIT] Admin verified document ${docId}: ${status}`, rejectionReason || '');
// Check auto-verification
if (status === 'APPROVED') {
const partnerDocs = mockKYCDocuments.filter(d => d.partnerId === doc.partnerId);
const mandatoryDocs = partnerDocs.filter(d => d.mandatory);
const allApproved = mandatoryDocs.every(d => d.status === 'APPROVED');
if (allApproved) {
const partner = mockPartners.find(p => p.id === doc.partnerId);
if (partner) {
(partner as any).verificationStatus = 'Verified';
console.log(`[AUDIT] Partner ${partner.name} auto-verified — all mandatory KYC docs approved.`);
return {
success: true,
message: `Document approved. All mandatory documents verified — partner auto-upgraded to Verified.`,
autoVerified: true,
};
}
}
}
return {
success: true,
message: status === 'APPROVED'
? 'Document approved successfully.'
: `Document rejected. Partner notified to re-upload.`,
};
}
// ═══════════════════════════════════════════════════════════════════
// EVENT GOVERNANCE
// ═══════════════════════════════════════════════════════════════════
/**
* Get events pending review, optionally filtered by partner.
*/
export async function getPendingEvents(partnerId?: string): Promise<PartnerEvent[]> {
let events = mockPartnerEvents.filter(e => e.status === 'PENDING_REVIEW');
if (partnerId) {
events = events.filter(e => e.partnerId === partnerId);
}
return events;
}
/**
* Get all events for a partner.
*/
export async function getPartnerEvents(partnerId: string): Promise<PartnerEvent[]> {
return mockPartnerEvents.filter(e => e.partnerId === partnerId);
}
/**
* Approve a partner event — PENDING_REVIEW → LIVE.
*/
export async function approvePartnerEvent(
eventId: string
): Promise<{ success: boolean; message: string }> {
const event = mockPartnerEvents.find(e => e.id === eventId);
if (!event) return { success: false, message: 'Event not found.' };
if (event.status !== 'PENDING_REVIEW') {
return { success: false, message: `Event is ${event.status}, not PENDING_REVIEW.` };
}
(event as any).status = 'LIVE';
(event as any).reviewedBy = 'Current Admin';
(event as any).reviewedAt = new Date().toISOString();
console.log(`[AUDIT] Admin approved event "${event.title}" (${eventId}) → LIVE`);
return { success: true, message: `"${event.title}" is now LIVE.` };
}
/**
* Reject a partner event — PENDING_REVIEW → DRAFT with reason.
*/
export async function rejectPartnerEvent(
eventId: string,
reason: string
): Promise<{ success: boolean; message: string }> {
const event = mockPartnerEvents.find(e => e.id === eventId);
if (!event) return { success: false, message: 'Event not found.' };
if (event.status !== 'PENDING_REVIEW') {
return { success: false, message: `Event is ${event.status}, not PENDING_REVIEW.` };
}
(event as any).status = 'REJECTED';
(event as any).rejectionReason = reason;
(event as any).reviewedBy = 'Current Admin';
(event as any).reviewedAt = new Date().toISOString();
console.log(`[AUDIT] Admin rejected event "${event.title}" (${eventId}): ${reason}`);
return { success: true, message: `"${event.title}" rejected. Partner notified with fix request.` };
}
// ═══════════════════════════════════════════════════════════════════
// ACCOUNT MANAGEMENT
// ═══════════════════════════════════════════════════════════════════
/**
* Generate impersonation token for "Login as Partner".
* Returns a redirect URL to the partner dashboard.
*/
export async function generateImpersonationToken(
partnerId: string
): Promise<{ success: boolean; token?: string; redirectUrl?: string; message: string }> {
const partner = mockPartners.find(p => p.id === partnerId);
if (!partner) return { success: false, message: 'Partner not found.' };
// Simulate token generation
const token = `imp_${partnerId}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
const redirectUrl = `https://partner.prototype.eventifyplus.com/impersonate?token=${token}`;
console.log(`[AUDIT] Admin impersonated Partner "${partner.name}" (${partnerId}). Token: ${token}`);
return {
success: true,
token,
redirectUrl,
message: `Impersonation session created for ${partner.name}. This action has been logged.`,
};
}
/**
* Reset partner's 2FA enrollment.
*/
export async function resetPartner2FA(
partnerId: string
): Promise<{ success: boolean; message: string }> {
const partner = mockPartners.find(p => p.id === partnerId);
if (!partner) return { success: false, message: 'Partner not found.' };
console.log(`[AUDIT] Admin reset 2FA for Partner "${partner.name}" (${partnerId})`);
return {
success: true,
message: `2FA has been reset for ${partner.name}. They will be prompted to re-enroll on next login.`,
};
}
/**
* Trigger password reset for a partner.
*/
export async function resetPartnerPassword(
partnerId: string
): Promise<{ success: boolean; message: string }> {
const partner = mockPartners.find(p => p.id === partnerId);
if (!partner) return { success: false, message: 'Partner not found.' };
console.log(`[AUDIT] Admin triggered password reset for Partner "${partner.name}" (${partnerId})`);
return {
success: true,
message: `Password reset email sent to ${partner.primaryContact.email}.`,
};
}
// ═══════════════════════════════════════════════════════════════════
// LIFECYCLE ACTIONS
// ═══════════════════════════════════════════════════════════════════
/**
* Suspend a partner account.
*/
export async function suspendPartner(
partnerId: string,
reason: string
): Promise<{ success: boolean; message: string }> {
const partner = mockPartners.find(p => p.id === partnerId);
if (!partner) return { success: false, message: 'Partner not found.' };
if (partner.status === 'Suspended') {
return { success: false, message: 'Partner is already suspended.' };
}
(partner as any).status = 'Suspended';
(partner as any).notes = reason;
console.log(`[AUDIT] Admin suspended Partner "${partner.name}" (${partnerId}): ${reason}`);
return { success: true, message: `${partner.name} has been suspended.` };
}
/**
* Unsuspend a partner account.
*/
export async function unsuspendPartner(
partnerId: string
): Promise<{ success: boolean; message: string }> {
const partner = mockPartners.find(p => p.id === partnerId);
if (!partner) return { success: false, message: 'Partner not found.' };
if (partner.status !== 'Suspended') {
return { success: false, message: 'Partner is not suspended.' };
}
(partner as any).status = 'Active';
console.log(`[AUDIT] Admin unsuspended Partner "${partner.name}" (${partnerId})`);
return { success: true, message: `${partner.name} has been reactivated.` };
}
// ═══════════════════════════════════════════════════════════════════
// DASHBOARD HELPERS
// ═══════════════════════════════════════════════════════════════════
/**
* Get dashboard stats for partner governance.
*/
export async function getPartnerDashboardStats() {
const pendingKYC = mockPartners.filter(p => p.verificationStatus === 'Pending').length;
const highRisk = mockPartners.filter(p => p.riskScore > 60).length;
const pendingEvents = mockPartnerEvents.filter(e => e.status === 'PENDING_REVIEW').length;
const activePartners = mockPartners.filter(p => p.status === 'Active').length;
const totalRevenue = mockPartners.reduce((sum, p) => sum + p.metrics.totalRevenue, 0);
return {
pendingKYC,
highRisk,
pendingEvents,
activePartners,
totalPartners: mockPartners.length,
totalRevenue,
};
}

View File

@@ -58,6 +58,9 @@ export const SCOPE_DEFINITIONS: Record<string, { label: string; category: string
'partners.read': { label: 'View Partners', category: 'Partners' },
'partners.write': { label: 'Edit Partners', category: 'Partners' },
'partners.kyc': { label: 'Verify Partner KYC', category: 'Partners' },
'partners.impersonate': { label: 'Login as Partner', category: 'Partners' },
'partners.events.review': { label: 'Review Partner Events', category: 'Partners' },
'partners.suspend': { label: 'Suspend/Unsuspend Partners', category: 'Partners' },
// Support Module
'tickets.read': { label: 'View Tickets', category: 'Support' },
'tickets.write': { label: 'Respond to Tickets', category: 'Support' },

View File

@@ -34,18 +34,76 @@ export const PartnerSchema = z.object({
notes: z.string().optional(),
joinedAt: z.string(),
verificationStatus: z.enum(['Pending', 'Verified', 'Rejected']).default('Verified'),
riskScore: z.number().min(0).max(100).default(0),
});
export type Partner = z.infer<typeof PartnerSchema>;
// ── KYC Document Types ──────────────────────────────────────────────
export type KYCDocType = 'PAN' | 'GST' | 'AADHAAR' | 'CANCELLED_CHEQUE' | 'BUSINESS_REG';
export type KYCDocStatus = 'PENDING' | 'APPROVED' | 'REJECTED';
export const KYCDocumentSchema = z.object({
id: z.string(),
partnerId: z.string(),
type: z.enum(['PAN', 'GST', 'AADHAAR', 'CANCELLED_CHEQUE', 'BUSINESS_REG']),
name: z.string(),
url: z.string(),
status: z.enum(['PENDING', 'APPROVED', 'REJECTED']).default('PENDING'),
mandatory: z.boolean().default(true),
adminNote: z.string().optional(),
reviewedBy: z.string().optional(),
reviewedAt: z.string().optional(),
uploadedBy: z.string(),
uploadedAt: z.string(),
});
export type KYCDocument = z.infer<typeof KYCDocumentSchema>;
// ── Event Governance Types ──────────────────────────────────────────
export type EventGovernanceStatus = 'PENDING_REVIEW' | 'LIVE' | 'DRAFT' | 'COMPLETED' | 'CANCELLED' | 'REJECTED';
export const PartnerEventSchema = z.object({
id: z.string(),
partnerId: z.string(),
title: z.string(),
description: z.string().optional(),
date: z.string(),
time: z.string().optional(),
venue: z.string(),
category: z.string().optional(),
ticketPrice: z.number().default(0),
totalTickets: z.number().default(0),
ticketsSold: z.number().default(0),
revenue: z.number().default(0),
status: z.enum(['PENDING_REVIEW', 'LIVE', 'DRAFT', 'COMPLETED', 'CANCELLED', 'REJECTED']),
coverImage: z.string().optional(),
rejectionReason: z.string().optional(),
reviewedBy: z.string().optional(),
reviewedAt: z.string().optional(),
submittedAt: z.string(),
createdAt: z.string(),
});
export type PartnerEvent = z.infer<typeof PartnerEventSchema>;
// ── Risk Score Helpers ──────────────────────────────────────────────
export type RiskLevel = 'low' | 'medium' | 'high';
export function getRiskLevel(score: number): RiskLevel {
if (score <= 30) return 'low';
if (score <= 60) return 'medium';
return 'high';
}
// ── Deal Terms ──────────────────────────────────────────────────────
export const DealTermSchema = z.object({
id: z.string(),
partnerId: z.string(),
type: z.enum(['RevenueShare', 'CommissionPerTicket', 'FixedFee', 'Tiered', 'Hybrid']),
name: z.string(),
params: z.object({
percentage: z.number().optional(), // For RevenueShare
amount: z.number().optional(), // For FixedFee or Commission
percentage: z.number().optional(),
amount: z.number().optional(),
currency: z.string().default('INR'),
tiers: z.array(z.object({
threshold: z.number(),
@@ -69,7 +127,7 @@ export const LedgerEntrySchema = z.object({
description: z.string(),
amount: z.number(),
currency: z.string().default('INR'),
referenceId: z.string().optional(), // Invoice ID or Transaction ID
referenceId: z.string().optional(),
createdAt: z.string(),
status: z.enum(['Pending', 'Cleared', 'Failed']),
});