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, ], );