diff --git a/pages/admin/review.tsx b/pages/admin/review.tsx
new file mode 100644
index 0000000..783bdf6
--- /dev/null
+++ b/pages/admin/review.tsx
@@ -0,0 +1,10 @@
+import { createRoute } from '@granite-js/react-native';
+import { AdminReviewScreen } from '../../src/features/profile/AdminReviewScreen';
+
+export const Route = createRoute('/admin/review', {
+ component: Page,
+});
+
+function Page() {
+ return ;
+}
diff --git a/src/api/adminMissionReview.ts b/src/api/adminMissionReview.ts
new file mode 100644
index 0000000..7702fc3
--- /dev/null
+++ b/src/api/adminMissionReview.ts
@@ -0,0 +1,153 @@
+import { apiRequest, ApiClientError, isApiEnabled } from './client';
+import { API_PATHS } from './notion/types';
+
+export type AdminReviewStatus = 'APPROVED' | 'REJECTED';
+
+export type AdminMissionPendingItem = {
+ completionId: number;
+ userId: number;
+ userNickname: string | null;
+ missionId: number;
+ missionTitle: string;
+ photoKey: string;
+ /** BE 추가 예정 — 있으면 미리보기에 우선 사용 */
+ photoUrl?: string | null;
+ submittedAt: string;
+};
+
+export type AdminCommunityProofPendingItem = {
+ proofId: number;
+ communityMissionId: number;
+ communityMissionTitle: string;
+ requirementId: number;
+ proofOrder: number;
+ requirementTitle: string | null;
+ userId: number;
+ nickname: string | null;
+ submittedAt: string;
+ imageKeys: string[];
+ /** BE 추가 예정 — 있으면 미리보기에 우선 사용 */
+ imageUrls?: string[] | null;
+};
+
+type CommunityPendingPage = {
+ items: AdminCommunityProofPendingItem[];
+ page: number;
+ size: number;
+ totalElements: number;
+ totalPages: number;
+ hasNext: boolean;
+};
+
+/** BE 공개 에셋 베이스 — photoKey 폴백용 (현재 미션 경로는 403일 수 있음). */
+export const PUBLIC_ASSETS_BASE_URL = 'https://assets.zero-st.com';
+
+export function publicAssetUrl(fileKey: string): string {
+ const trimmed = fileKey.trim().replace(/^\//, '');
+ return `${PUBLIC_ASSETS_BASE_URL}/${trimmed}`;
+}
+
+/** 일일 검수 — photoUrl 우선, 없으면 key로 폴백 */
+export function resolveDailyReviewPhotoUri(item: AdminMissionPendingItem): string | null {
+ const url = item.photoUrl?.trim();
+ if (url != null && url.length > 0) {
+ return url;
+ }
+ if (item.photoKey.trim().length > 0) {
+ return publicAssetUrl(item.photoKey);
+ }
+ return null;
+}
+
+/** 공동 검수 — imageUrls 우선, 없으면 imageKeys 폴백 */
+export function resolveCommunityReviewPhotoUris(
+ item: AdminCommunityProofPendingItem,
+): string[] {
+ const urls = (item.imageUrls ?? [])
+ .map((u) => u.trim())
+ .filter((u) => u.length > 0);
+ if (urls.length > 0) {
+ return urls;
+ }
+ return item.imageKeys
+ .map((k) => k.trim())
+ .filter((k) => k.length > 0)
+ .map(publicAssetUrl);
+}
+
+function bySubmittedAtAsc(a: T, b: T): number {
+ return a.submittedAt.localeCompare(b.submittedAt);
+}
+
+/** GET /api/v1/admin/missions/completions/pending — ADMIN만. 일반 유저는 null */
+export async function getAdminMissionCompletionsPending(): Promise<
+ AdminMissionPendingItem[] | null
+> {
+ if (!isApiEnabled()) {
+ return null;
+ }
+ try {
+ const items = await apiRequest(
+ API_PATHS.adminMissionCompletionsPending,
+ );
+ return [...items].sort(bySubmittedAtAsc);
+ } catch (error) {
+ if (
+ error instanceof ApiClientError &&
+ (error.status === 403 || error.code === 'ADMIN_ACCESS_DENIED')
+ ) {
+ return null;
+ }
+ throw error;
+ }
+}
+
+/** POST /api/v1/admin/missions/completions/{id}/review */
+export async function postAdminMissionCompletionReview(
+ completionId: number,
+ status: AdminReviewStatus,
+): Promise<{ completionId: number; status: string }> {
+ return apiRequest(API_PATHS.adminMissionCompletionReview(completionId), {
+ method: 'POST',
+ body: { status },
+ });
+}
+
+/** GET /api/v1/admin/community-missions/proofs/pending */
+export async function getAdminCommunityProofsPending(params?: {
+ page?: number;
+ size?: number;
+}): Promise {
+ if (!isApiEnabled()) {
+ return null;
+ }
+ const page = params?.page ?? 0;
+ const size = params?.size ?? 50;
+ const path = `${API_PATHS.adminCommunityProofsPending}?page=${page}&size=${size}`;
+ try {
+ const data = await apiRequest(path);
+ return {
+ ...data,
+ items: [...(data.items ?? [])].sort(bySubmittedAtAsc),
+ };
+ } catch (error) {
+ if (
+ error instanceof ApiClientError &&
+ (error.status === 403 || error.code === 'ADMIN_ACCESS_DENIED')
+ ) {
+ return null;
+ }
+ throw error;
+ }
+}
+
+/** POST /api/v1/admin/community-missions/proofs/{proofId}/review */
+export async function postAdminCommunityProofReview(
+ proofId: number,
+ status: AdminReviewStatus,
+): Promise<{ proofId: number; status: string }> {
+ return apiRequest(API_PATHS.adminCommunityProofReview(proofId), {
+ method: 'POST',
+ body: { status },
+ });
+}
diff --git a/src/api/notion/types.ts b/src/api/notion/types.ts
index 49139b2..4531d90 100644
--- a/src/api/notion/types.ts
+++ b/src/api/notion/types.ts
@@ -236,4 +236,10 @@ export const API_PATHS = {
adminTesterLink: '/api/v1/admin/tester-link',
adminUsers: '/api/v1/admin/users',
adminAssetsGrant: '/api/v1/admin/assets/grant',
+ adminMissionCompletionsPending: '/api/v1/admin/missions/completions/pending',
+ adminMissionCompletionReview: (completionId: number) =>
+ `/api/v1/admin/missions/completions/${completionId}/review`,
+ adminCommunityProofsPending: '/api/v1/admin/community-missions/proofs/pending',
+ adminCommunityProofReview: (proofId: number) =>
+ `/api/v1/admin/community-missions/proofs/${proofId}/review`,
} as const;
diff --git a/src/features/profile/AdminReviewEntry.tsx b/src/features/profile/AdminReviewEntry.tsx
new file mode 100644
index 0000000..3a8cff1
--- /dev/null
+++ b/src/features/profile/AdminReviewEntry.tsx
@@ -0,0 +1,88 @@
+import { readAccessTokenRole } from '@api/accessTokenRole';
+import { getAuthSession } from '@api/authSession';
+import { Txt } from '@toss/tds-react-native';
+import { useEffect, useState } from 'react';
+import { Pressable, StyleSheet, View } from 'react-native';
+
+type AdminReviewEntryProps = {
+ onPress: () => void;
+};
+
+/**
+ * ADMIN 전용 — 마이에서 검수 화면으로 들어가는 진입 행.
+ */
+export function AdminReviewEntry({ onPress }: AdminReviewEntryProps) {
+ const [ready, setReady] = useState(false);
+ const [isAdmin, setIsAdmin] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ try {
+ const session = await getAuthSession();
+ const role = readAccessTokenRole(session?.accessToken);
+ if (!cancelled) {
+ setIsAdmin(role === 'ADMIN');
+ }
+ } finally {
+ if (!cancelled) {
+ setReady(true);
+ }
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ if (!ready || !isAdmin) {
+ return null;
+ }
+
+ return (
+
+
+ 관리
+
+
+
+
+ 미션 검수
+
+
+ 일일·공동 인증을 승인하거나 반려해요.
+
+
+
+ 열기
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ wrap: {
+ gap: 10,
+ marginTop: 24,
+ marginBottom: 8,
+ },
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 12,
+ paddingVertical: 14,
+ paddingHorizontal: 14,
+ borderRadius: 12,
+ backgroundColor: '#F3F0E8',
+ },
+ rowText: {
+ flex: 1,
+ gap: 4,
+ },
+});
diff --git a/src/features/profile/AdminReviewScreen.tsx b/src/features/profile/AdminReviewScreen.tsx
new file mode 100644
index 0000000..41a45bb
--- /dev/null
+++ b/src/features/profile/AdminReviewScreen.tsx
@@ -0,0 +1,469 @@
+import { ApiClientError } from '@api/client';
+import {
+ getAdminCommunityProofsPending,
+ getAdminMissionCompletionsPending,
+ postAdminCommunityProofReview,
+ postAdminMissionCompletionReview,
+ resolveCommunityReviewPhotoUris,
+ resolveDailyReviewPhotoUri,
+ type AdminCommunityProofPendingItem,
+ type AdminMissionPendingItem,
+ type AdminReviewStatus,
+} from '@api/adminMissionReview';
+import { Button, Top, Txt } from '@toss/tds-react-native';
+import { useCallback, useEffect, useState } from 'react';
+import {
+ Image,
+ Modal,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ View,
+} from 'react-native';
+import { useAppToast } from '../../shared/feedback/useAppToast';
+import { Screen } from '../../shared/ui/Screen';
+import { CenterLoader } from '../../shared/ui/CenterLoader';
+import { colors } from '../../shared/theme/colors';
+
+type ReviewTab = 'daily' | 'community';
+
+type PhotoPreview = {
+ title: string;
+ uris: string[];
+ hint?: string;
+};
+
+function formatSubmittedAt(iso: string): string {
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) {
+ return iso;
+ }
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
+ const dd = String(d.getDate()).padStart(2, '0');
+ const hh = String(d.getHours()).padStart(2, '0');
+ const mi = String(d.getMinutes()).padStart(2, '0');
+ return `${mm}/${dd} ${hh}:${mi}`;
+}
+
+/**
+ * 관리자 미션 검수 화면 — 목록(오래된 순) + 행 옆 승인/반려 + 탭 시 사진.
+ */
+export function AdminReviewScreen() {
+ const { showSuccess, showError } = useAppToast();
+ const [tab, setTab] = useState('daily');
+ const [dailyItems, setDailyItems] = useState([]);
+ const [communityItems, setCommunityItems] = useState(
+ [],
+ );
+ const [loading, setLoading] = useState(true);
+ const [forbidden, setForbidden] = useState(false);
+ const [actingId, setActingId] = useState(null);
+ const [preview, setPreview] = useState(null);
+ const [imageFailed, setImageFailed] = useState(false);
+
+ const refresh = useCallback(async () => {
+ setLoading(true);
+ try {
+ const [daily, community] = await Promise.all([
+ getAdminMissionCompletionsPending(),
+ getAdminCommunityProofsPending({ page: 0, size: 50 }),
+ ]);
+ if (daily == null || community == null) {
+ setForbidden(true);
+ setDailyItems([]);
+ setCommunityItems([]);
+ return;
+ }
+ setForbidden(false);
+ setDailyItems(daily);
+ setCommunityItems(community.items ?? []);
+ } catch (error) {
+ setDailyItems([]);
+ setCommunityItems([]);
+ if (error instanceof ApiClientError) {
+ showError(error.message);
+ } else {
+ showError('검수 목록을 불러오지 못했어요.');
+ }
+ } finally {
+ setLoading(false);
+ }
+ }, [showError]);
+
+ useEffect(() => {
+ void refresh();
+ }, [refresh]);
+
+ const reviewDaily = useCallback(
+ (completionId: number, status: AdminReviewStatus) => {
+ void (async () => {
+ const actionKey = `d-${completionId}`;
+ setActingId(actionKey);
+ try {
+ await postAdminMissionCompletionReview(completionId, status);
+ showSuccess(status === 'APPROVED' ? '승인했어요.' : '반려했어요.');
+ await refresh();
+ } catch (error) {
+ if (error instanceof ApiClientError) {
+ showError(error.message);
+ } else {
+ showError('검수 처리에 실패했어요.');
+ }
+ } finally {
+ setActingId(null);
+ }
+ })();
+ },
+ [refresh, showError, showSuccess],
+ );
+
+ const reviewCommunity = useCallback(
+ (proofId: number, status: AdminReviewStatus) => {
+ void (async () => {
+ const actionKey = `c-${proofId}`;
+ setActingId(actionKey);
+ try {
+ await postAdminCommunityProofReview(proofId, status);
+ showSuccess(status === 'APPROVED' ? '승인했어요.' : '반려했어요.');
+ await refresh();
+ } catch (error) {
+ if (error instanceof ApiClientError) {
+ showError(error.message);
+ } else {
+ showError('검수 처리에 실패했어요.');
+ }
+ } finally {
+ setActingId(null);
+ }
+ })();
+ },
+ [refresh, showError, showSuccess],
+ );
+
+ const openDailyPhoto = (item: AdminMissionPendingItem) => {
+ const uri = resolveDailyReviewPhotoUri(item);
+ setImageFailed(false);
+ setPreview({
+ title: item.missionTitle,
+ uris: uri != null ? [uri] : [],
+ hint: item.photoUrl == null ? item.photoKey : undefined,
+ });
+ };
+
+ const openCommunityPhoto = (item: AdminCommunityProofPendingItem) => {
+ const uris = resolveCommunityReviewPhotoUris(item);
+ setImageFailed(false);
+ setPreview({
+ title: item.communityMissionTitle,
+ uris,
+ hint:
+ (item.imageUrls == null || item.imageUrls.length === 0) &&
+ item.imageKeys.length > 0
+ ? item.imageKeys.join(', ')
+ : undefined,
+ });
+ };
+
+ if (loading && dailyItems.length === 0 && communityItems.length === 0) {
+ return (
+
+
+
+ );
+ }
+
+ if (forbidden) {
+ return (
+
+ 미션 검수} />
+
+ 관리자만 이용할 수 있어요.
+
+
+ );
+ }
+
+ return (
+
+ 미션 검수}
+ subtitle2={
+
+ 오래된 제출이 위에 보여요. 줄을 누르면 사진을 봐요.
+
+ }
+ />
+
+
+ setTab('daily')}
+ style={[styles.tab, tab === 'daily' && styles.tabActive]}
+ accessibilityRole="button"
+ accessibilityState={{ selected: tab === 'daily' }}
+ >
+
+ {`일일 (${dailyItems.length})`}
+
+
+ setTab('community')}
+ style={[styles.tab, tab === 'community' && styles.tabActive]}
+ accessibilityRole="button"
+ accessibilityState={{ selected: tab === 'community' }}
+ >
+
+ {`공동 (${communityItems.length})`}
+
+
+ void refresh()}
+ disabled={loading || actingId != null}
+ accessibilityRole="button"
+ style={styles.refresh}
+ >
+
+ 새로고침
+
+
+
+
+
+ {tab === 'daily' && dailyItems.length === 0 ? (
+
+ 대기 중인 일일 미션이 없어요.
+
+ ) : null}
+ {tab === 'daily'
+ ? dailyItems.map((item) => {
+ const busy = actingId === `d-${item.completionId}`;
+ return (
+
+ openDailyPhoto(item)}
+ accessibilityRole="button"
+ accessibilityLabel="인증 사진 보기"
+ >
+
+ {item.missionTitle}
+
+
+ {`${item.userNickname ?? `유저 ${item.userId}`} · ${formatSubmittedAt(item.submittedAt)}`}
+
+
+
+
+
+
+
+ );
+ })
+ : null}
+
+ {tab === 'community' && communityItems.length === 0 ? (
+
+ 대기 중인 공동 미션이 없어요.
+
+ ) : null}
+ {tab === 'community'
+ ? communityItems.map((item) => {
+ const busy = actingId === `c-${item.proofId}`;
+ return (
+
+ openCommunityPhoto(item)}
+ accessibilityRole="button"
+ accessibilityLabel="인증 사진 보기"
+ >
+
+ {item.communityMissionTitle}
+
+
+ {`${item.requirementTitle ?? `${item.proofOrder}단계`} · ${item.nickname ?? `유저 ${item.userId}`} · ${formatSubmittedAt(item.submittedAt)}`}
+
+
+
+
+
+
+
+ );
+ })
+ : null}
+
+
+ setPreview(null)}
+ >
+ setPreview(null)}>
+ true}
+ >
+
+ {preview?.title ?? '인증 사진'}
+
+ {preview != null && preview.uris.length > 0 && !imageFailed ? (
+ preview.uris.map((uri) => (
+ setImageFailed(true)}
+ accessibilityLabel="인증 사진"
+ />
+ ))
+ ) : (
+
+ 사진을 불러올 수 없어요.{'\n'}
+ 서버에서 photoUrl(또는 서명 URL)이 필요해요.
+
+ )}
+ {preview?.hint != null ? (
+
+ {preview.hint}
+
+ ) : null}
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ pad: {
+ padding: 16,
+ lineHeight: 22,
+ },
+ tabRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ paddingHorizontal: 16,
+ marginBottom: 8,
+ flexWrap: 'wrap',
+ },
+ tab: {
+ paddingHorizontal: 12,
+ paddingVertical: 8,
+ borderRadius: 10,
+ backgroundColor: colors.border,
+ },
+ tabActive: {
+ backgroundColor: colors.primary,
+ },
+ refresh: {
+ marginLeft: 'auto',
+ paddingVertical: 8,
+ paddingHorizontal: 4,
+ },
+ list: {
+ paddingHorizontal: 16,
+ paddingBottom: 32,
+ gap: 10,
+ },
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ paddingVertical: 12,
+ paddingHorizontal: 12,
+ borderRadius: 12,
+ backgroundColor: '#F3F0E8',
+ },
+ rowMain: {
+ flex: 1,
+ gap: 4,
+ minWidth: 0,
+ },
+ rowActions: {
+ gap: 6,
+ alignItems: 'stretch',
+ },
+ modalOverlay: {
+ flex: 1,
+ backgroundColor: 'rgba(0,0,0,0.45)',
+ justifyContent: 'center',
+ padding: 20,
+ },
+ modalSheet: {
+ backgroundColor: colors.surface,
+ borderRadius: 16,
+ padding: 16,
+ gap: 12,
+ },
+ modalTitle: {
+ marginBottom: 4,
+ },
+ modalHint: {
+ lineHeight: 22,
+ },
+ previewImage: {
+ width: '100%',
+ height: 320,
+ backgroundColor: '#eee',
+ borderRadius: 10,
+ },
+});
diff --git a/src/features/profile/ProfileScreen.tsx b/src/features/profile/ProfileScreen.tsx
index 52ebb66..ac65ab1 100644
--- a/src/features/profile/ProfileScreen.tsx
+++ b/src/features/profile/ProfileScreen.tsx
@@ -33,11 +33,13 @@ import {
} from './ProfileListSection';
import { AdminTesterLinkSection } from './AdminTesterLinkSection';
import { AdminAssetGrantSection } from './AdminAssetGrantSection';
+import { AdminReviewEntry } from './AdminReviewEntry';
import { Screen } from '../../shared/ui/Screen';
import { colors } from '../../shared/theme/colors';
type ProfileScreenProps = {
onPressAbout?: () => void;
+ onPressAdminReview?: () => void;
onPressLogout?: () => void;
};
@@ -68,6 +70,7 @@ function formatLedgerTime(iso: string): string {
export function ProfileScreen({
onPressAbout,
+ onPressAdminReview,
onPressLogout,
}: ProfileScreenProps) {
const { state, updateNickname, updateAvatar, logout } = useUser();
@@ -305,6 +308,9 @@ export function ProfileScreen({
) : null}
+ {onPressAdminReview != null ? (
+
+ ) : null}
diff --git a/src/router.gen.ts b/src/router.gen.ts
index 3e27807..ce3f3a8 100644
--- a/src/router.gen.ts
+++ b/src/router.gen.ts
@@ -1,8 +1,9 @@
/* eslint-disable */
// This file is auto-generated by @granite-js/react-native. DO NOT EDIT.
import { Route as _AboutRoute } from '../pages/about';
+import { Route as _AdminReviewRoute } from '../pages/admin/review';
import { Route as _GachaRoute } from '../pages/gacha';
-import { Route as _IndexRoute } from '../pages/';
+import { Route as _IndexRoute } from '../pages/index';
import { Route as _IngredientsRoute } from '../pages/ingredients';
import { Route as _LoginRoute } from '../pages/login';
import { Route as _MissionsIdResultRoute } from '../pages/missions/[id]/result';
@@ -22,6 +23,7 @@ import { Route as _SoupResultRoute } from '../pages/soup/result';
declare module '@granite-js/react-native' {
interface RegisterScreenInput {
'/about': (typeof _AboutRoute)['_inputType'];
+ '/admin/review': (typeof _AdminReviewRoute)['_inputType'];
'/gacha': (typeof _GachaRoute)['_inputType'];
'/': (typeof _IndexRoute)['_inputType'];
'/ingredients': (typeof _IngredientsRoute)['_inputType'];
@@ -43,6 +45,7 @@ declare module '@granite-js/react-native' {
interface RegisterScreen {
'/about': (typeof _AboutRoute)['_outputType'];
+ '/admin/review': (typeof _AdminReviewRoute)['_outputType'];
'/gacha': (typeof _GachaRoute)['_outputType'];
'/': (typeof _IndexRoute)['_outputType'];
'/ingredients': (typeof _IngredientsRoute)['_outputType'];
diff --git a/src/shared/constants/routes.ts b/src/shared/constants/routes.ts
index 668a65a..6e76b2a 100644
--- a/src/shared/constants/routes.ts
+++ b/src/shared/constants/routes.ts
@@ -17,6 +17,7 @@ export const ROUTES = {
shopPartners: '/shop/partners',
profile: '/profile',
about: '/about',
+ adminReview: '/admin/review',
} as const;
type AppNavigationParams = {
diff --git a/src/shared/hooks/useMainTabsHostBindings.ts b/src/shared/hooks/useMainTabsHostBindings.ts
index 43e5694..6a8b4a6 100644
--- a/src/shared/hooks/useMainTabsHostBindings.ts
+++ b/src/shared/hooks/useMainTabsHostBindings.ts
@@ -26,6 +26,9 @@ export function useMainTabsHostBindings(navigation: object) {
onPressAbout: () => {
nav.navigate(ROUTES.about);
},
+ onPressAdminReview: () => {
+ nav.navigate(ROUTES.adminReview);
+ },
onPressLogout: () => {
nav.replace(ROUTES.login);
},
diff --git a/src/shared/layout/MainTabsHost.tsx b/src/shared/layout/MainTabsHost.tsx
index 0e29df1..bba8437 100644
--- a/src/shared/layout/MainTabsHost.tsx
+++ b/src/shared/layout/MainTabsHost.tsx
@@ -18,6 +18,7 @@ type MainTabsHostProps = {
onSoupMade: (recipeId: string, craft: SoupCraftResponse) => void;
onPressChangeShop: () => void;
onPressAbout?: () => void;
+ onPressAdminReview?: () => void;
onPressLogout?: () => void;
};
@@ -37,6 +38,7 @@ export function MainTabsHost({
onSoupMade,
onPressChangeShop,
onPressAbout,
+ onPressAdminReview,
onPressLogout,
}: MainTabsHostProps) {
const insets = useSafeAreaInsets();
@@ -104,6 +106,7 @@ export function MainTabsHost({
) : (
)}
@@ -118,6 +121,7 @@ export function MainTabsHost({
onSoupMade,
onPressChangeShop,
onPressAbout,
+ onPressAdminReview,
onPressLogout,
],
);