diff --git a/apps/web/src/app/admin/reports/AdminReportItem.tsx b/apps/web/src/app/admin/reports/AdminReportItem.tsx
new file mode 100644
index 0000000..c8d9535
--- /dev/null
+++ b/apps/web/src/app/admin/reports/AdminReportItem.tsx
@@ -0,0 +1,88 @@
+'use client';
+
+import { Check, X } from 'lucide-react';
+
+import ReportFieldCard from '@/components/ReportFieldCard';
+import { Button } from '@/components/ui/button';
+import {
+ AdminReport,
+ AdminReportAction,
+ REPORT_CATEGORY_LABEL,
+ REPORT_CATEGORY_TO_FIELD,
+ REPORT_NO_DATA_LABEL,
+ REPORT_STATUS_LABEL,
+} from '@/types/report';
+import { cn } from '@/utils/cn';
+import {
+ REPORT_CATEGORY_BADGE_CLASS,
+ REPORT_STATUS_BADGE_CLASS,
+ formatReportDate,
+} from '@/utils/reportDisplay';
+
+interface AdminReportItemProps {
+ report: AdminReport;
+ onAction: (report: AdminReport, action: AdminReportAction) => void;
+ isDisabled?: boolean;
+}
+
+export default function AdminReportItem({ report, onAction, isDisabled }: AdminReportItemProps) {
+ const activeField = REPORT_CATEGORY_TO_FIELD[report.category];
+ const newValue = report.suggested_value ?? REPORT_NO_DATA_LABEL;
+ const isPending = report.status === 'pending';
+
+ return (
+
+
+
+ {REPORT_STATUS_LABEL[report.status]}
+
+
+ {REPORT_CATEGORY_LABEL[report.category]}
+
+ {report.nickname}
+
+ {formatReportDate(report.created_at)}
+
+
+
+
+
+ {isPending && (
+
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/app/admin/reports/ReviewActionModal.tsx b/apps/web/src/app/admin/reports/ReviewActionModal.tsx
new file mode 100644
index 0000000..8b280e2
--- /dev/null
+++ b/apps/web/src/app/admin/reports/ReviewActionModal.tsx
@@ -0,0 +1,97 @@
+'use client';
+
+import { Check, X } from 'lucide-react';
+
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { usePatchAdminReportMutation } from '@/queries/adminReportQuery';
+import {
+ AdminReport,
+ AdminReportAction,
+ REPORT_CATEGORY_LABEL,
+ REPORT_NO_DATA_LABEL,
+} from '@/types/report';
+
+interface ReviewActionModalProps {
+ isOpen: boolean;
+ report: AdminReport | null;
+ action: AdminReportAction | null;
+ onClose: () => void;
+}
+
+export default function ReviewActionModal({
+ isOpen,
+ report,
+ action,
+ onClose,
+}: ReviewActionModalProps) {
+ const { mutate, isPending } = usePatchAdminReportMutation();
+
+ const handleConfirm = () => {
+ if (!report || !action) return;
+ mutate(
+ { reportId: report.id, action },
+ {
+ onSuccess: () => {
+ onClose();
+ },
+ },
+ );
+ };
+
+ const isApprove = action === 'approve';
+ const titleText = isApprove ? '신고 승인' : '신고 거부';
+ const Icon = isApprove ? Check : X;
+ const suggestedValue = report?.suggested_value ?? REPORT_NO_DATA_LABEL;
+ const categoryLabel = report ? REPORT_CATEGORY_LABEL[report.category] : '';
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/app/admin/reports/page.tsx b/apps/web/src/app/admin/reports/page.tsx
new file mode 100644
index 0000000..dc1a4cf
--- /dev/null
+++ b/apps/web/src/app/admin/reports/page.tsx
@@ -0,0 +1,108 @@
+'use client';
+
+import { AxiosError } from 'axios';
+import { ArrowLeft } from 'lucide-react';
+import { useRouter } from 'next/navigation';
+import { useEffect, useState } from 'react';
+import { toast } from 'sonner';
+
+import StaticLoading from '@/components/StaticLoading';
+import { Button } from '@/components/ui/button';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Separator } from '@/components/ui/separator';
+import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { useAdminReportsQuery } from '@/queries/adminReportQuery';
+import useAuthStore from '@/stores/useAuthStore';
+import {
+ ADMIN_REPORT_STATUS_FILTERS,
+ ADMIN_REPORT_STATUS_FILTER_LABEL,
+ AdminReport,
+ AdminReportAction,
+ AdminReportStatusFilter,
+} from '@/types/report';
+
+import AdminReportItem from './AdminReportItem';
+import ReviewActionModal from './ReviewActionModal';
+
+export default function AdminReportsPage() {
+ const router = useRouter();
+ const { isAuthenticated } = useAuthStore();
+ const [statusFilter, setStatusFilter] = useState('pending');
+ const { data, isLoading, error } = useAdminReportsQuery(statusFilter, isAuthenticated);
+ const reports = data ?? [];
+
+ const [reviewTarget, setReviewTarget] = useState<{
+ report: AdminReport;
+ action: AdminReportAction;
+ } | null>(null);
+
+ useEffect(() => {
+ if (!error) return;
+ if (error instanceof AxiosError) {
+ const status = error.response?.status;
+ if (status === 401 || status === 403) {
+ toast.error('관리자 권한이 없습니다.');
+ router.replace('/');
+ }
+ }
+ }, [error, router]);
+
+ const handleAction = (report: AdminReport, action: AdminReportAction) => {
+ setReviewTarget({ report, action });
+ };
+
+ const handleClose = () => {
+ setReviewTarget(null);
+ };
+
+ return (
+
+ {isLoading &&
}
+
+
+
신고 관리
+
+
+
+
+
setStatusFilter(value as AdminReportStatusFilter)}
+ className="mb-2"
+ >
+
+ {ADMIN_REPORT_STATUS_FILTERS.map(filter => (
+
+ {ADMIN_REPORT_STATUS_FILTER_LABEL[filter]}
+
+ ))}
+
+
+
+
+
+
+ {reports.length === 0 && !isLoading ? (
+
+ ) : (
+ reports.map(report => (
+
+ ))
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/api/admin/reports/[id]/route.ts b/apps/web/src/app/api/admin/reports/[id]/route.ts
new file mode 100644
index 0000000..d2acab3
--- /dev/null
+++ b/apps/web/src/app/api/admin/reports/[id]/route.ts
@@ -0,0 +1,102 @@
+import { NextResponse } from 'next/server';
+
+import createClient from '@/lib/supabase/server';
+import { ApiResponse } from '@/types/apiRoute';
+import { AdminReportAction, ReportCategory, ReportStatus } from '@/types/report';
+import { getAdminUser } from '@/utils/getAdminUser';
+
+const CATEGORY_TO_SONG_COLUMN: Record<
+ ReportCategory,
+ 'title_ko' | 'artist_ko' | 'num_tj' | 'num_ky'
+> = {
+ title_translation: 'title_ko',
+ artist_translation: 'artist_ko',
+ num_tj: 'num_tj',
+ num_ky: 'num_ky',
+};
+
+function isAdminReportAction(value: unknown): value is AdminReportAction {
+ return value === 'approve' || value === 'reject';
+}
+
+interface ReportLookupRow {
+ status: ReportStatus;
+ song_id: string;
+ category: ReportCategory;
+ suggested_value: string | null;
+}
+
+export async function PATCH(
+ request: Request,
+ { params }: { params: Promise<{ id: string }> },
+): Promise>> {
+ try {
+ const supabase = await createClient();
+ await getAdminUser(supabase);
+
+ const { id: reportId } = await params;
+ const { action } = await request.json();
+
+ if (!reportId) {
+ return NextResponse.json({ success: false, error: 'Missing reportId' }, { status: 400 });
+ }
+ if (!isAdminReportAction(action)) {
+ return NextResponse.json({ success: false, error: 'Invalid action' }, { status: 400 });
+ }
+
+ const { data: report, error: lookupError } = await supabase
+ .from('song_reports')
+ .select('status, song_id, category, suggested_value')
+ .eq('id', reportId)
+ .maybeSingle();
+
+ if (lookupError) throw lookupError;
+ if (!report) {
+ return NextResponse.json({ success: false, error: 'Report not found' }, { status: 404 });
+ }
+ if (report.status !== 'pending') {
+ return NextResponse.json(
+ { success: false, error: 'Report already processed' },
+ { status: 409 },
+ );
+ }
+
+ if (action === 'approve') {
+ const column = CATEGORY_TO_SONG_COLUMN[report.category];
+ const { error: songUpdateError } = await supabase
+ .from('songs')
+ .update({ [column]: report.suggested_value })
+ .eq('id', report.song_id);
+
+ if (songUpdateError) throw songUpdateError;
+
+ const { error: statusUpdateError } = await supabase
+ .from('song_reports')
+ .update({ status: 'applied' })
+ .eq('id', reportId);
+
+ if (statusUpdateError) throw statusUpdateError;
+ } else {
+ const { error: rejectError } = await supabase
+ .from('song_reports')
+ .update({ status: 'rejected' })
+ .eq('id', reportId);
+
+ if (rejectError) throw rejectError;
+ }
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ if (error instanceof Error && error.cause === 'auth') {
+ return NextResponse.json(
+ { success: false, error: 'User not authenticated' },
+ { status: 401 },
+ );
+ }
+ if (error instanceof Error && error.cause === 'forbidden') {
+ return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 });
+ }
+ console.error('Error in admin report PATCH API:', error);
+ return NextResponse.json({ success: false, error: 'Failed to update report' }, { status: 500 });
+ }
+}
diff --git a/apps/web/src/app/api/admin/reports/route.ts b/apps/web/src/app/api/admin/reports/route.ts
new file mode 100644
index 0000000..2202700
--- /dev/null
+++ b/apps/web/src/app/api/admin/reports/route.ts
@@ -0,0 +1,90 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+import createClient from '@/lib/supabase/server';
+import { ApiResponse } from '@/types/apiRoute';
+import {
+ ADMIN_REPORT_STATUS_FILTERS,
+ AdminReport,
+ AdminReportStatusFilter,
+ ReportCategory,
+ ReportStatus,
+} from '@/types/report';
+import { Song } from '@/types/song';
+import { getAdminUser } from '@/utils/getAdminUser';
+
+interface AdminReportRow {
+ id: string;
+ song_id: string;
+ user_id: string;
+ category: ReportCategory;
+ suggested_value: string | null;
+ status: ReportStatus;
+ created_at: string;
+ songs: Pick | null;
+ users: { nickname: string } | null;
+}
+
+function isStatusFilter(value: string | null): value is AdminReportStatusFilter {
+ return value !== null && ADMIN_REPORT_STATUS_FILTERS.includes(value as AdminReportStatusFilter);
+}
+
+export async function GET(request: NextRequest): Promise>> {
+ try {
+ const supabase = await createClient();
+ await getAdminUser(supabase);
+
+ const statusParam = request.nextUrl.searchParams.get('status');
+ const statusFilter: AdminReportStatusFilter = isStatusFilter(statusParam) ? statusParam : 'all';
+
+ let query = supabase
+ .from('song_reports')
+ .select(
+ `id, song_id, user_id, category, suggested_value, status, created_at,
+ songs ( title, artist, title_ko, artist_ko, num_tj, num_ky ),
+ users ( nickname )`,
+ )
+ .order('created_at', { ascending: false });
+
+ if (statusFilter !== 'all') {
+ query = query.eq('status', statusFilter);
+ }
+
+ const { data, error } = await query.returns();
+
+ if (error) throw error;
+
+ const reports: AdminReport[] = (data ?? []).map(row => ({
+ id: row.id,
+ song_id: row.song_id,
+ user_id: row.user_id,
+ nickname: row.users?.nickname ?? '알 수 없음',
+ category: row.category,
+ suggested_value: row.suggested_value,
+ status: row.status,
+ created_at: row.created_at,
+ title: row.songs?.title ?? '',
+ artist: row.songs?.artist ?? '',
+ title_ko: row.songs?.title_ko,
+ artist_ko: row.songs?.artist_ko,
+ num_tj: row.songs?.num_tj ?? '',
+ num_ky: row.songs?.num_ky ?? '',
+ }));
+
+ return NextResponse.json({ success: true, data: reports });
+ } catch (error) {
+ if (error instanceof Error && error.cause === 'auth') {
+ return NextResponse.json(
+ { success: false, error: 'User not authenticated' },
+ { status: 401 },
+ );
+ }
+ if (error instanceof Error && error.cause === 'forbidden') {
+ return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 });
+ }
+ console.error('Error in admin reports GET API:', error);
+ return NextResponse.json(
+ { success: false, error: 'Failed to get admin reports' },
+ { status: 500 },
+ );
+ }
+}
diff --git a/apps/web/src/app/info/reports/ReportItem.tsx b/apps/web/src/app/info/reports/ReportItem.tsx
index f484300..c6ec98c 100644
--- a/apps/web/src/app/info/reports/ReportItem.tsx
+++ b/apps/web/src/app/info/reports/ReportItem.tsx
@@ -10,10 +10,13 @@ import {
REPORT_CATEGORY_TO_FIELD,
REPORT_NO_DATA_LABEL,
REPORT_STATUS_LABEL,
- ReportCategory,
- ReportStatus,
} from '@/types/report';
import { cn } from '@/utils/cn';
+import {
+ REPORT_CATEGORY_BADGE_CLASS,
+ REPORT_STATUS_BADGE_CLASS,
+ formatReportDate,
+} from '@/utils/reportDisplay';
interface ReportItemProps {
report: MyReport;
@@ -21,33 +24,6 @@ interface ReportItemProps {
isDisabled?: boolean;
}
-const STATUS_BADGE_CLASS: Record = {
- pending:
- 'border border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300',
- applied:
- 'border border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-300',
- rejected:
- 'border border-red-300 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300',
-};
-
-const CATEGORY_BADGE_CLASS: Record = {
- title_translation:
- 'border border-sky-300 bg-sky-50 text-sky-700 dark:border-sky-800 dark:bg-sky-950 dark:text-sky-300',
- artist_translation:
- 'border border-pink-300 bg-pink-50 text-pink-700 dark:border-pink-800 dark:bg-pink-950 dark:text-pink-300',
- num_tj: 'border border-brand-tj/40 bg-brand-tj/10 text-brand-tj',
- num_ky: 'border border-brand-ky/40 bg-brand-ky/10 text-brand-ky',
-};
-
-function formatDate(value: string) {
- const date = new Date(value);
- if (Number.isNaN(date.getTime())) return value;
- const yyyy = date.getFullYear();
- const mm = String(date.getMonth() + 1).padStart(2, '0');
- const dd = String(date.getDate()).padStart(2, '0');
- return `${yyyy}.${mm}.${dd}`;
-}
-
export default function ReportItem({ report, onDelete, isDisabled }: ReportItemProps) {
const activeField = REPORT_CATEGORY_TO_FIELD[report.category];
const newValue = report.suggested_value ?? REPORT_NO_DATA_LABEL;
@@ -58,7 +34,7 @@ export default function ReportItem({ report, onDelete, isDisabled }: ReportItemP
{REPORT_STATUS_LABEL[report.status]}
@@ -66,13 +42,13 @@ export default function ReportItem({ report, onDelete, isDisabled }: ReportItemP
{REPORT_CATEGORY_LABEL[report.category]}
- {formatDate(report.created_at)}
+ {formatReportDate(report.created_at)}