[FEAT] 강사 새 외주 작성 및 마이페이지 API 연동#50
Conversation
|
Important Review skippedAuto incremental reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthrough강사 마이페이지의 통계/외주내역 조회가 mock 데이터에서 API 호출(getMyInfo, getCommissions) 기반으로 전환되고, 새 외주 작성 흐름에 파일 업로드(presigned URL), 외주 생성(postCommission), 입금 통보(postNotifyDeposit) API가 추가됩니다. 요청 스키마와 폼 스토어, 결제 모달 제출/에러 처리, 날짜/유효성 검증 로직 및 일부 문구가 함께 변경됩니다. Changes마이페이지 API 연동
Estimated code review effort: 3 (Moderate) | ~25 minutes 새 외주 작성 API 연동
Estimated code review effort: 4 (Complex) | ~55 minutes Sequence Diagram(s)sequenceDiagram
participant Step1 as Step1(약관 동의)
participant PaymentModal
participant WriteFormStore
participant WriteAPI as write API(postCommission)
participant Step2
participant DepositAPI as write API(postNotifyDeposit)
Step1->>PaymentModal: handlePay 호출
PaymentModal->>WriteFormStore: getOrderRequest()
WriteFormStore-->>PaymentModal: WriteOrderRequest
PaymentModal->>WriteAPI: postCommission(request)
WriteAPI-->>PaymentModal: CreateCommissionResult(commissionId)
PaymentModal->>Step2: step=2, commissionId 전달
Step2->>DepositAPI: postNotifyDeposit(commissionId)
DepositAPI-->>Step2: 성공/실패
Step2-->>Step2: 성공 시 /instructor로 이동, 실패 시 에러 토스트 표시
sequenceDiagram
participant AttachFileSection as 첨부파일 섹션
participant UploadedFilesHook as useUploadedFiles
participant WriteAPI as write API(uploadCommissionFile)
participant FileAPI as shared/api/file
participant Storage as Presigned URL 대상 스토리지
AttachFileSection->>UploadedFilesHook: 파일 추가
UploadedFilesHook->>WriteAPI: uploadCommissionFile(file, target)
WriteAPI->>FileAPI: postFilePresignedUrl(target, contentType)
FileAPI-->>WriteAPI: presignedUrl, key
WriteAPI->>Storage: uploadFileToPresignedUrl(file, presignedUrl)
Storage-->>WriteAPI: 업로드 결과
WriteAPI-->>UploadedFilesHook: key 반환
UploadedFilesHook-->>AttachFileSection: 파일 상태 갱신 또는 실패 콜백
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 7
🧹 Nitpick comments (7)
src/shared/lib/hooks/useUploadedFiles.ts (1)
6-12: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value옵셔널 위치 매개변수 4개는 확장성이 떨어집니다.
현재도
externalFiles,setExternalFiles,uploadFile,onUploadError4개의 옵셔널 위치 인자를 순서대로 넘겨야 합니다. 향후 파라미터가 늘어나면 호출부(AttachFileSection.tsx,ReferenceSection.tsx)에서 순서 실수가 발생하기 쉽습니다. 객체 파라미터로 전환하는 것을 권장합니다.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/shared/lib/hooks/useUploadedFiles.ts` around lines 6 - 12, The useUploadedFiles hook currently relies on four optional positional parameters, making call sites fragile and hard to extend. Refactor useUploadedFiles to accept a single options object instead of externalFiles, setExternalFiles, uploadFile, and onUploadError as ordered arguments, then update the callers in AttachFileSection and ReferenceSection to pass named fields so future additions do not depend on parameter order.src/widgets/instructor/write/ui/AttachFileSection.tsx (1)
19-29: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff
AttachFileSection과ReferenceSection의 업로드 연동 로직 중복.
useUploadedFiles호출부와 모달 상태(isInvalidFileModalOpen,isFileCountExceededModalOpen) 관리 로직이ReferenceSection.tsx와 거의 동일합니다(target/개수 제한만 다름). 공통 훅(예:useCommissionFileSection(target, maxCount))으로 추출하면 중복을 줄일 수 있습니다.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/widgets/instructor/write/ui/AttachFileSection.tsx` around lines 19 - 29, AttachFileSection and ReferenceSection duplicate the same upload wiring and modal state management around useUploadedFiles, with only the target and max file count differing. Extract this shared logic into a reusable hook such as useCommissionFileSection(target, maxCount) that owns the material/reference file state, isInvalidFileModalOpen and isFileCountExceededModalOpen handling, and the upload/remove callbacks. Update AttachFileSection to consume the new hook and keep only section-specific configuration like COMMISSION_FILE_TARGET.MATERIAL.src/shared/api/file.ts (1)
38-56: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winpresigned URL 업로드에 타임아웃 처리가 없습니다.
fetch에 타임아웃/취소 로직이 없어 프리사인드 URL 엔드포인트가 응답하지 않으면 무한 대기 상태가 될 수 있습니다. 이 경우useUploadedFiles의isUploading상태가 영구적으로true로 남아 사용자가 업로드 취소/재시도를 할 수 없습니다.AbortSignal.timeout()을 사용해 제한 시간을 두는 것을 권장합니다.♻️ 제안 diff
const response = await fetch(presignedUrl, { body: file, headers: { "Content-Type": contentType }, method: "PUT", + signal: AbortSignal.timeout(30000), });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/shared/api/file.ts` around lines 38 - 56, The uploadFileToPresignedUrl helper currently calls fetch without any timeout or cancellation, so add an AbortSignal-based timeout to the PUT request to the presignedUrl. Update the upload flow in uploadFileToPresignedUrl to use a timed abort (for example via AbortSignal.timeout or an equivalent AbortController pattern) and make sure the failure surfaces as an ApiError so useUploadedFiles can recover instead of leaving isUploading stuck.src/widgets/instructor/my/api/my.ts (1)
6-6: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
COMMISSIONS_SIZE네이밍 컨벤션 위반.이 상수는 export되지 않는 로컬 상수이므로 SCREAMING_SNAKE_CASE가 아닌 camelCase로 작성되어야 합니다.
✏️ 제안
-const COMMISSIONS_SIZE = 3; +const commissionsSize = 3;(하단 참조도 함께 변경 필요:
size: commissionsSize,size: commissionsSize등)As per path instructions, "함수, 변수명은 소문자로 시작하는 camelCase를 사용해야 합니다." Based on learnings, "camelCase 가이드라인은 함수와 non-exported/일반 변수에 적용되어야 하지만 exported 상수에는 적용되지 않습니다."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/widgets/instructor/my/api/my.ts` at line 6, `COMMISSIONS_SIZE` is a non-exported local constant, so it should follow camelCase instead of SCREAMING_SNAKE_CASE. Rename the constant in the `my` module to a camelCase name such as `commissionsSize`, and update every reference in the same area that uses it (for example the `size:` assignments) so the identifiers stay consistent.Sources: Path instructions, Learnings
src/app/instructor/(withSidebar)/my/page.tsx (1)
4-16: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win
myInfo가 null일 때 빈 상태 UI 부재
myInfo가 null이면MyInfoSection이 아무런 대체 UI 없이 그냥 사라집니다.CommissionsHistorySection은 데이터가 없을 때 "진행된 외주가 없습니다" 같은 안내 문구를 보여주는 것과 비교하면 일관성이 떨어집니다. API 실패/데이터 없음 상황에 대한 최소한의 안내 문구를 추가하는 것을 고려해보세요.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/app/instructor/`(withSidebar)/my/page.tsx around lines 4 - 16, `page`에서 `getMyInfo()` 결과가 null일 때 `MyInfoSection`이 그냥 렌더링되지 않아 빈 화면처럼 보이는 문제를 보완하세요. `myInfo` 조건부 렌더링 자리에, `MyInfoSection` 대신 API 실패/데이터 없음 상황을 알려주는 최소 안내 UI를 추가해 일관된 상태를 보여주도록 수정하면 됩니다. 관련 위치는 `page` 함수와 `MyInfoSection`, `CommissionsHistorySection` 주변입니다.src/widgets/instructor/write/ui/Step1Content.tsx (1)
21-24: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win로직은 정상이나, Step2Content와의 disabled 처리 일관성 검토 제안
isConceptReady/isAllSelected조건 계산 자체는 올바릅니다. 다만 이 버튼은onClick가드만으로 비활성 상태를 표현하고 실제disabled속성은 부여하지 않아, Step2Content(줄 42의disabled={!isAllFilled})와 처리 방식이 다릅니다. 키보드 포커스/엔터로 클릭 이벤트는 여전히 발생 가능합니다 (핸들러 내부에서 무시되긴 하지만).♻️ 일관성 개선 제안
<Button variant={isAllSelected ? "medium_primary" : "medium_disabled"} className="w-fit" + disabled={!isAllSelected} onClick={() => { if (isAllSelected) setCurrentStep(2); }} >🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/widgets/instructor/write/ui/Step1Content.tsx` around lines 21 - 24, The readiness checks in Step1Content are fine, but the action button is only guarded in the click handler and should match Step2Content’s disabled handling. Update the button in Step1Content to use a real disabled state driven by isAllSelected, and keep the existing onClick guard as a fallback so keyboard activation and focus behavior are consistent with the disabled UI in Step2Content.src/features/instructor/write/lib/date.ts (1)
8-19: 🚀 Performance & Scalability | 🔵 Trivial날짜 오프셋 계산 로직 중복.
getFirstAvailableDate와getMinFirstDate가 오프셋 숫자만 다르고 나머지 로직(자정 세팅)이 동일합니다. 공통 헬퍼로 추출하면 유지보수성이 개선됩니다.♻️ 제안 리팩터
+const addDaysAtMidnight = (days: number) => { + const date = new Date(); + date.setDate(date.getDate() + days); + date.setHours(0, 0, 0, 0); + return date; +}; + -export const getFirstAvailableDate = () => { - const date = new Date(); - date.setDate(date.getDate() + 12); - date.setHours(0, 0, 0, 0); - return date; -}; +export const getFirstAvailableDate = () => addDaysAtMidnight(12); -export const getMinFirstDate = () => { - const date = new Date(); - date.setDate(date.getDate() + 11); - date.setHours(0, 0, 0, 0); - return date; -}; +export const getMinFirstDate = () => addDaysAtMidnight(11);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/features/instructor/write/lib/date.ts` around lines 8 - 19, The date offset logic in getFirstAvailableDate and getMinFirstDate is duplicated except for the day delta. Extract the shared “create date and set to midnight” behavior into a common helper, then have both functions call it with their respective offsets (12 and 11) so the date calculation stays centralized and easier to maintain.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/features/instructor/write/api/write.ts`:
- Around line 66-70: The MIME fallback in uploadCommissionFile is incorrectly
hardcoded to an image type, which can mislabel non-image files when File.type is
empty. Update the contentType handling in uploadCommissionFile so unknown types
are treated as a generic binary type instead of image/png, while still passing
the resolved contentType through postFilePresignedUrl and
uploadFileToPresignedUrl.
In `@src/features/instructor/write/ui/PaymentModal/PaymentModal.tsx`:
- Around line 14-16: While PaymentModal is submitting, prevent the modal from
being dismissed so the user can’t lose the Step2 flow after a successful
request. Update the PaymentModal component’s dismissal handling to disable
backdrop click, Escape key, and the close button whenever isSubmitting is true,
and ensure the relevant open/close props or handlers around the modal content
respect this state.
- Around line 27-35: Prevent duplicate commission creation in PaymentModal’s
handlePay flow by checking whether commissionId already exists before calling
postCommission(getOrderRequest()). If a commissionId is already set, reuse it
and just advance the UI to step 2 instead of creating a new order; otherwise
proceed with the existing postCommission path and setCommissionId/result
handling as today.
In `@src/shared/lib/hooks/useUploadedFiles.ts`:
- Around line 41-57: The `useUploadedFiles` upload completion handlers are using
a stale `externalFilesRef.current` snapshot, so concurrent Promise resolutions
can overwrite each other’s file updates. Update `setFiles`/`setExternalFiles` to
apply changes from the latest state via an updater function instead of reading
from `externalFilesRef.current` inside the async `.then`/`.catch` callbacks.
Keep the fix centered in `useUploadedFiles` and any related store helpers that
back `setMaterialFiles`/`setReferenceFiles`, so each upload result merges
atomically and preserves `isUploading`, `key`, and removals correctly.
In `@src/widgets/instructor/my/ui/CommissionsHistorySection.tsx`:
- Around line 31-41: In CommissionsHistorySection, the empty-state branch
currently treats the initial empty items array as “no commissions,” so “진행된 외주가
없습니다” can flash before getCommissions finishes. Add and use a loading flag in
this component (or from the caller) to distinguish initial fetch from a true
empty result, and only render the empty-state message after loading has
completed; keep the items.map rendering unchanged for the loaded state.
- Around line 16-21: `CommissionsHistorySection`의 `useEffect`에서
`getCommissions(page).then(...)`만 호출해 실패 처리와 stale 응답 방지가 없습니다. `getCommissions`
요청에 `.catch`를 추가해 에러 상태를 처리하고, cleanup 플래그(또는 취소 가능 여부)를 넣어 `page`가 바뀔 때 이전 요청의
응답이 `setItems`/`setTotalPages`를 덮어쓰지 않도록 `useEffect` 내부 로직을 수정하세요.
In `@src/widgets/instructor/write/ui/DeadlineChooseSection.tsx`:
- Line 67: The invalidMessage string in DeadlineChooseSection contains an extra
leading space before “12일” on the second line, which makes the displayed 안내문구
inconsistent with the other message format. Update the invalidMessage value in
DeadlineChooseSection so the newline is followed immediately by “12일” without
the extra whitespace, matching the style used by the other deadline guidance
text.
---
Nitpick comments:
In `@src/app/instructor/`(withSidebar)/my/page.tsx:
- Around line 4-16: `page`에서 `getMyInfo()` 결과가 null일 때 `MyInfoSection`이 그냥 렌더링되지
않아 빈 화면처럼 보이는 문제를 보완하세요. `myInfo` 조건부 렌더링 자리에, `MyInfoSection` 대신 API 실패/데이터 없음
상황을 알려주는 최소 안내 UI를 추가해 일관된 상태를 보여주도록 수정하면 됩니다. 관련 위치는 `page` 함수와
`MyInfoSection`, `CommissionsHistorySection` 주변입니다.
In `@src/features/instructor/write/lib/date.ts`:
- Around line 8-19: The date offset logic in getFirstAvailableDate and
getMinFirstDate is duplicated except for the day delta. Extract the shared
“create date and set to midnight” behavior into a common helper, then have both
functions call it with their respective offsets (12 and 11) so the date
calculation stays centralized and easier to maintain.
In `@src/shared/api/file.ts`:
- Around line 38-56: The uploadFileToPresignedUrl helper currently calls fetch
without any timeout or cancellation, so add an AbortSignal-based timeout to the
PUT request to the presignedUrl. Update the upload flow in
uploadFileToPresignedUrl to use a timed abort (for example via
AbortSignal.timeout or an equivalent AbortController pattern) and make sure the
failure surfaces as an ApiError so useUploadedFiles can recover instead of
leaving isUploading stuck.
In `@src/shared/lib/hooks/useUploadedFiles.ts`:
- Around line 6-12: The useUploadedFiles hook currently relies on four optional
positional parameters, making call sites fragile and hard to extend. Refactor
useUploadedFiles to accept a single options object instead of externalFiles,
setExternalFiles, uploadFile, and onUploadError as ordered arguments, then
update the callers in AttachFileSection and ReferenceSection to pass named
fields so future additions do not depend on parameter order.
In `@src/widgets/instructor/my/api/my.ts`:
- Line 6: `COMMISSIONS_SIZE` is a non-exported local constant, so it should
follow camelCase instead of SCREAMING_SNAKE_CASE. Rename the constant in the
`my` module to a camelCase name such as `commissionsSize`, and update every
reference in the same area that uses it (for example the `size:` assignments) so
the identifiers stay consistent.
In `@src/widgets/instructor/write/ui/AttachFileSection.tsx`:
- Around line 19-29: AttachFileSection and ReferenceSection duplicate the same
upload wiring and modal state management around useUploadedFiles, with only the
target and max file count differing. Extract this shared logic into a reusable
hook such as useCommissionFileSection(target, maxCount) that owns the
material/reference file state, isInvalidFileModalOpen and
isFileCountExceededModalOpen handling, and the upload/remove callbacks. Update
AttachFileSection to consume the new hook and keep only section-specific
configuration like COMMISSION_FILE_TARGET.MATERIAL.
In `@src/widgets/instructor/write/ui/Step1Content.tsx`:
- Around line 21-24: The readiness checks in Step1Content are fine, but the
action button is only guarded in the click handler and should match
Step2Content’s disabled handling. Update the button in Step1Content to use a
real disabled state driven by isAllSelected, and keep the existing onClick guard
as a fallback so keyboard activation and focus behavior are consistent with the
disabled UI in Step2Content.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 800a26af-a136-4745-956e-dcf1e020ede0
📒 Files selected for processing (31)
src/app/instructor/(withSidebar)/my/page.tsxsrc/features/instructor/my/api/my.tssrc/features/instructor/my/api/myTypes.tssrc/features/instructor/my/index.tssrc/features/instructor/my/model/myMock.tssrc/features/instructor/my/ui/CommissionsHistoryRow.tsxsrc/features/instructor/write/api/write.tssrc/features/instructor/write/api/writeTypes.tssrc/features/instructor/write/config/write.tssrc/features/instructor/write/index.tssrc/features/instructor/write/lib/date.tssrc/features/instructor/write/model/write.tssrc/features/instructor/write/model/writeFormStore.tssrc/features/instructor/write/ui/PaymentModal/PaymentModal.tsxsrc/features/instructor/write/ui/PaymentModal/Step1.tsxsrc/features/instructor/write/ui/PaymentModal/Step2.tsxsrc/shared/api/file.tssrc/shared/api/fileTypes.tssrc/shared/lib/hooks/useUploadedFiles.tssrc/shared/types/file.tssrc/widgets/instructor/my/api/my.tssrc/widgets/instructor/my/api/myTypes.tssrc/widgets/instructor/my/ui/CommissionsHistorySection.tsxsrc/widgets/instructor/my/ui/MyInfoSection.tsxsrc/widgets/instructor/write/ui/AttachFileSection.tsxsrc/widgets/instructor/write/ui/DeadlineChooseSection.tsxsrc/widgets/instructor/write/ui/DesignConceptSection.tsxsrc/widgets/instructor/write/ui/NecessaryPageChooseSection.tsxsrc/widgets/instructor/write/ui/ReferenceSection.tsxsrc/widgets/instructor/write/ui/Step1Content.tsxsrc/widgets/instructor/write/ui/Step2Content.tsx
💤 Files with no reviewable changes (1)
- src/features/instructor/my/model/myMock.ts
| export const uploadCommissionFile = async (file: File, target: CommissionFileTarget) => { | ||
| const contentType = file.type || "image/png"; | ||
| const { key, presignedUrl } = await postFilePresignedUrl({ target, contentType }); | ||
|
|
||
| await uploadFileToPresignedUrl({ file, presignedUrl, contentType }); |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
MIME 타입 fallback을 이미지로 고정하지 마세요.
File.type이 비어 있는 PDF/문서도 image/png으로 presigned URL을 발급·업로드하게 되어 서버 메타데이터나 검증이 실제 파일과 달라질 수 있습니다. 알 수 없는 타입은 일반 바이너리로 처리하는 편이 안전합니다.
🐛 제안 수정
export const uploadCommissionFile = async (file: File, target: CommissionFileTarget) => {
- const contentType = file.type || "image/png";
+ const contentType = file.type || "application/octet-stream";
const { key, presignedUrl } = await postFilePresignedUrl({ target, contentType });📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export const uploadCommissionFile = async (file: File, target: CommissionFileTarget) => { | |
| const contentType = file.type || "image/png"; | |
| const { key, presignedUrl } = await postFilePresignedUrl({ target, contentType }); | |
| await uploadFileToPresignedUrl({ file, presignedUrl, contentType }); | |
| export const uploadCommissionFile = async (file: File, target: CommissionFileTarget) => { | |
| const contentType = file.type || "application/octet-stream"; | |
| const { key, presignedUrl } = await postFilePresignedUrl({ target, contentType }); | |
| await uploadFileToPresignedUrl({ file, presignedUrl, contentType }); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/features/instructor/write/api/write.ts` around lines 66 - 70, The MIME
fallback in uploadCommissionFile is incorrectly hardcoded to an image type,
which can mislabel non-image files when File.type is empty. Update the
contentType handling in uploadCommissionFile so unknown types are treated as a
generic binary type instead of image/png, while still passing the resolved
contentType through postFilePresignedUrl and uploadFileToPresignedUrl.
| const [isSubmitting, setIsSubmitting] = useState(false); | ||
| const [errorMessage, setErrorMessage] = useState<string | null>(null); | ||
| const [commissionId, setCommissionId] = useState<number | null>(null); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
제출 중에는 모달 닫기를 막아주세요.
외주 생성 요청 중 backdrop/Escape/닫기 버튼으로 모달이 닫히면, 요청은 성공했지만 사용자는 Step2의 입금 안내와 입금 통보 흐름을 잃을 수 있습니다.
🐛 제안 수정
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [commissionId, setCommissionId] = useState<number | null>(null);
const { basicInfo, getOrderRequest } = useWriteFormStore();
+ const handleClose = () => {
+ if (isSubmitting) return;
+ onClose?.();
+ };
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
- if (e.key === "Escape") onClose?.();
+ if (e.key === "Escape") handleClose();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
- }, [onClose]);
+ }, [handleClose]);- onClick={onClose}
+ onClick={handleClose}- <CloseIcon className="text-gray-90 size-6 cursor-pointer" onClick={onClose} />
+ <CloseIcon className="text-gray-90 size-6 cursor-pointer" onClick={handleClose} />Also applies to: 27-40
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/features/instructor/write/ui/PaymentModal/PaymentModal.tsx` around lines
14 - 16, While PaymentModal is submitting, prevent the modal from being
dismissed so the user can’t lose the Step2 flow after a successful request.
Update the PaymentModal component’s dismissal handling to disable backdrop
click, Escape key, and the close button whenever isSubmitting is true, and
ensure the relevant open/close props or handlers around the modal content
respect this state.
| const handlePay = async () => { | ||
| if (isSubmitting) return; | ||
|
|
||
| setIsSubmitting(true); | ||
| setErrorMessage(null); | ||
| try { | ||
| const result = await postCommission(getOrderRequest()); | ||
| setCommissionId(result.commissionId); | ||
| setStep(2); |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
생성된 commissionId가 있으면 외주를 다시 생성하지 마세요.
Step2에서 뒤로 간 뒤 다시 결제하기를 누르면 Line 33의 postCommission(getOrderRequest())가 다시 실행되어 동일 요청으로 외주가 중복 생성될 수 있습니다.
🐛 제안 수정
const handlePay = async () => {
if (isSubmitting) return;
+ if (commissionId != null) {
+ setStep(2);
+ return;
+ }
setIsSubmitting(true);Also applies to: 62-62
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/features/instructor/write/ui/PaymentModal/PaymentModal.tsx` around lines
27 - 35, Prevent duplicate commission creation in PaymentModal’s handlePay flow
by checking whether commissionId already exists before calling
postCommission(getOrderRequest()). If a commissionId is already set, reuse it
and just advance the UI to step 2 instead of creating a new order; otherwise
proceed with the existing postCommission path and setCommissionId/result
handling as today.
| newEntries.forEach(entry => { | ||
| if (uploadFile) { | ||
| uploadFile(entry.file) | ||
| .then(key => { | ||
| setFiles(prev => | ||
| prev.map(f => (f.id === entry.id ? { ...f, isUploading: false, key } : f)), | ||
| ); | ||
| }) | ||
| .catch(() => { | ||
| setFiles(prev => prev.filter(f => f.id !== entry.id)); | ||
| onUploadError?.(entry.file); | ||
| }); | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| // 임시 업로드 시뮬레이션 (실제 API 연동 시 교체) |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift
동시 업로드 완료 시 setFiles의 stale ref로 인한 lost update 가능성.
setFiles는 externalFilesRef.current를 기준으로 새 배열을 계산해 setExternalFiles를 호출하지만, externalFilesRef.current는 useEffect(렌더 이후)에서만 갱신됩니다. 이번 변경으로 uploadFile(entry.file)이 실제 네트워크 요청(Promise)이 되었기 때문에, 여러 파일이 비슷한 시점에 업로드를 마치면 .then/.catch 콜백들이 렌더가 끼어들 틈 없이 연속으로(같은 tick에) 실행될 수 있습니다. 이 경우 각 콜백이 동일한 stale externalFilesRef.current를 기준으로 배열을 계산해 setExternalFiles를 호출하므로, 나중에 실행된 콜백이 먼저 실행된 콜백의 갱신(isUploading: false, key 설정 또는 실패 항목 제거)을 덮어씁니다.
결과적으로 일부 파일이 isUploading: true 상태로 영구히 멈추거나 key가 누락된 채로 buildOrderRequest(materialFiles/referenceFiles → keys)에 반영되지 않아, 외주 생성 요청에서 정상 업로드된 파일이 빠질 수 있습니다. 기존 setTimeout 시뮬레이션은 매크로태스크 단위라 렌더가 끼어들 여지가 있었지만, 실제 fetch 기반 Promise는 마이크로태스크 연쇄로 거의 동시에 처리되어 이 문제가 훨씬 쉽게 재현됩니다.
수정하려면 setExternalFiles가 업데이트 함수(updater)를 받아 스토어의 최신 상태를 기준으로 원자적으로 갱신하도록 계약을 바꾸는 것을 권장합니다(예: zustand의 set(state => ...) 패턴 활용). externalFilesRef에 의존하는 현재 방식은 여러 비동기 완료가 같은 tick에 몰릴 때 근본적으로 안전하지 않습니다.
🐛 개념적 수정 방향
-export const useUploadedFiles = (
- externalFiles?: UploadedFile[],
- setExternalFiles?: (files: UploadedFile[]) => void,
- uploadFile?: UploadFile,
- onUploadError?: (file: File) => void,
-) => {
- ...
- const setFiles = (updater: (prev: UploadedFile[]) => UploadedFile[]) => {
- if (setExternalFiles) {
- setExternalFiles(updater(externalFilesRef.current ?? []));
- } else {
- setLocalFiles(updater);
- }
- };
+export const useUploadedFiles = (
+ externalFiles?: UploadedFile[],
+ setExternalFiles?: (updater: (prev: UploadedFile[]) => UploadedFile[]) => void,
+ uploadFile?: UploadFile,
+ onUploadError?: (file: File) => void,
+) => {
+ ...
+ const setFiles = (updater: (prev: UploadedFile[]) => UploadedFile[]) => {
+ if (setExternalFiles) {
+ setExternalFiles(updater); // 스토어가 최신 상태 기준으로 원자적 갱신
+ } else {
+ setLocalFiles(updater);
+ }
+ };(대응하여 writeFormStore.ts의 setMaterialFiles/setReferenceFiles도 updater 함수를 받아 set(state => ({ materialFiles: updater(state.materialFiles) })) 형태로 변경 필요)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/shared/lib/hooks/useUploadedFiles.ts` around lines 41 - 57, The
`useUploadedFiles` upload completion handlers are using a stale
`externalFilesRef.current` snapshot, so concurrent Promise resolutions can
overwrite each other’s file updates. Update `setFiles`/`setExternalFiles` to
apply changes from the latest state via an updater function instead of reading
from `externalFilesRef.current` inside the async `.then`/`.catch` callbacks.
Keep the fix centered in `useUploadedFiles` and any related store helpers that
back `setMaterialFiles`/`setReferenceFiles`, so each upload result merges
atomically and preserves `isUploading`, `key`, and removals correctly.
| useEffect(() => { | ||
| getCommissions(page).then(result => { | ||
| setItems(result.items); | ||
| setTotalPages(result.totalPages); | ||
| }); | ||
| }, [page]); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
useEffect 요청에 에러 처리와 stale 응답 방지 로직이 없습니다.
getCommissions(page).then(...)에.catch가 없어 요청 실패 시 처리되지 않은 rejection이 발생하고 사용자에게 아무런 피드백이 없습니다.page가 빠르게 여러 번 바뀌면(다음/이전 버튼 연타) 취소 로직 없이 여러 요청이 동시에 진행되어, 늦게 도착한 이전 페이지 응답이 최신 상태를 덮어쓸 수 있는 race condition이 존재합니다.
🔒 제안: cleanup 플래그 + 에러 처리 추가
useEffect(() => {
- getCommissions(page).then(result => {
- setItems(result.items);
- setTotalPages(result.totalPages);
- });
+ let ignore = false;
+
+ getCommissions(page)
+ .then(result => {
+ if (ignore) return;
+ setItems(result.items);
+ setTotalPages(result.totalPages);
+ })
+ .catch(error => {
+ if (ignore) return;
+ // TODO: 사용자에게 에러 노출 처리
+ console.error(error);
+ });
+
+ return () => {
+ ignore = true;
+ };
}, [page]);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| useEffect(() => { | |
| getCommissions(page).then(result => { | |
| setItems(result.items); | |
| setTotalPages(result.totalPages); | |
| }); | |
| }, [page]); | |
| useEffect(() => { | |
| let ignore = false; | |
| getCommissions(page) | |
| .then(result => { | |
| if (ignore) return; | |
| setItems(result.items); | |
| setTotalPages(result.totalPages); | |
| }) | |
| .catch(error => { | |
| if (ignore) return; | |
| // TODO: 사용자에게 에러 노출 처리 | |
| console.error(error); | |
| }); | |
| return () => { | |
| ignore = true; | |
| }; | |
| }, [page]); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/widgets/instructor/my/ui/CommissionsHistorySection.tsx` around lines 16 -
21, `CommissionsHistorySection`의 `useEffect`에서 `getCommissions(page).then(...)`만
호출해 실패 처리와 stale 응답 방지가 없습니다. `getCommissions` 요청에 `.catch`를 추가해 에러 상태를 처리하고,
cleanup 플래그(또는 취소 가능 여부)를 넣어 `page`가 바뀔 때 이전 요청의 응답이 `setItems`/`setTotalPages`를
덮어쓰지 않도록 `useEffect` 내부 로직을 수정하세요.
| {items.length === 0 ? ( | ||
| <div className="flex flex-1 items-center justify-center"> | ||
| <p className="text-heading3-m text-gray-60">진행된 외주가 없습니다</p> | ||
| </div> | ||
| ) : ( | ||
| <> | ||
| {items.map(item => ( | ||
| <CommissionsHistoryRow key={item.commissionId} item={item} /> | ||
| ))} | ||
| </> | ||
| )} |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
초기 로딩 중 "진행된 외주가 없습니다" 문구가 잠깐 노출될 수 있습니다.
첫 getCommissions 응답이 오기 전까지 items가 빈 배열이므로, 실제 데이터가 있어도 로딩 순간 빈 상태 문구가 잠깐 보입니다. 로딩 플래그를 두면 방지할 수 있습니다.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/widgets/instructor/my/ui/CommissionsHistorySection.tsx` around lines 31 -
41, In CommissionsHistorySection, the empty-state branch currently treats the
initial empty items array as “no commissions,” so “진행된 외주가 없습니다” can flash
before getCommissions finishes. Add and use a loading flag in this component (or
from the caller) to distinguish initial fetch from a true empty result, and only
render the empty-state message after loading has completed; keep the items.map
rendering unchanged for the loaded state.
| invalidMessage={"오늘 이후 날짜를\n선택해주세요"} | ||
| defaultDate={firstDate ?? undefined} | ||
| minDate={minFirstDate} | ||
| invalidMessage={"오늘로부터 최소\n 12일 이후 날짜를\n선택해주세요"} |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
invalidMessage 문자열에 불필요한 공백.
"오늘로부터 최소\n 12일 이후 날짜를\n선택해주세요"에서 두 번째 줄 시작 부분에 공백이 하나 더 있어(\n 12일) 최종 문구에 표시 시 앞쪽 들여쓰기처럼 보일 수 있습니다. 다른 안내 문구("1차 시안 수령일로부터\n최소 2주 이후 날짜를\n선택해주세요")와 형식이 다릅니다.
✏️ 제안 수정
- invalidMessage={"오늘로부터 최소\n 12일 이후 날짜를\n선택해주세요"}
+ invalidMessage={"오늘로부터 최소\n12일 이후 날짜를\n선택해주세요"}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| invalidMessage={"오늘로부터 최소\n 12일 이후 날짜를\n선택해주세요"} | |
| invalidMessage={"오늘로부터 최소\n12일 이후 날짜를\n선택해주세요"} |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/widgets/instructor/write/ui/DeadlineChooseSection.tsx` at line 67, The
invalidMessage string in DeadlineChooseSection contains an extra leading space
before “12일” on the second line, which makes the displayed 안내문구 inconsistent
with the other message format. Update the invalidMessage value in
DeadlineChooseSection so the newline is followed immediately by “12일” without
the extra whitespace, matching the style used by the other deadline guidance
text.
📢 PR 유형
어떤 변경 사항이 있었나요?
📌 관련 이슈번호
✅ Key Changes
강사 새 외주 작성, 마이페이지 관련 API 연동했습니다.
📸 스크린샷 or 실행영상
2026-07-02.015711.mov
🎸 기타 사항 or 추가 코멘트
Summary by CodeRabbit
New Features
Bug Fixes