Files
WooNooW/admin-spa/src/routes/Marketing/Affiliates/Referrals.tsx

357 lines
16 KiB
TypeScript

import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { __ } from '@/lib/i18n';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Search, Filter, X, Download } from 'lucide-react';
import { formatMoney } from '@/lib/currency';
interface Affiliate {
id: number;
user_id: number;
user_name?: string;
referral_code: string;
}
interface Referral {
id: number;
affiliate_id: number;
affiliate_name?: string;
order_id: number;
status: string;
commission_amount: string;
currency: string;
created_at: string;
approved_at?: string;
}
export default function AffiliatesReferrals() {
const [filters, setFilters] = useState({
affiliate_id: '',
status: '',
date_start: '',
date_end: '',
order_id: '',
});
const [showFilters, setShowFilters] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
// Fetch affiliates for filter dropdown
const { data: affiliates = [] } = useQuery<Affiliate[]>({
queryKey: ['admin-affiliates'],
queryFn: async () => {
const response: any = await api.get('/admin/affiliates');
return Array.isArray(response) ? response : (response?.data || []);
},
});
// Build query params
const queryParams = new URLSearchParams();
if (filters.affiliate_id) queryParams.set('affiliate_id', filters.affiliate_id);
if (filters.status) queryParams.set('status', filters.status);
if (filters.date_start) queryParams.set('date_start', filters.date_start);
if (filters.date_end) queryParams.set('date_end', filters.date_end);
if (filters.order_id) queryParams.set('order_id', filters.order_id);
// Fetch referrals with filters
const { data: referrals = [], isLoading } = useQuery<Referral[]>({
queryKey: ['admin-referrals', filters],
queryFn: async () => {
const queryString = queryParams.toString();
const url = queryString ? `/admin/affiliates/referrals?${queryString}` : '/admin/affiliates/referrals';
const response: any = await api.get(url);
return Array.isArray(response) ? response : (response?.data || []);
},
});
// Client-side search filter (must be after referrals is defined)
const filteredReferrals = (referrals || []).filter((ref) => {
if (searchQuery) {
const query = searchQuery.toLowerCase();
return (
ref.order_id.toString().includes(query) ||
(ref.affiliate_name || '').toLowerCase().includes(query)
);
}
return true;
});
// Export to CSV
const exportToCSV = () => {
const headers = ['ID', 'Affiliate', 'Order ID', 'Status', 'Commission', 'Currency', 'Created At'];
const rows = filteredReferrals.map(ref => [
ref.id,
ref.affiliate_name || `Affiliate #${ref.affiliate_id}`,
ref.order_id,
ref.status,
ref.commission_amount,
ref.currency,
new Date(ref.created_at).toISOString()
]);
const csvContent = [
headers.join(','),
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
].join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `affiliate-referrals-${new Date().toISOString().split('T')[0]}.csv`;
link.click();
};
const clearFilters = () => {
setFilters({
affiliate_id: '',
status: '',
date_start: '',
date_end: '',
order_id: '',
});
setSearchQuery('');
};
const hasActiveFilters = Object.values(filters).some(v => v !== '');
const formatCurrency = (amount: string | number, currency?: string) => {
try {
return formatMoney(amount, { currency });
} catch {
const num = typeof amount === 'string' ? parseFloat(amount) : amount;
if (isNaN(num)) return '—';
return `${currency || 'IDR'} ${num.toLocaleString()}`;
}
};
// Stats
const totalCommissions = filteredReferrals.reduce((sum, r) => sum + parseFloat(r.commission_amount || 0), 0);
const pendingCount = filteredReferrals.filter(r => r.status === 'pending').length;
const approvedCount = filteredReferrals.filter(r => r.status === 'approved').length;
return (
<div className="space-y-6">
{/* Header with search, filter toggle, and export */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex-1 max-w-md">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
placeholder={__('Search by order ID or affiliate...')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="!pl-9"
/>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={exportToCSV}
disabled={filteredReferrals.length === 0}
>
<Download className="w-4 h-4 mr-2" />
{__('Export CSV')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowFilters(!showFilters)}
>
<Filter className="w-4 h-4 mr-2" />
{__('Filters')}
{hasActiveFilters && (
<span className="ml-2 bg-primary text-primary-foreground rounded-full w-5 h-5 text-xs flex items-center justify-center">
{Object.values(filters).filter(v => v !== '').length}
</span>
)}
</Button>
</div>
</div>
{/* Stats summary text */}
<p className="text-sm text-muted-foreground">
{filteredReferrals.length} referral(s) {hasActiveFilters || searchQuery ? '(filtered)' : ''}
</p>
{/* Filter Panel */}
{showFilters && (
<div className="bg-white dark:bg-gray-800 rounded-lg border p-4 space-y-4">
<div className="flex items-center justify-between">
<h4 className="font-medium">{__('Filter Referrals')}</h4>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={clearFilters}>
<X className="w-4 h-4 mr-1" />
{__('Clear All')}
</Button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
{/* Affiliate Filter */}
<div>
<label className="text-sm font-medium mb-2 block">{__('Affiliate')}</label>
<Select
value={filters.affiliate_id}
onValueChange={(v) => setFilters({ ...filters, affiliate_id: v })}
>
<SelectTrigger>
<SelectValue placeholder={__('All affiliates')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="">{__('All affiliates')}</SelectItem>
{affiliates.map((aff) => (
<SelectItem key={aff.id} value={aff.id.toString()}>
{aff.user_name || aff.referral_code}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Status Filter */}
<div>
<label className="text-sm font-medium mb-2 block">{__('Status')}</label>
<Select
value={filters.status}
onValueChange={(v) => setFilters({ ...filters, status: v })}
>
<SelectTrigger>
<SelectValue placeholder={__('All statuses')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="">{__('All statuses')}</SelectItem>
<SelectItem value="pending">{__('Pending')}</SelectItem>
<SelectItem value="approved">{__('Approved')}</SelectItem>
<SelectItem value="rejected">{__('Rejected')}</SelectItem>
</SelectContent>
</Select>
</div>
{/* Order ID Filter */}
<div>
<label className="text-sm font-medium mb-2 block">{__('Order ID')}</label>
<Input
type="number"
placeholder={__('Order ID')}
value={filters.order_id}
onChange={(e) => setFilters({ ...filters, order_id: e.target.value })}
/>
</div>
{/* Date Start */}
<div>
<label className="text-sm font-medium mb-2 block">{__('From Date')}</label>
<Input
type="date"
value={filters.date_start}
onChange={(e) => setFilters({ ...filters, date_start: e.target.value })}
/>
</div>
{/* Date End */}
<div>
<label className="text-sm font-medium mb-2 block">{__('To Date')}</label>
<Input
type="date"
value={filters.date_end}
onChange={(e) => setFilters({ ...filters, date_end: e.target.value })}
/>
</div>
</div>
</div>
)}
{/* Stats Summary */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border">
<div className="text-sm text-muted-foreground">{__('Total Referrals')}</div>
<div className="text-2xl font-bold mt-1">{filteredReferrals.length}</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border">
<div className="text-sm text-muted-foreground">{__('Total Commission')}</div>
<div className="text-2xl font-bold mt-1">{formatCurrency(totalCommissions)}</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border">
<div className="text-sm text-muted-foreground">{__('Pending')}</div>
<div className="text-2xl font-bold mt-1 text-yellow-600">{pendingCount}</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border">
<div className="text-sm text-muted-foreground">{__('Approved')}</div>
<div className="text-2xl font-bold mt-1 text-green-600">{approvedCount}</div>
</div>
</div>
{/* Referrals Table */}
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">
{__('Loading referrals...')}
</div>
) : filteredReferrals.length === 0 ? (
<div className="text-center py-8 text-muted-foreground border rounded-lg">
{hasActiveFilters || searchQuery ? __('No referrals match your filters') : __('No referrals yet')}
</div>
) : (
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>{__('ID')}</TableHead>
<TableHead>{__('Affiliate')}</TableHead>
<TableHead>{__('Order ID')}</TableHead>
<TableHead>{__('Status')}</TableHead>
<TableHead>{__('Date')}</TableHead>
<TableHead className="text-right">{__('Commission')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredReferrals.map((ref) => (
<TableRow key={ref.id}>
<TableCell className="font-medium">#{ref.id}</TableCell>
<TableCell>
<div>
<div className="font-medium">{ref.affiliate_name || `Affiliate #${ref.affiliate_id}`}</div>
</div>
</TableCell>
<TableCell>#{ref.order_id}</TableCell>
<TableCell>
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
ref.status === 'approved'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'
: ref.status === 'pending'
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300'
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300'
}`}>
{ref.status}
</span>
</TableCell>
<TableCell className="text-muted-foreground">
{new Date(ref.created_at).toLocaleDateString('id-ID', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</TableCell>
<TableCell className="text-right font-medium">
{formatCurrency(ref.commission_amount, ref.currency)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
);
}