feat(admin): add affiliate marketing screens and admin order integration
This commit is contained in:
357
admin-spa/src/routes/Marketing/Affiliates/Referrals.tsx
Normal file
357
admin-spa/src/routes/Marketing/Affiliates/Referrals.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user