diff --git a/admin/src/pages/influence/PetitionModerationPage.tsx b/admin/src/pages/influence/PetitionModerationPage.tsx new file mode 100644 index 00000000..7e4a709e --- /dev/null +++ b/admin/src/pages/influence/PetitionModerationPage.tsx @@ -0,0 +1,243 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + Card, Table, Button, Space, Tag, Input, Select, Drawer, Typography, message, + Statistic, Row, Col, Modal, Grid, +} from 'antd'; +import { + CheckCircleOutlined, CloseCircleOutlined, ExclamationCircleOutlined, + SearchOutlined, ReloadOutlined, +} from '@ant-design/icons'; +import type { ColumnsType } from 'antd/es/table'; +import dayjs from 'dayjs'; +import { api } from '@/lib/api'; +import type { Petition, PetitionsListResponse, CampaignModerationStatus } from '@/types/api'; + +const { Text, Paragraph } = Typography; +const { TextArea } = Input; + +const MODERATION_STATUS_COLORS: Record = { + PENDING_REVIEW: 'orange', + APPROVED: 'green', + REJECTED: 'red', + CHANGES_REQUESTED: 'gold', +}; + +export default function PetitionModerationPage() { + const screens = Grid.useBreakpoint(); + const isMobile = !screens.md; + const [petitions, setPetitions] = useState([]); + const [loading, setLoading] = useState(false); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pageSize] = useState(20); + const [search, setSearch] = useState(''); + const [statusFilter, setStatusFilter] = useState(undefined); + const [stats, setStats] = useState<{ pending: number; approved: number; rejected: number; changesRequested: number } | null>(null); + const [selectedPetition, setSelectedPetition] = useState(null); + const [drawerOpen, setDrawerOpen] = useState(false); + const [actionModalOpen, setActionModalOpen] = useState(false); + const [actionType, setActionType] = useState<'reject' | 'request_changes' | null>(null); + const [actionReason, setActionReason] = useState(''); + const [actionLoading, setActionLoading] = useState(false); + + const fetchQueue = useCallback(async () => { + setLoading(true); + try { + const params: Record = { page, limit: pageSize }; + if (search) params.search = search; + if (statusFilter) params.moderationStatus = statusFilter; + const { data } = await api.get('/petitions/moderation/queue', { params }); + setPetitions(data.petitions); + setTotal(data.pagination.total); + } catch { + message.error('Failed to load moderation queue'); + } finally { + setLoading(false); + } + }, [page, pageSize, search, statusFilter]); + + const fetchStats = useCallback(async () => { + try { + const { data } = await api.get('/petitions/moderation/stats'); + setStats(data); + } catch { /* non-critical */ } + }, []); + + useEffect(() => { fetchQueue(); }, [fetchQueue]); + useEffect(() => { fetchStats(); }, [fetchStats]); + + const handleApprove = async (petition: Petition) => { + try { + await api.patch(`/petitions/moderation/${petition.id}`, { action: 'approve' }); + message.success('Petition approved'); + fetchQueue(); + fetchStats(); + setDrawerOpen(false); + } catch { + message.error('Failed to approve petition'); + } + }; + + const handleActionSubmit = async () => { + if (!selectedPetition || !actionType) return; + setActionLoading(true); + try { + await api.patch(`/petitions/moderation/${selectedPetition.id}`, { + action: actionType, + reason: actionReason, + }); + message.success(actionType === 'reject' ? 'Petition rejected' : 'Changes requested'); + setActionModalOpen(false); + setActionReason(''); + setActionType(null); + setDrawerOpen(false); + fetchQueue(); + fetchStats(); + } catch { + message.error('Failed to update petition'); + } finally { + setActionLoading(false); + } + }; + + const columns: ColumnsType = [ + { + title: 'Title', + dataIndex: 'title', + key: 'title', + ellipsis: true, + render: (title: string, record: Petition) => ( + + ), + }, + { + title: 'Status', + dataIndex: 'moderationStatus', + key: 'status', + width: 130, + render: (status: CampaignModerationStatus | null) => + status ? {status.replace(/_/g, ' ')} : '-', + }, + { + title: 'Submitted By', + dataIndex: 'createdByUserName', + key: 'submitter', + width: 150, + ellipsis: true, + }, + ...(isMobile ? [] : [{ + title: 'Date', + dataIndex: 'createdAt', + key: 'date', + width: 110, + render: (d: string) => dayjs(d).format('MMM D, YYYY'), + } as any]), + { + title: 'Actions', + key: 'actions', + width: 200, + render: (_: unknown, record: Petition) => ( + + + + + ), + }, + ]; + + return ( +
+ {stats && ( + + + + + + + )} + + + } allowClear value={search} onChange={e => { setSearch(e.target.value); setPage(1); }} style={{ width: isMobile ? 140 : 200 }} /> +