diff --git a/docs/conventions/coding-style.md b/docs/conventions/coding-style.md index 57b3762b..0997728b 100644 --- a/docs/conventions/coding-style.md +++ b/docs/conventions/coding-style.md @@ -53,6 +53,7 @@ - 서버 데이터는 TanStack Query로 받는다. 조회 정의와 타입은 `shared/entities/<도메인>/`에 역할별 파일로 둔다 — `<도메인>Keys.ts`(조회 key)·`<도메인>Queries.ts`(조회·mutation)·`<도메인>Types.ts`(타입)·`<도메인>Mock.ts`(목데이터). 파일 이름은 레포의 다른 파일처럼 camelCase다. 조회 파일은 `<도메인>Queries` 객체에 조회마다 `queryOptions`를 돌려주는 함수를 두고(`eventsQueries.detail(id)`), 화면은 데이터 컴포넌트에서 `const { data } = useSuspenseQuery(eventsQueries.detail(id))`로 읽는다. 화면 모양으로 바꿔야 하면 화면 쪽에서 `select`를 더한다 — 캐시에는 응답 그대로 둔다. 같은 데이터를 여러 화면이 다른 모양으로 쓰고(내 납부 상태 → 학생회비 화면·행사 상세), 캐시가 Swagger 응답과 같아야 `setQueryData`·디버깅이 단순하다. 여러 화면이 같은 변환을 쓰면 변환 함수를 `shared/entities/<도메인>/`에 하나 두고 `select`로 넘긴다. - 조회 key는 `<도메인>Keys.ts`의 key 함수 하나(`eventsKeys`)로 만든다. key만 따로 둬서 다른 도메인이 캐시를 무효화할 때 조회 파일 전체를 가져오지 않게 한다. 도메인 이름으로 시작하는 배열을 쌓아서(`root()` → `["events"]`, `application(id)` → `["events", "applications", id]`) 앞부분 key 하나로 아래 조회를 함께 무효화할 수 있게 한다. 조회와 무효화가 같은 key 함수를 써야 한쪽만 바뀌어 무효화가 조용히 빗나가지 않는다. - 요청은 `@/shared/api`의 `api.get(url, { signal })`·`api.post(url, body)` 등으로 보낸다. 서버 응답 봉투(`{ success, code, message, data }`)를 벗겨 `data`만 돌려주고, 실패는 `ApiError`(`status`·`code`·`message`)·`NetworkError`·`TimeoutError`로 던진다(`shared/api/errors.ts`). `shared/api` 밖에서는 `@/shared/api`(`index.ts`)로만 가져온다. 화면은 `api`를 직접 부르지 않고 entities의 `queryOptions`·mutation 훅만 쓴다. +- 파일은 `shared/entities/files/filesQueries.ts`의 `useUploadFileMutation`으로 올린다. 서버에서 업로드 URL을 받고(`POST /v1/files/presigned-url`, 용도는 `category`) 그 주소로 파일을 바로 PUT한 뒤, 다른 API에 넘길 파일 키를 돌려준다. 이 PUT만 `apiClient`를 거치지 않는다 — 스토리지 주소라 baseURL·인증 헤더가 붙으면 안 되고 응답도 서버 봉투가 아니다. 서버는 형식을 MIME이 아니라 파일 이름의 확장자로, 크기는 용도별 상한으로 거절하므로 고를 때 먼저 걸러 둔다(학생회비 캡처 `isAcceptableTranscript`). - 인증 토큰은 `shared/api/token.ts`로만 읽고 쓴다. 요청마다 `Authorization: Bearer`가 붙고, 서버가 401을 주면 토큰을 지운 뒤 `subscribeTokens`로 알린다 — 로그인 화면 이동은 알림을 구독하는 가드가 맡는다(요청 코드가 라우터를 import하면 router → 화면 → entities → client 순환이 생긴다). 가드는 로그인 실연결 때 붙인다. - 조회 기본값은 `lib/queryClient.ts`에 있다: 1분 안에 다시 들어온 화면은 요청 없이 캐시를 쓰고(`staleTime`), 실패는 네트워크·5xx만 한 번 더 시도한다(4xx는 다시 보내도 같다). 도메인마다 다르게 해야 하면 그 `queryOptions`에 덮어쓴다. - 실 API 전까지 `queryFn`은 `<도메인>Mock.ts`의 목데이터를 `mockupResponse(() => data)`(`lib/mockupApi.ts`)로 돌려준다. 개발 서버에서는 스켈레톤을 확인할 수 있게 500ms 늦게 응답하고, 배포 빌드에서는 바로 응답한다. API가 붙으면 `queryFn` 안쪽만 `api.get(...)`으로 바꾼다. diff --git a/docs/conventions/wds-component-usage.md b/docs/conventions/wds-component-usage.md index 2fb6a6c9..91deece9 100644 --- a/docs/conventions/wds-component-usage.md +++ b/docs/conventions/wds-component-usage.md @@ -654,7 +654,7 @@ Figma는 헤더 배경이 없어서 배너 그림자(`Shadow/Spread/Small`, 60px |---|---|---| | `Content Badge` (답변완료·답변대기, 납부확인중·확인필요·납부완료, 배정완료) | `ContentBadge size="small" variant="solid" color="accent"` | 홈 내 정보 목록과 같은 상태 이름·색이다. 초록 green, 주황 orange, 빨강 red. 상태→배지 매핑은 `FEE_STATUS_BADGES`(`features/fee/constants`)·`LOCKER_STATUS_BADGES`(`features/lockers/constants`) 한 곳에 두고 홈과 같이 쓴다 | | 작성내역 상세 `해당 회차 답변 모아보기` | `Button color="primary" size="medium" variant="outlined"` | | -| 학생회비 `문의하기`, 사물함 `사물함 위치 보기` | `Button color="assistive" size="medium" variant="outlined" fullWidth` | 위치 보기의 꺾쇠는 `trailingContent={}` | +| 학생회비 `문의하기`·`다시 납부하기`, 사물함 `사물함 위치 보기` | `Button color="assistive" size="medium" variant="outlined" fullWidth` | 위치 보기의 꺾쇠는 `trailingContent={}` | | 빈 상태 버튼 `열린피드백 작성하기` / `열린피드백 보러 가기` | `Button color="assistive" size="small" variant="solid"` | 행사 목록 빈 상태(outlined)와 variant가 다르다 | | 뒤로가기 헤더 | `ScreenHeader variant="normal"` + `TopNavigationButton` | `useBackNavigation(fallbackPath)`(`hooks/`) — 바로 들어와 뒤로 갈 기록이 없으면 홈(상세는 목록, 위치 보기는 배정 상태)으로 보낸다. 대비 경로는 각 기능 `constants`의 경로 상수(`FEE_PAYMENTS_PATH`·`MY_FEEDBACKS_PATH`·`LOCKER_ASSIGNMENT_PATH` 등)를 쓰고, 데이터가 없을 때의 ``도 화면이 넘긴 `fallbackPath`로 간다 | @@ -844,8 +844,10 @@ Figma는 같은 알림 설정 화면을 **설정 섹션(`3524:152367`)과 알림 남은 학기 수 → 학점이수현황 캡처 → 토스 이동 → 송금 확인 → 확인요청 완료를 `@use-funnel/react-router-dom`의 `useFunnel`로 묶었다(라우트 `/fee/transfer`, 단계는 `?fee.step=`). 로그인·가입 퍼널(`AuthSignupScreen`)과 같은 구성이다 — 퍼널 파일(`FeeTransferScreen.tsx`)은 단계 전환만 맡고, 단계 화면은 `features/fee/steps/`에 파일 하나씩(`FeeTransferSemesterStep`, `FeeTransferTranscriptStep`, `FeeTransferTossStep`, `FeeTransferConfirmStep`, `FeeTransferCompleteStep`) 두며 전환은 콜백 prop으로 받는다. 단계·context 타입과 `guard`는 `constants/feeTransferFunnel.ts`에 있고, 단계 화면이 공유하는 뼈대는 `components/FeeTransferStepLayout.tsx`다(뒤로가기는 `onBack` prop으로 받아서 첫 단계는 플로우 밖으로, 나머지는 이전 단계로 간다). -처음에는 단계마다 라우트를 따로 뒀는데(`/fee/transfer/semester` 등 5개), 그러면 단계 사이에 값을 navigate state로 넘겨야 해서 남은 학기 수가 캡처 단계에서 읽히지 않고 고른 파일도 다음 단계로 넘어가지 않았다. 퍼널 context로 바꾸면서 두 값이 플로우 끝까지 이어지고, 토스 단계에서 뒤로 돌아와도 입력이 남는다. 학기 수 입력·파일 선택은 단계를 바꾸지 않고 `history.replace`로 context만 갈아 끼운다(히스토리가 늘지 않음). 앞 단계 값 없이 중간 단계로 들어오면(새로고침·주소 직접 입력) `guard`가 걸러서 첫 단계로 돌려보낸다. +처음에는 단계마다 라우트를 따로 뒀는데(`/fee/transfer/semester` 등 5개), 그러면 단계 사이에 값을 navigate state로 넘겨야 해서 남은 학기 수가 캡처 단계에서 읽히지 않고 고른 파일도 다음 단계로 넘어가지 않았다. 퍼널 context로 바꾸면서 두 값이 플로우 끝까지 이어지고, 토스 단계에서 뒤로 돌아와도 입력이 남는다. 학기 수 입력은 단계를 바꾸지 않고 `history.replace`로 context만 갈아 끼운다(히스토리가 늘지 않음). 앞 단계 값 없이 중간 단계로 들어오면(새로고침·주소 직접 입력) `guard`가 걸러서 첫 단계로 돌려보낸다. -캡처 이미지는 `File`을 context에 그대로 담는다 — `File`은 구조화 복제가 되는 값이라 history.state에 실린다. 다만 history.state는 브라우저마다 크기 상한이 있어서(Firefox 16MiB) 업로드 API가 붙으면 파일 대신 서버가 준 식별자를 담는 쪽으로 바꾸는 게 안전하다. `File`이 history.state에서 되살아나지 못하는 경우도 있어 `guard`가 `instanceof File`로 확인하고, 아니면 첫 단계로 돌려보낸다. +**캡처 이미지(`File`)는 context에 담지 않는다.** context는 history.state에 실리는데, Chromium(Android WebView 포함)은 `File`이 든 history.state를 뒤로가기로 되살리지 못하고 state를 통째로 `null`로 돌려준다 — 처음엔 `File`을 context에 담았다가 토스 단계에서 뒤로 가면 퍼널이 첫 단계로 초기화되는 걸 확인하고 바꿨다. 고른 파일은 퍼널 화면(`FeeTransferScreen`)의 state가 들고 있고(단계를 오가도 화면은 그대로라 남는다), context에는 문자열·숫자만 싣는다. 새로고침하면 캡처만 다시 고르면 된다. 캡처 단계에서 "다음"을 누르면 퍼널이 캡처를 올리고(`useUploadFileMutation`) 송금 링크를 받아(`feeQueries.transferGuide`) 파일 키(`proofImageKey`)와 링크(`transferLinkUrl`)를 다음 단계 context에 싣는다 — 업로드 실패를 송금하기 **전에** 알리기 위해서다. 토스에서 돌아와 같은 파일로 다시 누르면 또 올리지 않는다. + +퍼널은 들어올 때 내 납부 상태(`feeQueries.me`)를 한 번만 보고, 미납·확인필요가 아니면 납부내역으로 보낸다. 들어올 때의 값만 보는 이유는 납부 요청을 보내면 상태가 납부확인중으로 바뀌는데, 그때 완료 단계에서 쫓겨나면 안 되기 때문이다. 단계를 넘기지 못한 실패(업로드·송금 링크·납부 요청)는 퍼널이 토스트 하나로 알린다 — 송금 확인 단계의 실패는 이미 송금한 뒤라 "다시 송금하라"로 읽히지 않게 요청 실패로만 안내한다. 토스 이동 안내/실패(`3562:163025` / `3562:163046`)와 캡처 업로드 전/후(`3562:162845` / `3562:162891`)는 각각 **한 단계 안의 state**다. Figma가 프레임을 나눠 뒀지만 퍼널 단계로는 올리지 않았다 — 뒤로가기로 돌아갈 지점이 아니기 때문이다. diff --git a/package.json b/package.json index 51c7def8..0e101adb 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "lottie-react": "^3.1.2", "react": "^19.2.8", "react-dom": "^19.2.8", - "react-router-dom": "^7.18.3" + "react-router-dom": "^7.18.3", + "zod": "^4.6.7" }, "devDependencies": { "@biomejs/biome": "^2.5.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e77156c8..2ed4e590 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: react-router-dom: specifier: ^7.18.3 version: 7.18.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + zod: + specifier: ^4.6.7 + version: 4.6.7 devDependencies: '@biomejs/biome': specifier: ^2.5.11 @@ -1416,6 +1419,9 @@ packages: resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} engines: {node: '>= 6'} + zod@4.6.7: + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} + zustand@5.0.15: resolution: {integrity: sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==} engines: {node: '>=12.20.0'} @@ -2559,6 +2565,8 @@ snapshots: yaml@1.10.3: {} + zod@4.6.7: {} + zustand@5.0.15(@types/react@19.2.18)(react@19.2.8): optionalDependencies: '@types/react': 19.2.18 diff --git a/src/features/events/components/EventsDetailContent.tsx b/src/features/events/components/EventsDetailContent.tsx index 59b2b3e9..f5fefdfe 100644 --- a/src/features/events/components/EventsDetailContent.tsx +++ b/src/features/events/components/EventsDetailContent.tsx @@ -18,6 +18,7 @@ import { } from "@/features/events/constants/events"; import { eventsQueries } from "@/shared/entities/events/eventsQueries"; import { feeQueries } from "@/shared/entities/fee/feeQueries"; +import type { FeeResponse } from "@/shared/entities/fee/feeTypes"; interface EventsDetailContentProps { eventId: string; @@ -45,8 +46,15 @@ function EventsDetailContent({ }: EventsDetailContentProps) { // 납부 상태는 행사와 따로 기다리지 않게(요청이 줄줄이 이어지지 않게) 한 번에 같이 받는다. // 납부자 전용이 아닌 행사에서는 쓰지 않는다. - const [{ data: event }, { data: feeStatus }] = useSuspenseQueries({ - queries: [eventsQueries.detail(eventId), feeQueries.myStatus()], + const [{ data: event }, { data: isFeePaid }] = useSuspenseQueries({ + queries: [ + eventsQueries.detail(eventId), + // 납부완료만 납부로 본다 — 납부확인중·확인필요는 아직 아니다 + { + ...feeQueries.me(), + select: (fee: FeeResponse) => fee.feeStatus === "PAID", + }, + ], }); if (!event) { @@ -62,7 +70,7 @@ function EventsDetailContent({ ); } - const isFeeRequired = event.feePaidOnly && feeStatus !== "paid"; + const isFeeRequired = event.feePaidOnly && !isFeePaid; const canApply = event.status === "open" && !isFeeRequired; const actionLabel = event.status === "open" && isFeeRequired diff --git a/src/features/fee/FeePaymentsScreen.tsx b/src/features/fee/FeePaymentsScreen.tsx index 27d4fe9b..cf85b1c9 100644 --- a/src/features/fee/FeePaymentsScreen.tsx +++ b/src/features/fee/FeePaymentsScreen.tsx @@ -1,12 +1,16 @@ import { TopNavigationButton } from "@wanteddev/wds"; import { IconChevronLeft } from "@wanteddev/wds-icon"; import { Suspense } from "react"; +import { useNavigate } from "react-router-dom"; import ScreenHeader from "@/components/ui/ScreenHeader"; import { useScreenHeader } from "@/components/ui/useScreenHeader"; import FeePaymentList from "@/features/fee/components/FeePaymentList"; import FeePaymentsSkeleton from "@/features/fee/components/FeePaymentsSkeleton"; -import { FEE_EXTERNAL_LINKS } from "@/features/fee/constants/fee"; +import { + FEE_EXTERNAL_LINKS, + FEE_TRANSFER_PATH, +} from "@/features/fee/constants/fee"; import { HOME_PATH } from "@/features/home/constants/home"; import { useBackNavigation } from "@/hooks/useBackNavigation"; import { openExternalLink } from "@/lib/openExternalLink"; @@ -15,6 +19,7 @@ import { openExternalLink } from "@/lib/openExternalLink"; function FeePaymentsScreen() { // 바로 들어와 뒤로 갈 히스토리가 없으면 홈으로 보낸다 const goBack = useBackNavigation(HOME_PATH); + const navigate = useNavigate(); useScreenHeader( }> - + navigate(FEE_TRANSFER_PATH, { viewTransition: true })} + /> ); diff --git a/src/features/fee/FeeTransferScreen.tsx b/src/features/fee/FeeTransferScreen.tsx index b3add421..46d8e830 100644 --- a/src/features/fee/FeeTransferScreen.tsx +++ b/src/features/fee/FeeTransferScreen.tsx @@ -1,4 +1,14 @@ -import { FEE_EXTERNAL_LINKS } from "@/features/fee/constants/fee"; +import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; +import { Suspense, useRef, useState } from "react"; +import { Navigate } from "react-router-dom"; + +import ScreenToast from "@/components/ui/ScreenToast"; +import FeeTransferSkeleton from "@/features/fee/components/FeeTransferSkeleton"; +import { + FEE_EXTERNAL_LINKS, + FEE_PAYMENTS_PATH, + isAcceptableTranscript, +} from "@/features/fee/constants/fee"; import { FEE_TRANSFER_STEP_OPTIONS, type FeeTransferSteps, @@ -12,14 +22,23 @@ import { HOME_PATH } from "@/features/home/constants/home"; import { useBackNavigation } from "@/hooks/useBackNavigation"; import { openExternalLink } from "@/lib/openExternalLink"; import { useFunnel, useFunnelExit } from "@/lib/useFunnel"; +import { isApiError } from "@/shared/api"; +import { + FEE_ERROR_CODES, + feeQueries, + useSubmitFeeMutation, +} from "@/shared/entities/fee/feeQueries"; +import { useUploadFileMutation } from "@/shared/entities/files/filesQueries"; // 학생회비 계좌 송금(남은 학기 수 → 학점이수현황 캡처 → 토스 이동 → 송금 확인 → 확인요청 완료)을 // useFunnel 하나로 묶은 화면이다. 단계 화면은 `steps/`에 하나씩 나눠 두고, 이 파일은 단계 전환 // (어느 단계에서 어디로, 무슨 값을 넘기는지)만 맡는다 — 로그인·온보딩 퍼널(AuthSignupScreen)과 같은 구성이다. // 단계는 `/fee/transfer?fee.step=...`으로 나뉘고 단계마다 브라우저 뒤로가기가 한 단계씩 돌아간다. -// 단계 사이에 넘기는 값(남은 학기 수·캡처 이미지)은 퍼널 context가 들고 있어서 뒤로 돌아와도 유지된다. +// 단계 사이에 넘기는 값(남은 학기 수·올린 캡처의 파일 키·송금 링크)은 퍼널 context가 들고 있어서 +// 뒤로 돌아와도 유지된다. 고른 캡처 파일만은 context(history.state)에 담지 못해서 이 화면이 메모리에 들고 +// 있는다(feeTransferFunnel.ts 참고) — 새로고침하면 캡처만 다시 고르면 된다. -// 단계가 바뀌는 이동에만 슬라이드를 켠다 — 학기 수 입력·파일 선택처럼 같은 단계의 context만 +// 단계가 바뀌는 이동에만 슬라이드를 켠다 — 학기 수 입력처럼 같은 단계의 context만 // 갈아 끼우는 replace에는 켜지 않는다. 뒤로가기는 React Router가 앞선 이동을 기억해 반대 방향으로 // 자동 적용한다(coding-style.md 라우팅). const SLIDE = { viewTransition: true }; @@ -28,7 +47,25 @@ const SLIDE = { viewTransition: true }; // 값을 다시 계산하지 않게 한다. const INITIAL_STATE = { context: {}, step: "semester" } as const; -function FeeTransferScreen() { +// 단계를 넘기지 못했을 때 띄우는 토스트 문구 +// 송금 확인 단계의 실패는 이미 송금한 뒤라 "다시 송금하라"로 읽히지 않게 요청 실패로만 안내한다 +const TOAST_MESSAGES = { + invalidProofImage: "캡처를 확인하지 못했어요. 다시 올려 주세요.", + invalidTranscript: "10MB 이하의 PNG·JPG·WEBP 이미지만 올릴 수 있어요.", + prepareTossFailed: "다음 단계로 넘어가지 못했어요. 다시 시도해 주세요.", + submitFailed: "납부 확인 요청에 실패했어요. 다시 시도해 주세요.", +} as const; + +function FeeTransferFunnel() { + const { data: entryFeeStatus } = useSuspenseQuery({ + ...feeQueries.me(), + select: (fee) => fee.feeStatus, + }); + // 들어올 때의 상태로만 판단한다 — 납부 요청을 보내면 납부확인중으로 바뀌는데, 그때 완료 단계에서 + // 쫓겨나면 안 된다. 미납·확인필요만 낼 수 있다(서버도 그 외에는 409로 거절한다). + const [canPay] = useState( + () => entryFeeStatus === "UNPAID" || entryFeeStatus === "REJECTED", + ); // 첫 단계의 뒤로가기 — 타고 들어왔으면 한 단계 뒤로, 플로우로 바로 들어왔으면(딥링크·앱 // WebView 진입·주소 직접 입력) 홈으로 보낸다. `navigate(-1)`만 쓰면 먹통이 된다(#107 리뷰). const goBackFromFlow = useBackNavigation(HOME_PATH); @@ -40,63 +77,182 @@ function FeeTransferScreen() { initial: INITIAL_STATE, steps: FEE_TRANSFER_STEP_OPTIONS, }); + const queryClient = useQueryClient(); + const { mutateAsync: uploadFile } = useUploadFileMutation(); + const { mutate: submitFee, isPending: isSubmitting } = useSubmitFeeMutation(); + // 고른 캡처 — 단계를 오가도 이 화면은 그대로 떠 있어서 토스에서 돌아와도 남는다 + const [transcript, setTranscript] = useState(); + // 캡처 단계의 "다음" — 캡처를 올리고 송금 링크를 받는 동안 버튼을 막는다 + const [isPreparingToss, setPreparingToss] = useState(false); + // 마지막으로 올린 캡처와 그 키 — 토스에서 돌아와 같은 파일로 다시 "다음"을 누르면 또 올리지 않는다 + const lastUploadRef = useRef<{ file: File; fileKey: string } | null>(null); + // 닫힌 뒤에도 마지막 문구를 들고 있어서 닫히는 동안 문구가 바뀌지 않는다. + // 떠 있는 동안 다시 띄워도 새로 안내되게 key를 올린다(ScreenToast의 key) + const [toast, setToast] = useState({ isOpen: false, key: 0, message: "" }); + + const showToast = (message: string) => + setToast((prev) => ({ isOpen: true, key: prev.key + 1, message })); + + const uploadTranscript = async (file: File) => { + const lastUpload = lastUploadRef.current; + if (lastUpload?.file === file) { + return lastUpload.fileKey; + } + const fileKey = await uploadFile({ + category: "FEE_TRANSCRIPT_IMAGE", + file, + }); + lastUploadRef.current = { file, fileKey }; + return fileKey; + }; + + // 이미 납부 확인을 요청했거나 납부가 끝났으면 송금까지 다 하고 409를 받기 전에 납부내역으로 보낸다 + if (!canPay) { + return ; + } return ( - ( - exitFunnel(index)} /> - )} - confirm={({ history, index }) => ( - history.back()} - // 확인 요청을 보내면 송금 확인으로 돌아올 일이 없어서 히스토리를 대체한다 - onConfirm={() => history.replace("complete", {}, SLIDE)} - // 송금을 미루고 나가는 길 — 다음에 들어오면 플로우를 처음부터 다시 탄다 - onPostpone={() => exitFunnel(index)} - /> - )} - semester={({ context, history }) => ( - - history.replace("semester", { semester }) - } - onNext={(semester) => history.push("transcript", { semester }, SLIDE)} - /> - )} - toss={({ history }) => ( - history.back()} - onOpenToss={() => { - openExternalLink(FEE_EXTERNAL_LINKS.tossTransfer); - // 토스에 다녀오면 돌아올 화면이 송금 확인 단계다. 딥링크가 실제로 열렸는지는 - // 알 수 없어서 열기만 하고 넘긴다 — 송금하지 못했으면 그 단계에서 빠져나간다. - history.push("confirm", {}, SLIDE); - }} - /> - )} - transcript={({ context, history }) => ( - history.back()} - // 단계를 바꾸지 않고 context만 갈아 끼운다(replace) — 토스를 다녀와도 고른 파일이 남는다 - onChangeTranscript={(transcript) => - history.replace("transcript", (prev) => ({ ...prev, transcript })) - } - onNext={(transcript) => history.push("toss", { transcript }, SLIDE)} - // 주소를 아직 못 받아서 비어 있을 수 있다 — 빈 주소로 열면 빈 탭이 뜬다 - onOpenGuide={() => { - if (FEE_EXTERNAL_LINKS.kSmartMentor) { - openExternalLink(FEE_EXTERNAL_LINKS.kSmartMentor); + <> + ( + exitFunnel(index)} /> + )} + confirm={({ context, history, index }) => ( + history.back()} + onConfirm={() => + submitFee( + { + proofImageKey: context.proofImageKey, + remainingSemesters: context.semester, + }, + { + // 확인 요청을 보내면 송금 확인으로 돌아올 일이 없어서 히스토리를 대체한다 + onSuccess: () => history.replace("complete", {}, SLIDE), + onError: (error) => { + // 이미 요청이 들어가 있다 — 응답을 못 받고 다시 누른 경우라 요청은 된 것으로 본다 + if ( + isApiError(error) && + error.code === FEE_ERROR_CODES.alreadyRequested + ) { + history.replace("complete", {}, SLIDE); + return; + } + console.error(error); + // 올린 캡처를 서버가 못 찾았다 — 같은 키를 다시 쓰지 않게 비우고 캡처 단계로 돌려보낸다 + if ( + isApiError(error) && + error.code === FEE_ERROR_CODES.invalidProofImage + ) { + lastUploadRef.current = null; + showToast(TOAST_MESSAGES.invalidProofImage); + history.go(-2); + return; + } + showToast(TOAST_MESSAGES.submitFailed); + }, + }, + ) + } + // 송금을 미루고 나가는 길 — 다음에 들어오면 플로우를 처음부터 다시 탄다 + onPostpone={() => exitFunnel(index)} + /> + )} + semester={({ context, history }) => ( + + history.replace("semester", { semester }) + } + onNext={(semester) => + history.push("transcript", { semester }, SLIDE) } - }} - /> - )} - /> + /> + )} + toss={({ context, history }) => ( + history.back()} + onOpenToss={() => { + if (!context.transferLinkUrl) { + return; + } + openExternalLink(context.transferLinkUrl); + // 토스에 다녀오면 돌아올 화면이 송금 확인 단계다. 딥링크가 실제로 열렸는지는 + // 알 수 없어서 열기만 하고 넘긴다 — 송금하지 못했으면 그 단계에서 빠져나간다. + history.push("confirm", {}, SLIDE); + }} + /> + )} + transcript={({ context, history }) => ( + history.back()} + onChangeTranscript={(nextTranscript) => { + if (nextTranscript && !isAcceptableTranscript(nextTranscript)) { + showToast(TOAST_MESSAGES.invalidTranscript); + return; + } + setTranscript(nextTranscript); + }} + // 송금하러 가기 전에 캡처를 올려 둔다 — 업로드가 실패하면 송금 전에 알 수 있다. + // 송금 링크는 남은 학기 수마다 금액이 달라서 이때 같이 받는다 + onNext={(file) => { + setPreparingToss(true); + Promise.all([ + uploadTranscript(file), + queryClient.fetchQuery( + feeQueries.transferGuide(context.semester), + ), + ]) + .then(([proofImageKey, { transferLinkUrl }]) => + history.push( + "toss", + { proofImageKey, transferLinkUrl }, + SLIDE, + ), + ) + .catch((error: unknown) => { + console.error(error); + showToast(TOAST_MESSAGES.prepareTossFailed); + }) + .finally(() => setPreparingToss(false)); + }} + // 주소를 아직 못 받아서 비어 있을 수 있다 — 빈 주소로 열면 빈 탭이 뜬다 + onOpenGuide={() => { + if (FEE_EXTERNAL_LINKS.kSmartMentor) { + openExternalLink(FEE_EXTERNAL_LINKS.kSmartMentor); + } + }} + /> + )} + /> + + { + if (!open) { + setToast((prev) => ({ ...prev, isOpen: false })); + } + }} + open={toast.isOpen} + /> + + ); +} + +// 들어올 때 내 납부 상태부터 받는다 — 받는 동안 첫 단계 모양의 스켈레톤을 보여 준다 +function FeeTransferScreen() { + return ( + }> + + ); } diff --git a/src/features/fee/components/FeePaymentCard.tsx b/src/features/fee/components/FeePaymentCard.tsx index 3888e756..ab2e396b 100644 --- a/src/features/fee/components/FeePaymentCard.tsx +++ b/src/features/fee/components/FeePaymentCard.tsx @@ -1,14 +1,20 @@ import { Button, Typography } from "@wanteddev/wds"; import StatusSummaryCard from "@/components/ui/StatusSummaryCard"; -import { FEE_STATUS_BADGES } from "@/features/fee/constants/fee"; +import { FEE_STATUS_BADGES, FEE_TITLE } from "@/features/fee/constants/fee"; import { formatDateTime } from "@/lib/date"; -import type { FeePayment } from "@/shared/entities/fee/feeTypes"; +import type { + FeePaymentStatus, + FeeResponse, +} from "@/shared/entities/fee/feeTypes"; interface FeePaymentCardProps { - payment: FeePayment; + /** 납부를 요청한 적이 있는 응답 — 미납(UNPAID)은 그릴 내역이 없어서 목록이 거른다 */ + payment: FeeResponse & { feeStatus: FeePaymentStatus }; /** 없으면 문의하기 버튼을 그리지 않는다(채널 주소가 아직 없을 때) */ onContact?: () => void; + /** 확인필요일 때 "다시 납부하기" — 송금 플로우를 처음(남은 학기 수)부터 다시 탄다 */ + onRepay: () => void; } function formatAmount(value: number | null) { @@ -18,10 +24,15 @@ function formatAmount(value: number | null) { // Figma: 학생회비 납부내역 납부확인중 (nodeId 3147:147860), 확인필요 (3147:147879), 납부완료 (3147:147841) // 납부 요약은 공용 StatusSummaryCard이고, 그 아래에 확인필요일 때만 학생회 확인 내용 카드(학생회비 로컬)가 붙는다. // 행 간격은 Figma(8px)가 아니라 다른 요약 카드와 같은 6px다 — Figma 쪽 실수로 보고 맞췄다(#120). -function FeePaymentCard({ payment, onContact }: FeePaymentCardProps) { +// "다시 납부하기"는 Figma에 없는 버튼이라 같은 카드의 "문의하기"와 같은 모양으로 그 아래에 둔다. +function FeePaymentCard({ payment, onContact, onRepay }: FeePaymentCardProps) { + // 확인필요면 서버가 금액을 비워서 준다(다시 낼 때 학기 수부터 새로 받기 때문) — 그때는 "-"다 const rows = [ - { label: "납부금액", value: formatAmount(payment.amount) }, - { label: "납부일시", value: formatDateTime(payment.paidAt) }, + { label: "납부금액", value: formatAmount(payment.transferAmount) }, + { + label: "납부일시", + value: payment.requestedAt ? formatDateTime(payment.requestedAt) : "-", + }, { label: "확인일시", value: payment.confirmedAt ? formatDateTime(payment.confirmedAt) : "-", @@ -31,12 +42,12 @@ function FeePaymentCard({ payment, onContact }: FeePaymentCardProps) { return (
- {payment.status === "needs-check" && ( + {payment.feeStatus === "REJECTED" && (
- {payment.reviewNote} + {payment.rejectReason}
{onContact && ( @@ -67,6 +78,15 @@ function FeePaymentCard({ payment, onContact }: FeePaymentCardProps) { 문의하기 )} +
)}
diff --git a/src/features/fee/components/FeePaymentList.tsx b/src/features/fee/components/FeePaymentList.tsx index 696a940d..9b0b2e90 100644 --- a/src/features/fee/components/FeePaymentList.tsx +++ b/src/features/fee/components/FeePaymentList.tsx @@ -1,26 +1,46 @@ import { useSuspenseQuery } from "@tanstack/react-query"; +import emptyEventsIllustration from "@/assets/icons/common/empty-events.svg"; +import EmptyState from "@/components/ui/EmptyState"; import FeePaymentCard from "@/features/fee/components/FeePaymentCard"; import { feeQueries } from "@/shared/entities/fee/feeQueries"; interface FeePaymentListProps { /** 없으면 문의하기 버튼을 그리지 않는다(채널 주소가 아직 없을 때) */ onContact?: () => void; + onRepay: () => void; } -// 학기별 납부 내역을 최신순으로 쌓는다. Figma는 한 건만 그렸고, 여러 건이면 건 사이를 카드 간격보다 넓게(12px) 둔다. -function FeePaymentList({ onContact }: FeePaymentListProps) { - const { data: payments } = useSuspenseQuery(feeQueries.payments()); +// 서버가 학생의 최신 납부 요청 한 건만 주므로 카드도 한 장만 그린다. +function FeePaymentList({ onContact, onRepay }: FeePaymentListProps) { + const { data: payment } = useSuspenseQuery({ + ...feeQueries.me(), + // 한 번도 요청하지 않았으면(UNPAID) 그릴 내역이 없다 + select: (fee) => + fee.feeStatus === "UNPAID" ? null : { ...fee, feeStatus: fee.feeStatus }, + }); - return ( -
- {payments.map((payment) => ( - + - ))} +
+ ); + } + + return ( +
+
); } diff --git a/src/features/fee/components/FeeTransferSkeleton.tsx b/src/features/fee/components/FeeTransferSkeleton.tsx new file mode 100644 index 00000000..576c3338 --- /dev/null +++ b/src/features/fee/components/FeeTransferSkeleton.tsx @@ -0,0 +1,50 @@ +import { Skeleton } from "@wanteddev/wds"; + +import { + useScreenHeaderSkeleton, + useSkeletonAnimation, +} from "@/components/ui/ScreenSkeleton"; + +// 송금 퍼널에 들어올 때 내 납부 상태를 받는 동안 보이는 자리 — 첫 단계(남은 학기 수)의 +// 두 줄 제목, 설명, 입력칸 배치를 따른다. 간격은 FeeTransferStepLayout(헤더 아래 12px, 본문 16px)과 같다. +function FeeTransferSkeleton() { + const animation = useSkeletonAnimation(); + useScreenHeaderSkeleton(); + + return ( +
+
+ + + +
+ +
+ ); +} + +export default FeeTransferSkeleton; diff --git a/src/features/fee/constants/fee.ts b/src/features/fee/constants/fee.ts index ed59ca39..b781e7ac 100644 --- a/src/features/fee/constants/fee.ts +++ b/src/features/fee/constants/fee.ts @@ -9,29 +9,47 @@ export const FEE_TRANSFER_PATH = "/fee/transfer"; // 학생회비 납부내역 화면 경로 export const FEE_PAYMENTS_PATH = "/my/fee"; +// 납부내역 카드 제목 — 서버 응답에 없는 값이라 화면이 들고 있는다 +export const FEE_TITLE = "소프트웨어융합대학 학생회비"; + // 아직 받지 못한 외부 연동 주소들. 값이 있을 때만 열도록 해서 빈 창이 뜨는 걸 막는다. // // - kSmartMentor: 학점이수현황을 확인하는 국민대 K-스마트멘토 주소 // - studentCouncilKakaoChannel: 학생회 카카오톡 채널. 비어 있으면 확인필요 카드에 // 문의하기 버튼을 그리지 않는다. -// - tossTransfer: 계좌·금액이 채워진 토스 송금 딥링크. 학생회 계좌와 납부 금액 규칙이 정해져야 -// 만들 수 있다(남은 학기 수에 따라 금액이 달라진다). +// +// 토스 송금 딥링크는 남은 학기 수마다 금액이 달라서 서버가 계산해 준다(feeQueries.transferGuide). export const FEE_EXTERNAL_LINKS = { kSmartMentor: "", studentCouncilKakaoChannel: "", - tossTransfer: "", } as const; // 업로드 받는 이미지 형식 — 학점이수현황 "캡처"라 스크린샷 확장자만 받는다 export const FEE_TRANSCRIPT_ACCEPT = "image/png,image/jpeg,image/webp"; +// 서버 업로드 정책(FileUploadPolicy의 FEE_TRANSCRIPT_IMAGE)과 같은 값. 서버는 MIME이 아니라 +// 파일 이름의 확장자로 판단해서, `accept`를 통과해도 이름에 확장자가 없으면 거절한다. +const FEE_TRANSCRIPT_EXTENSIONS = ["jpg", "jpeg", "png", "webp"]; +const FEE_TRANSCRIPT_MAX_BYTES = 10 * 1024 * 1024; + +// 서버가 받아 줄 캡처인지 — 송금하러 가기 전에 걸러야 해서 고를 때 먼저 본다 +export function isAcceptableTranscript(file: File): boolean { + const extension = file.name.split(".").pop()?.toLowerCase() ?? ""; + return ( + file.name.includes(".") && + FEE_TRANSCRIPT_EXTENSIONS.includes(extension) && + file.size > 0 && + file.size <= FEE_TRANSCRIPT_MAX_BYTES + ); +} + // 학생회비 납부 상태 배지 — 납부내역 카드와 홈 내 정보 목록(HomeInfoList)이 같이 쓴다 // Figma: 학생회비 납부 여부 뱃지 (nodeId 3147:146475) export const FEE_STATUS_BADGES: Record< FeePaymentStatus, { label: string; color: "orange" | "red" | "green" } > = { - checking: { color: "orange", label: "납부확인중" }, - "needs-check": { color: "red", label: "확인필요" }, - paid: { color: "green", label: "납부완료" }, + PAID: { color: "green", label: "납부완료" }, + PENDING: { color: "orange", label: "납부확인중" }, + REJECTED: { color: "red", label: "확인필요" }, }; diff --git a/src/features/fee/constants/feeTransferFunnel.ts b/src/features/fee/constants/feeTransferFunnel.ts index c06fb62c..77d381e2 100644 --- a/src/features/fee/constants/feeTransferFunnel.ts +++ b/src/features/fee/constants/feeTransferFunnel.ts @@ -3,15 +3,23 @@ // Figma: 남은 학기 수(nodeId 3562:162833) / 학점이수현황 업로드(3562:162845) / // 토스 이동 안내(3562:163025) / 송금 확인(3562:162973) / 확인요청 완료(3562:163013). // -// 캡처 이미지는 `File`을 context에 그대로 담는다 — `File`은 구조화 복제가 되는 값이라 -// history.state에 실려서, 토스 단계에서 뒤로 돌아와도 고른 파일이 남는다(별도 저장소 불필요). -// 다만 history.state는 브라우저마다 크기 상한이 있어서(Firefox 16MiB) 업로드 API가 붙으면 -// 파일 대신 서버가 준 식별자를 담는 쪽으로 바꾸는 게 안전하다. +// context는 history.state에 실려서 문자열·숫자만 담는다. 캡처 이미지(`File`)는 담지 않는다 — +// Chromium(Android WebView 포함)은 `File`이 든 history.state를 뒤로가기로 되살리지 못하고 state를 +// 통째로 null로 돌려줘서, 토스 단계에서 뒤로 가면 퍼널이 첫 단계로 초기화됐다. 고른 파일은 퍼널 화면 +// (FeeTransferScreen)이 메모리에 들고 있는다. +// +// 캡처 단계에서 "다음"을 누르면 캡처를 서버에 올리고 토스 송금 링크를 받아 함께 싣는다. +// - `proofImageKey`: 올린 캡처의 파일 키 — 송금 확인에서 납부 요청에 넘긴다 +// - `transferLinkUrl`: 서버가 링크를 주지 않으면(지금 낼 수 없는 상태) null이고, 토스 단계가 이동 실패 화면을 띄운다 export type FeeTransferSteps = { semester: { semester?: number }; - transcript: { semester: number; transcript?: File }; - toss: { semester: number; transcript: File }; - confirm: { semester: number; transcript: File }; + transcript: { semester: number }; + toss: { + semester: number; + proofImageKey: string; + transferLinkUrl: string | null; + }; + confirm: { semester: number; proofImageKey: string }; complete: Record; }; @@ -19,17 +27,19 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } -// 남은 학기 수로 받을 수 있는 값. 0학기는 납부할 이유가 없고, 상한은 실제 학적 기준이 없어 -// 두 자리까지만 받는다 — 서버 검증 규칙이 정해지면 맞춘다. -export const SEMESTER_PATTERN = /^[1-9][0-9]?$/; +// 남은 학기 수로 받을 수 있는 값 — 서버 회비 정책(RemainingSemesters)과 같은 1~8이다. +export const SEMESTER_PATTERN = /^[1-8]$/; function isSemester(value: unknown): value is number { return typeof value === "number" && SEMESTER_PATTERN.test(String(value)); } +function isTransferLinkUrl(value: unknown): value is string | null { + return value === null || typeof value === "string"; +} + // 새로고침이나 주소 직접 입력처럼 앞 단계의 값이 없는 채로 중간 단계에 들어오면 guard가 걸러서 -// 첫 단계(남은 학기 수)로 돌려보낸다 — 학기 수나 캡처 없이 토스 이동 화면이 열리는 일이 없다. -// `File`은 history.state에서 되살아나지 못하는 경우가 있어 `instanceof`로 확인한다. +// 첫 단계(남은 학기 수)로 돌려보낸다 — 학기 수나 올린 캡처 없이 토스 이동 화면이 열리는 일이 없다. export const FEE_TRANSFER_STEP_OPTIONS = { complete: { guard: (data: unknown): data is FeeTransferSteps["complete"] => @@ -39,7 +49,7 @@ export const FEE_TRANSFER_STEP_OPTIONS = { guard: (data: unknown): data is FeeTransferSteps["confirm"] => isRecord(data) && isSemester(data.semester) && - data.transcript instanceof File, + typeof data.proofImageKey === "string", }, semester: { guard: (data: unknown): data is FeeTransferSteps["semester"] => @@ -50,12 +60,11 @@ export const FEE_TRANSFER_STEP_OPTIONS = { guard: (data: unknown): data is FeeTransferSteps["toss"] => isRecord(data) && isSemester(data.semester) && - data.transcript instanceof File, + typeof data.proofImageKey === "string" && + isTransferLinkUrl(data.transferLinkUrl), }, transcript: { guard: (data: unknown): data is FeeTransferSteps["transcript"] => - isRecord(data) && - isSemester(data.semester) && - (data.transcript === undefined || data.transcript instanceof File), + isRecord(data) && isSemester(data.semester), }, }; diff --git a/src/features/fee/steps/FeeTransferConfirmStep.tsx b/src/features/fee/steps/FeeTransferConfirmStep.tsx index 581526ca..58d3fe6c 100644 --- a/src/features/fee/steps/FeeTransferConfirmStep.tsx +++ b/src/features/fee/steps/FeeTransferConfirmStep.tsx @@ -12,10 +12,13 @@ import FeeTransferStepLayout from "@/features/fee/components/FeeTransferStepLayo // Lottie로 받아 재생한다. 플러그인이 이징을 매 프레임으로 구워서 키프레임만 보면 끝을 알 수 // 없는데, 실제 값은 49프레임(0.82초)부터 고정이다 — rotation 6° → -2.5° → 1° → 0°로 잦아든다. function FeeTransferConfirmStep({ + isSubmitting, onBack, onConfirm, onPostpone, }: { + /** 납부 요청을 보내는 중인지 — 그동안 두 버튼을 모두 막는다 */ + isSubmitting: boolean; onBack: () => void; onConfirm: () => void; /** 송금을 미루고 플로우를 빠져나간다 */ @@ -25,13 +28,18 @@ function FeeTransferConfirmStep({ - + 네, 송금했어요 {/* 송금을 미루고 빠져나가는 길 — 플로우를 처음부터 다시 타도록 홈으로 보낸다. Figma Sub Action은 44px인데 WDS 텍스트 버튼은 28px이라 세로 padding을 보정하고, WDS가 따로 주는 세로 margin 8px은 지운다(안 지우면 Figma의 8px 간격이 16px이 된다). */} void; /** - * 고르거나 지울 때마다 퍼널 context를 갈아 끼운다 — 로컬 state에 같은 값을 또 두지 않는다. - * `history.replace`가 새 context로 다시 그려 주므로 이 화면은 context만 읽으면 된다. + * 고르거나 지울 때마다 퍼널에 알린다 — 로컬 state에 같은 값을 또 두지 않는다. + * 퍼널이 바뀐 파일로 다시 그려 주므로 이 화면은 `transcript`만 읽으면 된다. */ onChangeTranscript: (transcript: File | undefined) => void; onNext: (transcript: File) => void; @@ -61,6 +63,7 @@ function FeeTransferTranscriptStep({ actions={ transcript && onNext(transcript)} sx={{ paddingBlock: "16px" }} > diff --git a/src/features/home/components/HomeInfoList.tsx b/src/features/home/components/HomeInfoList.tsx index 7dfe4b9b..b5473d6f 100644 --- a/src/features/home/components/HomeInfoList.tsx +++ b/src/features/home/components/HomeInfoList.tsx @@ -132,7 +132,7 @@ function HomeInfoList({ return (
- {feeStatus === "unpaid" ? ( + {feeStatus === "UNPAID" ? ( = { applyCards: [], myInfo: { feedbackCount: 1, - feeStatus: "unpaid", + feeStatus: "UNPAID", lockerLabel: "B-21", lockerStatus: "assigned", }, @@ -161,7 +158,7 @@ const HOME_DATA: Record = { applyCards: [SPORTS_DAY, LOCKER_2026], myInfo: { feedbackCount: 0, - feeStatus: "unpaid", + feeStatus: "UNPAID", lockerLabel: "B-21", lockerStatus: "assigned", }, @@ -195,7 +192,7 @@ const HOME_DATA: Record = { applyCards: [SPORTS_DAY], myInfo: { feedbackCount: 1, - feeStatus: "checking", + feeStatus: "PENDING", lockerLabel: "B-21", lockerStatus: "assigned", }, diff --git a/src/lib/date.ts b/src/lib/date.ts index 040c204c..477c633b 100644 --- a/src/lib/date.ts +++ b/src/lib/date.ts @@ -19,7 +19,8 @@ export function formatDate(date: string, options: DateFormatOptions = {}) { } // "2026-09-05T13:59" → "2026.09.05 13:59", weekday면 "2026.09.05(토) 13:59" +// 서버(LocalDateTime)는 초와 소수 초까지 내려 주므로("13:59:00.123456") 시:분까지만 남긴다 export function formatDateTime(value: string, options: DateFormatOptions = {}) { const [date, time] = value.split("T"); - return `${formatDate(date, options)} ${time}`; + return `${formatDate(date, options)} ${time.slice(0, 5)}`; } diff --git a/src/shared/entities/fee/feeKeys.ts b/src/shared/entities/fee/feeKeys.ts index 067b5e4b..a2a950d9 100644 --- a/src/shared/entities/fee/feeKeys.ts +++ b/src/shared/entities/fee/feeKeys.ts @@ -1,5 +1,6 @@ export const feeKeys = { root: () => ["fee"] as const, - payments: () => [...feeKeys.root(), "payments"] as const, - myStatus: () => [...feeKeys.root(), "me"] as const, + me: () => [...feeKeys.root(), "me"] as const, + transferGuide: (remainingSemesters: number) => + [...feeKeys.root(), "transfer-guide", remainingSemesters] as const, }; diff --git a/src/shared/entities/fee/feeMock.ts b/src/shared/entities/fee/feeMock.ts deleted file mode 100644 index 5a9e382d..00000000 --- a/src/shared/entities/fee/feeMock.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { FeePayment, MyFeeStatus } from "@/shared/entities/fee/feeTypes"; - -const TITLE = "소프트웨어융합대학 학생회비"; - -// Figma: 학생회비 납부내역 납부확인중 (nodeId 3147:147860), 확인필요 (3147:147879), 납부완료 (3147:147841) -const CHECKING: FeePayment = { - amount: 150000, - confirmedAt: null, - id: "2026-2", - paidAt: "2026-09-04T11:49", - status: "checking", - title: TITLE, -}; - -const NEEDS_CHECK: FeePayment = { - amount: null, - confirmedAt: null, - id: "2026-2", - paidAt: "2026-09-04T11:49", - reviewNote: - "입금자 정보를 확인하지 못했어요. 문제가 있을 경우 학생회에 문의해 주세요.", - status: "needs-check", - title: TITLE, -}; - -const PAID: FeePayment = { - amount: 150000, - confirmedAt: "2026-09-05T13:30", - id: "2026-2", - paidAt: "2026-09-04T11:49", - status: "paid", - title: TITLE, -}; - -// 여러 학기 내역이 쌓인 경우 — 최신순 -const MULTIPLE: FeePayment[] = [ - NEEDS_CHECK, - { - ...PAID, - confirmedAt: "2026-03-05T13:30", - id: "2026-1", - paidAt: "2026-03-04T11:49", - }, -]; - -// 상태를 바꿔 납부내역 화면을 확인한다 -type FeeMockState = "checking" | "needs-check" | "paid" | "multiple"; -const FEE_MOCK_STATE: FeeMockState = "multiple"; - -const FEE_PAYMENTS: Record = { - checking: [CHECKING], - multiple: MULTIPLE, - "needs-check": [NEEDS_CHECK], - paid: [PAID], -}; - -export const FEE_PAYMENTS_MOCK = FEE_PAYMENTS[FEE_MOCK_STATE]; - -// 상태를 바꿔 행사 상세의 학생회비 납부자 전용 처리를 확인한다. unpaid·pending이면 신청 버튼이 막힌다. -export const MY_FEE_STATUS_MOCK: MyFeeStatus = "paid"; diff --git a/src/shared/entities/fee/feeQueries.ts b/src/shared/entities/fee/feeQueries.ts index 3d763c05..f0e59f1c 100644 --- a/src/shared/entities/fee/feeQueries.ts +++ b/src/shared/entities/fee/feeQueries.ts @@ -1,23 +1,96 @@ -import { queryOptions } from "@tanstack/react-query"; +import { + queryOptions, + useMutation, + useQueryClient, +} from "@tanstack/react-query"; +import { z } from "zod"; -import { mockupResponse } from "@/lib/mockupApi"; +import { api, isApiError } from "@/shared/api"; import { feeKeys } from "@/shared/entities/fee/feeKeys"; -import { - FEE_PAYMENTS_MOCK, - MY_FEE_STATUS_MOCK, -} from "@/shared/entities/fee/feeMock"; +import type { FeeSubmitRequest } from "@/shared/entities/fee/feeTypes"; + +const FEE_PATH = "/v1/app/fee"; + +export const FEE_STATUSES = ["UNPAID", "PENDING", "PAID", "REJECTED"] as const; -// 실 API가 붙으면 queryFn 안쪽만 요청 코드로 바꾼다 — 화면은 이 queryOptions로만 데이터를 받는다. +// 내 학생회비 납부 현황(서버 FeeResponse). 학생회비 조회·납부 요청이 같은 모양으로 준다. +// 금액·송금 링크는 지금 낼 수 있을 때(UNPAID·REJECTED에 남은 학기 수를 보냈을 때)만 계산해 주고, +// PENDING·PAID면 요청 때 남긴 금액만 준다(링크는 null). 일시는 시간대 없는 서버 시각("2026-09-04T11:49:00")이다. +export const feeResponseSchema = z.looseObject({ + // 한 번도 요청하지 않았으면 null + studentTransferStatusId: z.number().nullable(), + feeStatus: z.enum(FEE_STATUSES), + // 계좌를 설정하기 전이면 서버가 이 응답 대신 404(FEE_ACCOUNT_NOT_FOUND)를 준다 + bank: z.string(), + accountNo: z.string(), + remainingSemesters: z.number().nullable(), + transferAmount: z.number().nullable(), + // 금액이 채워진 토스 송금 딥링크(supertoss://send?…) + transferLinkUrl: z.string().nullable(), + requestedAt: z.string().nullable(), + // 납부완료(PAID)일 때만 있다 + confirmedAt: z.string().nullable(), + // 확인필요(REJECTED)일 때만 있다 — 그때는 서버가 항상 채운다 + rejectReason: z.string().nullable(), +}); + +// 화면은 이 queryOptions로만 데이터를 받는다. 화면 모양으로 바꿔야 하면 화면 쪽에서 select를 더한다. export const feeQueries = { - payments: () => + // 내 납부 현황(GET /v1/app/fee/me) — 납부내역과 행사 상세의 학생회비 납부자 전용 판단이 같이 쓴다. + me: () => queryOptions({ - queryKey: feeKeys.payments(), - queryFn: () => mockupResponse(() => FEE_PAYMENTS_MOCK), + queryKey: feeKeys.me(), + queryFn: async ({ signal }) => + feeResponseSchema.parse( + await api.get(`${FEE_PATH}/me`, { signal }), + ), }), - // 내 납부 상태(GET /v1/app/fee/me) — 학생회비 납부자 전용 행사의 신청 가능 여부에 쓴다. - myStatus: () => + // 남은 학기 수로 계산한 금액과 토스 송금 링크 — 같은 API에 학기 수를 실어 보낸다(서버는 아무것도 저장하지 않는다). + // 지금 낼 수 없는 상태(납부확인중·납부완료)면 링크가 null이다. + transferGuide: (remainingSemesters: number) => queryOptions({ - queryKey: feeKeys.myStatus(), - queryFn: () => mockupResponse(() => MY_FEE_STATUS_MOCK), + queryKey: feeKeys.transferGuide(remainingSemesters), + queryFn: async ({ signal }) => + feeResponseSchema.parse( + await api.get(`${FEE_PATH}/me`, { + params: { remainingSemesters }, + signal, + }), + ), }), }; + +// 납부 요청에서 화면이 갈라 처리하는 서버 에러 코드(FeeErrorCode) +export const FEE_ERROR_CODES = { + // 이미 납부 확인을 요청했거나 납부가 끝난 학생이 또 냈다 + alreadyRequested: "FEE_REQUEST_ALREADY_REQUESTED", + // 캡처 키가 없거나, 본인이 학점이수현황 캡처로 올린 파일이 아니다 + invalidProofImage: "INVALID_PROOF_IMAGE", +} as const; + +// 학생회비 납부 요청(POST /v1/app/fee). 성공하면 납부확인중이 된다. +// 납부 상태와 학기 수별 송금 링크가 모두 바뀌어서 회비 캐시를 통째로 무효화한다 — 이미 요청이 들어가 있다는 +// 응답(409)도 지금 캐시가 낡았다는 뜻이라 같이 무효화한다. +export function useSubmitFeeMutation() { + const queryClient = useQueryClient(); + const invalidateFee = () => + queryClient.invalidateQueries({ queryKey: feeKeys.root() }); + + return useMutation({ + // 응답(납부 현황)은 쓰지 않는다 — 무효화로 다시 받는 me 조회가 스키마로 검사한다. + // 여기서도 검사하면 서버에 이미 접수된 요청을 응답 모양 때문에 실패로 안내하게 된다. + mutationFn: async (request: FeeSubmitRequest) => { + await api.post(FEE_PATH, request); + }, + // Promise를 돌려줘서 다시 받은 상태가 들어온 뒤에 mutation이 끝나게 한다 + onSuccess: invalidateFee, + onError: (error) => { + if ( + isApiError(error) && + error.code === FEE_ERROR_CODES.alreadyRequested + ) { + return invalidateFee(); + } + }, + }); +} diff --git a/src/shared/entities/fee/feeTypes.ts b/src/shared/entities/fee/feeTypes.ts index 022cabdc..81f1b879 100644 --- a/src/shared/entities/fee/feeTypes.ts +++ b/src/shared/entities/fee/feeTypes.ts @@ -1,22 +1,23 @@ -// 납부확인중 · 확인필요 · 납부완료 — 미납이면 내역이 없다(홈에서 납부하기로 안내) -export type FeePaymentStatus = "checking" | "needs-check" | "paid"; +import type { z } from "zod"; -interface FeePaymentBase { - id: string; - title: string; - // 확인 전이거나 입금자를 확인하지 못하면 금액·확인일시가 비어 있다 - amount: number | null; - paidAt: string; - confirmedAt: string | null; -} +import type { + FEE_STATUSES, + feeResponseSchema, +} from "@/shared/entities/fee/feeQueries"; + +// 학생 본인의 학생회비 납부 상태 — 서버(GET /v1/app/fee/me의 feeStatus) 문자열 그대로 쓴다. +// 한 번도 요청하지 않았으면 UNPAID(서버 DB에는 없고 응답에서만 쓰는 상태)다. 납부확인중(PENDING)은 아직 납부로 보지 않는다. +export type FeeStatus = (typeof FEE_STATUSES)[number]; -// 학생회 확인 내용은 확인필요일 때만 온다 — 상태로 갈라 두어 화면이 상태만 보고 판단하게 한다 -export type FeePayment = FeePaymentBase & - ( - | { status: "checking" | "paid" } - | { status: "needs-check"; reviewNote: string } - ); +// 납부를 요청한 뒤의 상태(납부확인중 · 확인필요 · 납부완료) — 미납이면 내역이 없다(홈에서 납부하기로 안내) +export type FeePaymentStatus = Exclude; -// 학생 본인의 학생회비 납부 상태 — GET /v1/app/fee/me의 feeStatus(UNPAID·PENDING·PAID). -// 납부확인중(pending)은 아직 납부로 보지 않는다. -export type MyFeeStatus = "unpaid" | "pending" | "paid"; +// 내 학생회비 납부 현황(서버 FeeResponse) — 필드 설명은 feeQueries의 스키마에 있다 +export type FeeResponse = z.infer; + +// POST /v1/app/fee 요청 — 송금을 마친 뒤 남은 학기 수와 그 근거인 학점이수현황 캡처를 함께 낸다 +export interface FeeSubmitRequest { + remainingSemesters: number; + /** 업로드 때 받은 캡처의 파일 키 */ + proofImageKey: string; +} diff --git a/src/shared/entities/files/filesQueries.ts b/src/shared/entities/files/filesQueries.ts new file mode 100644 index 00000000..76266eed --- /dev/null +++ b/src/shared/entities/files/filesQueries.ts @@ -0,0 +1,44 @@ +import { useMutation } from "@tanstack/react-query"; +import axios from "axios"; +import { z } from "zod"; + +import { api } from "@/shared/api"; +import type { FileCategory } from "@/shared/entities/files/filesTypes"; + +// POST /v1/files/presigned-url 응답(서버 FileUploadUrlIssueResponse) +export const fileUploadUrlResponseSchema = z.looseObject({ + fileId: z.number(), + // 스토리지에 파일 본문을 바로 PUT하는 서명된 주소 + uploadUrl: z.string(), + // 다른 API(학생회비 납부 요청 등)에 넘기는 파일 키 + fileKey: z.string(), + expiresAt: z.string(), +}); + +interface UploadFileVariables { + file: File; + category: FileCategory; +} + +// 파일을 스토리지에 올리고, 다른 API에 넘길 파일 키를 돌려준다. +// 서버에서 업로드 URL을 받은 뒤(POST /v1/files/presigned-url) 그 주소로 파일 본문을 바로 PUT한다. +// PUT은 apiClient를 거치지 않는다 — 스토리지 주소라 baseURL·인증 헤더가 붙으면 안 되고, 응답도 서버 봉투가 아니다. +export function useUploadFileMutation() { + return useMutation({ + mutationFn: async ({ file, category }: UploadFileVariables) => { + const { fileKey, uploadUrl } = fileUploadUrlResponseSchema.parse( + await api.post("/v1/files/presigned-url", { + category, + contentType: file.type, + fileSize: file.size, + originalName: file.name, + }), + ); + // 서명에 Content-Type이 들어가 있어서 발급 때 보낸 값과 같아야 한다 + await axios.put(uploadUrl, file, { + headers: { "Content-Type": file.type }, + }); + return fileKey; + }, + }); +} diff --git a/src/shared/entities/files/filesTypes.ts b/src/shared/entities/files/filesTypes.ts new file mode 100644 index 00000000..10c87778 --- /dev/null +++ b/src/shared/entities/files/filesTypes.ts @@ -0,0 +1,10 @@ +import type { z } from "zod"; + +import type { fileUploadUrlResponseSchema } from "@/shared/entities/files/filesQueries"; + +// 업로드 URL을 받을 때 밝히는 파일 용도(서버 FileCategory) — 용도마다 서버가 받는 형식·용량이 다르다. +// 앱이 올리는 용도만 둔다. +export type FileCategory = "FEE_TRANSCRIPT_IMAGE"; + +// POST /v1/files/presigned-url 응답(서버 FileUploadUrlIssueResponse) +export type FileUploadUrlResponse = z.infer;