diff --git a/app/moderators/auto-payments/AutoPaymentsContent.tsx b/app/moderators/auto-payments/AutoPaymentsContent.tsx new file mode 100644 index 000000000..435df9e2e --- /dev/null +++ b/app/moderators/auto-payments/AutoPaymentsContent.tsx @@ -0,0 +1,317 @@ +'use client'; + +import { useState, useMemo, useEffect } from 'react'; +import { ChevronDown, DollarSign, RefreshCw } from 'lucide-react'; +import Link from 'next/link'; +import { useInView } from 'react-intersection-observer'; +import { Avatar } from '@/components/ui/Avatar'; +import { Badge } from '@/components/ui/Badge'; +import { Button } from '@/components/ui/Button'; +import { CurrencyBadge } from '@/components/ui/RSCBadge'; +import { Dropdown, DropdownItem } from '@/components/ui/form/Dropdown'; +import { DatePicker } from '@/components/ui/form/DatePicker'; +import { useScreenSize } from '@/hooks/useScreenSize'; +import { useAutoPayments } from '@/hooks/useAutoPayments'; +import { formatTimestamp } from '@/utils/date'; +import type { + AutoPayment, + AutoPaymentsFilters, + DistributionType, + DistributedStatus, +} from '@/types/autoPayment'; +import { DISTRIBUTION_TYPE_LABELS } from '@/types/autoPayment'; + +const FILTER_OPTIONS: { value: DistributionType | ''; label: string }[] = [ + { value: '', label: 'All Types' }, + { value: 'EDITOR_PAYOUT', label: 'Editor Pay' }, + { value: 'PREREGISTRATION_UPDATE_REWARD', label: 'Author Update Reward' }, +]; + +const STATUS_STYLES: Record = { + DISTRIBUTED: 'bg-green-100 text-green-800', + PENDING: 'bg-yellow-100 text-yellow-800', + FAILED: 'bg-red-100 text-red-800', +}; + +function PaymentCard({ payment }: Readonly<{ payment: AutoPayment }>) { + const recipientName = payment.recipient + ? `${payment.recipient.firstName} ${payment.recipient.lastName}` + : null; + + const statusStyle = payment.distributedStatus + ? STATUS_STYLES[payment.distributedStatus] + : 'bg-gray-100 text-gray-800'; + + return ( +
+
+
+ {payment.recipient && ( + + )} +
+ {recipientName ? ( + + {recipientName} + + ) : ( + Unknown user + )} +
+ +
+
+ + {DISTRIBUTION_TYPE_LABELS[payment.distributionType]} + +
+
+
+ +
+ {formatTimestamp(payment.createdDate)} + + {payment.distributedStatus || 'Unknown'} + +
+
+
+ ); +} + +function PaymentCardSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} + +export default function AutoPaymentsContent() { + const { mdAndUp } = useScreenSize(); + const [distributionType, setDistributionType] = useState(''); + const [createdAfter, setCreatedAfter] = useState(null); + const [createdBefore, setCreatedBefore] = useState(null); + const [isRefreshing, setIsRefreshing] = useState(false); + + const hasActiveFilters = !!(distributionType || createdAfter || createdBefore); + + const filters = useMemo( + () => ({ + distributionType: distributionType || undefined, + createdAfter, + createdBefore, + }), + [distributionType, createdAfter, createdBefore] + ); + + const [state, { loadMore, refetch }] = useAutoPayments(filters); + + const { ref: loadMoreRef, inView } = useInView({ + threshold: 0, + rootMargin: '100px', + }); + + useEffect(() => { + if (inView && state.hasMore && !state.isLoading) { + loadMore(); + } + }, [inView, state.hasMore, state.isLoading, loadMore]); + + const handleRefresh = async () => { + setIsRefreshing(true); + try { + await refetch(); + } finally { + setIsRefreshing(false); + } + }; + + const clearFilters = () => { + setDistributionType(''); + setCreatedAfter(null); + setCreatedBefore(null); + }; + + const selectedFilterLabel = FILTER_OPTIONS.find((o) => o.value === distributionType)?.label; + + return ( +
+ {/* Header */} +
+
+
+

Auto-Payments

+

+ Audit automated payments including editor pay and author update rewards +

+
+ + +
+ + {/* Filters */} +
+
+ + {selectedFilterLabel} + + + } + className="!w-auto whitespace-nowrap" + > + {FILTER_OPTIONS.map((option) => ( + setDistributionType(option.value)} + className={distributionType === option.value ? 'bg-blue-50 text-blue-700' : ''} + > + {option.label} + + ))} + + + {hasActiveFilters && ( + + )} +
+ +
+
+ +
+ +
+ +
+ + {hasActiveFilters && ( + + )} +
+
+
+ + {/* Content */} +
+
+ {state.error && ( +
+
Error: {state.error}
+ +
+ )} + + {state.isLoading && state.payments.length === 0 && ( +
+ {Array.from({ length: 5 }).map((_, skeletonIndex) => ( + + ))} +
+ )} + + {state.payments.length === 0 && !state.isLoading && !state.error && ( +
+
+
+ +
+

No payments found

+

+ No automated payments match the current filters. Try adjusting your date range or + payment type. +

+
+
+ )} + + {state.payments.length > 0 && ( +
+ {state.payments.map((payment) => ( + + ))} +
+ )} + + {state.isLoading && state.payments.length > 0 && ( +
+ {Array.from({ length: 3 }).map((_, skeletonIndex) => ( + + ))} +
+ )} + + {!state.isLoading && state.hasMore &&
} +
+
+
+ ); +} diff --git a/app/moderators/auto-payments/page.tsx b/app/moderators/auto-payments/page.tsx new file mode 100644 index 000000000..694c56768 --- /dev/null +++ b/app/moderators/auto-payments/page.tsx @@ -0,0 +1,5 @@ +import AutoPaymentsContent from './AutoPaymentsContent'; + +export default function AutoPaymentsPage() { + return ; +} diff --git a/components/Moderators/ModerationSidebar.tsx b/components/Moderators/ModerationSidebar.tsx index 5898c008e..d089c1ed6 100644 --- a/components/Moderators/ModerationSidebar.tsx +++ b/components/Moderators/ModerationSidebar.tsx @@ -1,7 +1,7 @@ 'use client'; import { FC } from 'react'; -import { Flag, UserRoundPen, Users, FileCheck } from 'lucide-react'; +import { Flag, UserRoundPen, Users, DollarSign, FileCheck } from 'lucide-react'; import { SidebarNav, SidebarNavMenu, type SidebarNavItem } from '@/components/SidebarNav'; const navigationItems: SidebarNavItem[] = [ @@ -29,6 +29,12 @@ const navigationItems: SidebarNavItem[] = [ icon: UserRoundPen, description: 'Review editors activity', }, + { + name: 'Auto-Payments', + href: '/moderators/auto-payments', + icon: DollarSign, + description: 'Audit automated payments', + }, ]; export const ModerationSidebar: FC = () => ; diff --git a/components/ui/form/DatePicker.tsx b/components/ui/form/DatePicker.tsx index a0bc22c22..814cc6c9c 100644 --- a/components/ui/form/DatePicker.tsx +++ b/components/ui/form/DatePicker.tsx @@ -15,6 +15,7 @@ interface DatePickerProps { maxDate?: Date; className?: string; disabled?: boolean; + withPortal?: boolean; } export function DatePicker({ @@ -28,6 +29,7 @@ export function DatePicker({ maxDate, className, disabled, + withPortal, ...props }: DatePickerProps) { return ( @@ -40,6 +42,7 @@ export function DatePicker({ minDate={minDate} maxDate={maxDate} disabled={disabled} + withPortal={withPortal} dateFormat="MM/dd/yyyy" wrapperClassName="w-full" className={cn( diff --git a/hooks/useAutoPayments.ts b/hooks/useAutoPayments.ts new file mode 100644 index 000000000..cefd9e521 --- /dev/null +++ b/hooks/useAutoPayments.ts @@ -0,0 +1,77 @@ +import { useState, useEffect, useCallback } from 'react'; +import { AutoPaymentService } from '@/services/autoPayment.service'; +import type { AutoPayment, AutoPaymentsFilters } from '@/types/autoPayment'; + +export interface AutoPaymentsState { + payments: AutoPayment[]; + isLoading: boolean; + error: string | null; + hasMore: boolean; +} + +export type UseAutoPaymentsReturn = [ + AutoPaymentsState, + { + loadMore: () => Promise; + refetch: () => Promise; + }, +]; + +export function useAutoPayments( + filters: AutoPaymentsFilters, + pageSize: number = 20 +): UseAutoPaymentsReturn { + const [payments, setPayments] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(false); + + const fetchPayments = useCallback( + async (targetPage: number, append: boolean) => { + try { + setIsLoading(true); + setError(null); + + const response = await AutoPaymentService.fetchAutoPayments(filters, { + page: targetPage, + pageSize, + }); + + setPayments((prev) => (append ? [...prev, ...response.payments] : response.payments)); + setHasMore(response.count > targetPage * pageSize); + setPage(targetPage); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to fetch auto-payments'; + setError(message); + } finally { + setIsLoading(false); + } + }, + [filters, pageSize] + ); + + const loadMore = useCallback(async () => { + if (!hasMore || isLoading) return; + await fetchPayments(page + 1, true); + }, [hasMore, isLoading, page, fetchPayments]); + + const refetch = useCallback(async () => { + setPayments([]); + setPage(1); + setHasMore(false); + await fetchPayments(1, false); + }, [fetchPayments]); + + useEffect(() => { + setPayments([]); + setPage(1); + setHasMore(false); + fetchPayments(1, false); + }, [fetchPayments]); + + return [ + { payments, isLoading, error, hasMore }, + { loadMore, refetch }, + ]; +} diff --git a/services/autoPayment.service.ts b/services/autoPayment.service.ts new file mode 100644 index 000000000..fe290e233 --- /dev/null +++ b/services/autoPayment.service.ts @@ -0,0 +1,69 @@ +import { ApiClient } from './client'; +import type { AutoPayment, AutoPaymentApi, AutoPaymentsFilters } from '@/types/autoPayment'; +import { transformAutoPayment } from '@/types/autoPayment'; + +interface AutoPaymentsApiResponse { + count: number; + next: string | null; + previous: string | null; + results: AutoPaymentApi[]; +} + +export interface AutoPaymentsResult { + payments: AutoPayment[]; + count: number; + hasNextPage: boolean; + hasPrevPage: boolean; +} + +export class AutoPaymentServiceError extends Error { + constructor( + message: string, + public readonly originalError?: unknown + ) { + super(message); + this.name = 'AutoPaymentServiceError'; + } +} + +export class AutoPaymentService { + private static readonly BASE_PATH = '/api/audit/auto_payments'; + + static async fetchAutoPayments( + filters: AutoPaymentsFilters, + params: { page: number; pageSize: number } + ): Promise { + try { + const queryParams = new URLSearchParams(); + queryParams.append('page', params.page.toString()); + queryParams.append('page_size', params.pageSize.toString()); + + if (filters.distributionType) { + queryParams.append('distribution_type', filters.distributionType); + } + if (filters.recipientId) { + queryParams.append('recipient', filters.recipientId.toString()); + } + if (filters.createdAfter) { + queryParams.append('created_after', filters.createdAfter.toISOString()); + } + if (filters.createdBefore) { + queryParams.append('created_before', filters.createdBefore.toISOString()); + } + + const response = await ApiClient.get( + `${this.BASE_PATH}/?${queryParams.toString()}` + ); + + return { + payments: response.results.map(transformAutoPayment), + count: response.count, + hasNextPage: !!response.next, + hasPrevPage: !!response.previous, + }; + } catch (error) { + console.error('Error fetching auto-payments:', error); + throw new AutoPaymentServiceError('Failed to fetch auto-payments. Please try again.', error); + } + } +} diff --git a/types/autoPayment.ts b/types/autoPayment.ts new file mode 100644 index 000000000..9029c248a --- /dev/null +++ b/types/autoPayment.ts @@ -0,0 +1,83 @@ +export type DistributionType = + | 'EDITOR_PAYOUT' + | 'EDITOR_COMPENSATION' + | 'PREREGISTRATION_UPDATE_REWARD'; + +export type DistributedStatus = 'DISTRIBUTED' | 'PENDING' | 'FAILED'; + +export const DISTRIBUTION_TYPE_LABELS: Record = { + EDITOR_PAYOUT: 'Editor Pay', + EDITOR_COMPENSATION: 'Editor Pay', + PREREGISTRATION_UPDATE_REWARD: 'Author Update Reward', +}; + +interface AutoPaymentRecipientApi { + id: number; + first_name: string; + last_name: string; + email: string; + author_profile: { + id: number; + profile_image: string | null; + } | null; +} + +export interface AutoPaymentApi { + id: number; + recipient: AutoPaymentRecipientApi | null; + amount: string; + distribution_type: DistributionType; + distributed_status: DistributedStatus | null; + created_date: string; +} + +export interface AutoPaymentRecipient { + id: number; + firstName: string; + lastName: string; + email: string; + authorProfile: { + id: number; + profileImage: string | null; + } | null; +} + +export interface AutoPayment { + id: number; + recipient: AutoPaymentRecipient | null; + amount: string; + distributionType: DistributionType; + distributedStatus: DistributedStatus | null; + createdDate: string; +} + +export interface AutoPaymentsFilters { + distributionType?: DistributionType; + recipientId?: number; + createdAfter?: Date | null; + createdBefore?: Date | null; +} + +export function transformAutoPayment(api: AutoPaymentApi): AutoPayment { + return { + id: api.id, + recipient: api.recipient + ? { + id: api.recipient.id, + firstName: api.recipient.first_name, + lastName: api.recipient.last_name, + email: api.recipient.email, + authorProfile: api.recipient.author_profile + ? { + id: api.recipient.author_profile.id, + profileImage: api.recipient.author_profile.profile_image, + } + : null, + } + : null, + amount: api.amount, + distributionType: api.distribution_type, + distributedStatus: api.distributed_status, + createdDate: api.created_date, + }; +}