357 lines
16 KiB
TypeScript
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>
|
|
);
|
|
} |