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:
@@ -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])
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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="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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-between items-center">
|
||||
<div className="relative w-full sm:w-96">
|
||||
{/* 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 partners..."
|
||||
className="pl-10 bg-secondary border-border/50 focus:border-accent"
|
||||
placeholder="Search by name, email, or contact..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</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 className="gap-2 shrink-0">
|
||||
<Plus className="h-4 w-4" /> Add Partner
|
||||
</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>
|
||||
</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>
|
||||
)}
|
||||
{/* 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>
|
||||
|
||||
<TabsContent value="all" className="mt-6">
|
||||
{/* Render grid... handled below */}
|
||||
</TabsContent>
|
||||
<TabsContent value="pending_kyc" className="mt-6">
|
||||
{/* Render grid... handled below */}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* 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 ? (
|
||||
<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>
|
||||
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" />
|
||||
) : (
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.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>
|
||||
</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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
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}>
|
||||
{/* 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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
279
src/features/partners/components/EventApprovalQueue.tsx
Normal file
279
src/features/partners/components/EventApprovalQueue.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
src/features/partners/components/ImpersonationDialog.tsx
Normal file
122
src/features/partners/components/ImpersonationDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
223
src/features/partners/components/KYCVaultPanel.tsx
Normal file
223
src/features/partners/components/KYCVaultPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
287
src/lib/actions/partner-governance.ts
Normal file
287
src/lib/actions/partner-governance.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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']),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user