diff --git a/src/app/instructor/(withSidebar)/my/page.tsx b/src/app/instructor/(withSidebar)/my/page.tsx index a31f1b5..1ff2da3 100644 --- a/src/app/instructor/(withSidebar)/my/page.tsx +++ b/src/app/instructor/(withSidebar)/my/page.tsx @@ -1,17 +1,16 @@ -import { cookies } from "next/headers"; - +import { getMyInfo } from "@/features/instructor/my/api/my"; import { CommissionsHistorySection, MyInfoSection } from "@/widgets/instructor/my"; +export const dynamic = "force-dynamic"; + const page = async () => { - const cookieStore = await cookies(); - const name = cookieStore.get("userName")?.value ?? ""; - const profileImageUrl = cookieStore.get("userProfileImageUrl")?.value; + const myInfo = await getMyInfo(); return (

마이페이지

- + {myInfo != null && }
diff --git a/src/features/instructor/my/api/my.ts b/src/features/instructor/my/api/my.ts new file mode 100644 index 0000000..1c58c7b --- /dev/null +++ b/src/features/instructor/my/api/my.ts @@ -0,0 +1,10 @@ +import type { MyInfo } from "@/features/instructor/my/api/myTypes"; +import type { ApiResponse } from "@/shared/api/commonType"; +import { serverApi } from "@/shared/api/server"; + +// 강사 마이페이지 통계 조회 +export const getMyInfo = async (): Promise => { + const response = await serverApi.get("/api/v1/instructors/me").json>(); + + return response.result; +}; diff --git a/src/features/instructor/my/api/myTypes.ts b/src/features/instructor/my/api/myTypes.ts new file mode 100644 index 0000000..66b18db --- /dev/null +++ b/src/features/instructor/my/api/myTypes.ts @@ -0,0 +1,38 @@ +import type { BadgeVariant } from "@/shared/ui/Badge"; + +export type MyInfoStats = { + totalCommissionCount: number; + ongoingCommissionCount: number; +}; + +export type MyInfo = { + name: string; + profileImageUrl: string; + stats: MyInfoStats; +}; + +export type CommissionHistoryItem = { + commissionId: number; + category: string; + title: string; + createdAt: string; + plan: "BASIC" | "PLUS" | "MAX"; + paidAmount: number | null; + status: string; +}; + +export const PLAN_DISPLAY_MAP: Record = { + BASIC: "기본", + PLUS: "플러스", + MAX: "맥스", +}; + +export const CATEGORY_BADGE_MAP: Record = { + FLYER_TEXTBOOK_COVER_INNER: "교재", +}; + +export const PLAN_PAID_AMOUNT_MAP: Record = { + BASIC: 400000, + PLUS: 450000, + MAX: 480000, +}; diff --git a/src/features/instructor/my/index.ts b/src/features/instructor/my/index.ts index 64097c1..bf305be 100644 --- a/src/features/instructor/my/index.ts +++ b/src/features/instructor/my/index.ts @@ -1,4 +1,4 @@ -export type { CommissionHistoryItem } from "./model/myMock"; -export { CATEGORY_BADGE_MAP, commissionHistoryData, PLAN_DISPLAY_MAP } from "./model/myMock"; +export type { CommissionHistoryItem, MyInfo } from "./api/myTypes"; +export { CATEGORY_BADGE_MAP, PLAN_DISPLAY_MAP, PLAN_PAID_AMOUNT_MAP } from "./api/myTypes"; export { default as CommissionsHeader } from "./ui/CommissionsHeader"; export { default as CommissionsHistoryRow } from "./ui/CommissionsHistoryRow"; diff --git a/src/features/instructor/my/model/myMock.ts b/src/features/instructor/my/model/myMock.ts deleted file mode 100644 index 5a94bcc..0000000 --- a/src/features/instructor/my/model/myMock.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { BadgeVariant } from "@/shared/ui/Badge"; - -// [강사] 마이페이지 외주 내역 조회 -export type CommissionHistoryItem = { - commissionId: number; - category: string; - title: string; - createdAt: string; - plan: "BASIC" | "PLUS" | "MAX"; - totalAmount: number; - status: "COMPLETED" | "ONGOING"; -}; - -export const PLAN_DISPLAY_MAP: Record = { - BASIC: "기본", - PLUS: "플러스", - MAX: "맥스", -}; - -export const CATEGORY_BADGE_MAP: Record = { - FLYER_TEXTBOOK_COVER_INNER: "교재", -}; - -// [강사] 마이페이지 본인 정보 + 통계 조회 -export type MyInfo = { - name: string; - profileImageUrl: string; - stats: { - totalCommissionCount: number; - ongoingCommissionCount: number; - }; -}; - -export const myInfoData: MyInfo = { - name: "고다현", - profileImageUrl: "", - stats: { - totalCommissionCount: 5, - ongoingCommissionCount: 5, - }, -}; - -export const commissionHistoryData: CommissionHistoryItem[] = [ - { - commissionId: 1, - category: "FLYER_TEXTBOOK_COVER_INNER", - title: "YMB 영어교재 표지디자인 외주", - createdAt: "2025-05-05", - plan: "BASIC", - totalAmount: 400000, - status: "COMPLETED", - }, - { - commissionId: 2, - category: "FLYER_TEXTBOOK_COVER_INNER", - title: "해커스톡 왕초보 영어 기초 문법편 표지디자인 외주", - createdAt: "2025-04-18", - plan: "PLUS", - totalAmount: 480000, - status: "COMPLETED", - }, - { - commissionId: 3, - category: "FLYER_TEXTBOOK_COVER_INNER", - title: "토익 실전 모의고사 Part 5·6 표지디자인 외주", - createdAt: "2025-03-24", - plan: "MAX", - totalAmount: 560000, - status: "COMPLETED", - }, - { - commissionId: 4, - category: "FLYER_TEXTBOOK_COVER_INNER", - title: "수능 영어 독해 빈칸추론 완성 표지디자인 외주", - createdAt: "2025-03-02", - plan: "BASIC", - totalAmount: 400000, - status: "COMPLETED", - }, - { - commissionId: 5, - category: "FLYER_TEXTBOOK_COVER_INNER", - title: "중학 수학 개념서 1학기 과정 표지디자인 외주", - createdAt: "2025-02-14", - plan: "PLUS", - totalAmount: 480000, - status: "ONGOING", - }, - { - commissionId: 6, - category: "FLYER_TEXTBOOK_COVER_INNER", - title: "고등 국어 문학 현대시 집중 표지디자인 외주", - createdAt: "2025-01-28", - plan: "MAX", - totalAmount: 560000, - status: "ONGOING", - }, - { - commissionId: 7, - category: "FLYER_TEXTBOOK_COVER_INNER", - title: "중등 과학 탐구 물질과 에너지 표지디자인 외주", - createdAt: "2024-12-19", - plan: "BASIC", - totalAmount: 400000, - status: "COMPLETED", - }, - { - commissionId: 8, - category: "FLYER_TEXTBOOK_COVER_INNER", - title: "고등 수학 II 미적분 집중 완성 표지디자인 외주", - createdAt: "2024-11-30", - plan: "PLUS", - totalAmount: 480000, - status: "COMPLETED", - }, - { - commissionId: 9, - category: "FLYER_TEXTBOOK_COVER_INNER", - title: "초등 사회 한국사 인물편 표지디자인 외주", - createdAt: "2024-11-12", - plan: "BASIC", - totalAmount: 400000, - status: "ONGOING", - }, - { - commissionId: 10, - category: "FLYER_TEXTBOOK_COVER_INNER", - title: "고등 영어 듣기평가 모의고사 표지디자인 외주", - createdAt: "2024-10-27", - plan: "MAX", - totalAmount: 560000, - status: "COMPLETED", - }, -]; diff --git a/src/features/instructor/my/ui/CommissionsHistoryRow.tsx b/src/features/instructor/my/ui/CommissionsHistoryRow.tsx index 298975a..f124fe5 100644 --- a/src/features/instructor/my/ui/CommissionsHistoryRow.tsx +++ b/src/features/instructor/my/ui/CommissionsHistoryRow.tsx @@ -2,12 +2,13 @@ import { CATEGORY_BADGE_MAP, CommissionHistoryItem, PLAN_DISPLAY_MAP, -} from "@/features/instructor/my/model/myMock"; + PLAN_PAID_AMOUNT_MAP, +} from "@/features/instructor/my"; import { ArrowRightIcon } from "@/shared/assets/icons"; import Badge from "@/shared/ui/Badge"; const CommissionsHistoryRow = ({ item }: { item: CommissionHistoryItem }) => { - const { category, title, createdAt, plan, totalAmount } = item; + const { category, title, createdAt, plan, paidAmount } = item; return (
@@ -21,7 +22,9 @@ const CommissionsHistoryRow = ({ item }: { item: CommissionHistoryItem }) => {

{createdAt.replaceAll("-", ".")}

{PLAN_DISPLAY_MAP[plan]}

-

{totalAmount.toLocaleString()}원

+

+ {(paidAmount ?? PLAN_PAID_AMOUNT_MAP[plan]).toLocaleString()}원 +

); diff --git a/src/features/instructor/write/api/write.ts b/src/features/instructor/write/api/write.ts index 432e9d2..f92f0a6 100644 --- a/src/features/instructor/write/api/write.ts +++ b/src/features/instructor/write/api/write.ts @@ -1,6 +1,19 @@ -import type { GetPlansResult, Plan } from "@/features/instructor/write/api/writeTypes"; -import { api, createApiPath } from "@/shared/api/client"; +import type { + CreateCommissionResult, + GetPlansResult, + Plan, +} from "@/features/instructor/write/api/writeTypes"; +import type { CommissionFileTarget } from "@/features/instructor/write/config/write"; +import type { WriteOrderRequest } from "@/features/instructor/write/model/write"; +import { + api, + ApiError, + createApiPath, + getApiResponseMessage, + toApiError, +} from "@/shared/api/client"; import type { ApiResponse } from "@/shared/api/commonType"; +import { postFilePresignedUrl, uploadFileToPresignedUrl } from "@/shared/api/file"; // 플랜 조회 export const getPlans = async (): Promise => { @@ -10,3 +23,51 @@ export const getPlans = async (): Promise => { return response.result?.plans ?? []; }; + +// 외주 생성(결제) 요청 +export const postCommission = async (body: WriteOrderRequest): Promise => { + try { + const response = await api + .post(createApiPath("/api/v1/instructors/commissions"), { json: body }) + .json>(); + + if (!response.success || !response.result) { + throw new ApiError(getApiResponseMessage(response), { + code: response.code, + response, + }); + } + + return response.result; + } catch (error) { + throw await toApiError(error); + } +}; + +// 입금 통보 +export const postNotifyDeposit = async (commissionId: number): Promise => { + try { + const response = await api + .post(createApiPath(`/api/v1/instructors/commissions/${commissionId}/notify-deposit`)) + .json>(); + + if (!response.success) { + throw new ApiError(getApiResponseMessage(response), { + code: response.code, + response, + }); + } + } catch (error) { + throw await toApiError(error); + } +}; + +// 외주 첨부 파일 업로드 (presigned URL 발급 후 업로드, 반환된 key를 외주 작성 요청에 사용) +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 }); + + return key; +}; diff --git a/src/features/instructor/write/api/writeTypes.ts b/src/features/instructor/write/api/writeTypes.ts index 92c6d34..a3633a8 100644 --- a/src/features/instructor/write/api/writeTypes.ts +++ b/src/features/instructor/write/api/writeTypes.ts @@ -10,3 +10,15 @@ export type Plan = { export type GetPlansResult = { plans: Plan[]; }; + +export type CreateCommissionResult = { + commissionId: number; + title: string; + category: string; + status: string; + applicationDeadline: string; + firstDraftDeadline: string; + finalDeadline: string; + maxRevision: number; + createdAt: string; +}; diff --git a/src/features/instructor/write/config/write.ts b/src/features/instructor/write/config/write.ts index 7b26e66..e90bc08 100644 --- a/src/features/instructor/write/config/write.ts +++ b/src/features/instructor/write/config/write.ts @@ -64,6 +64,14 @@ export const MAX_CONCEPT_SELECT = 5; /* ========================= STEP2 ========================= */ +export const COMMISSION_FILE_TARGET = { + MATERIAL: "COMMISSION_MATERIAL", + REFERENCE: "COMMISSION_REFERENCE", +} as const; + +export type CommissionFileTarget = + (typeof COMMISSION_FILE_TARGET)[keyof typeof COMMISSION_FILE_TARGET]; + export const BASIC_INFO_FIELDS = [ { label: "교재명", placeholder: "교재명을 작성해주세요" }, { label: "강사명", placeholder: "강사명을 작성해주세요" }, diff --git a/src/features/instructor/write/index.ts b/src/features/instructor/write/index.ts index 74cbda0..92a1b7f 100644 --- a/src/features/instructor/write/index.ts +++ b/src/features/instructor/write/index.ts @@ -1,8 +1,8 @@ -export { getPlans } from "./api/write"; -export type { Plan } from "./api/writeTypes"; +export { getPlans, postCommission, postNotifyDeposit, uploadCommissionFile } from "./api/write"; +export type { CreateCommissionResult, Plan } from "./api/writeTypes"; export * from "./config/write"; export type { RgbaColor } from "./lib/color"; -export { formatDate, getMinFinalDate, getYesterday } from "./lib/date"; +export { formatDate, getFirstAvailableDate, getMinFinalDate, getMinFirstDate } from "./lib/date"; export type { BasicInfo } from "./model/writeFormStore"; export { useWriteFormStore } from "./model/writeFormStore"; export { default as ColorChooseCard } from "./ui/ColorChooseCard"; diff --git a/src/features/instructor/write/lib/date.ts b/src/features/instructor/write/lib/date.ts index 73f069d..744d29b 100644 --- a/src/features/instructor/write/lib/date.ts +++ b/src/features/instructor/write/lib/date.ts @@ -5,11 +5,18 @@ export const formatDate = (date: Date) => { return `${year}년 ${month}월 ${day}일`; }; -export const getYesterday = () => { - const yesterday = new Date(); - yesterday.setDate(yesterday.getDate() - 1); - yesterday.setHours(0, 0, 0, 0); - return yesterday; +export const getFirstAvailableDate = () => { + const date = new Date(); + date.setDate(date.getDate() + 12); + date.setHours(0, 0, 0, 0); + return date; +}; + +export const getMinFirstDate = () => { + const date = new Date(); + date.setDate(date.getDate() + 11); + date.setHours(0, 0, 0, 0); + return date; }; export const getMinFinalDate = (date: Date) => { diff --git a/src/features/instructor/write/model/write.ts b/src/features/instructor/write/model/write.ts index a4346fb..f0d64ee 100644 --- a/src/features/instructor/write/model/write.ts +++ b/src/features/instructor/write/model/write.ts @@ -6,7 +6,7 @@ export interface WriteOrderColorRequest { } export interface WriteOrderDesignInfoRequest { - size: string; + pageSize: string; concepts: string[]; additionalConcept?: string; colorSelectionMode: "DESIGNER_DELEGATED" | "USER_SELECTED"; @@ -18,6 +18,21 @@ export interface WriteOrderPageRequest { description: string | null; } +export interface WriteOrderTextbookRequest { + textbookName: string; + instructorName: string; + subject: string; + requiredPages: WriteOrderPageRequest[]; +} + +export type CommissionFileKind = "MATERIAL" | "REFERENCE"; + +export interface WriteOrderFileRequest { + fileKind: CommissionFileKind; + keys: string[]; + description?: string; +} + export interface WriteOrderDateRequest { firstDraftDeadline: string; finalDeadline: string; @@ -32,13 +47,9 @@ export interface WriteOrderTermRequest { export interface WriteOrderRequest { category: string; designInfo: WriteOrderDesignInfoRequest; - textbookName: string; - instructorName: string; - subject: string; - requiredPages: WriteOrderPageRequest[]; - materialDescription?: string; - referenceDescription?: string; + textbook: WriteOrderTextbookRequest; + files?: WriteOrderFileRequest[]; plan: string; - dates: WriteOrderDateRequest[]; - term: WriteOrderTermRequest[]; + date: WriteOrderDateRequest; + term: WriteOrderTermRequest; } diff --git a/src/features/instructor/write/model/writeFormStore.ts b/src/features/instructor/write/model/writeFormStore.ts index 7114b9d..8675e91 100644 --- a/src/features/instructor/write/model/writeFormStore.ts +++ b/src/features/instructor/write/model/writeFormStore.ts @@ -69,6 +69,8 @@ interface WriteFormState { setFinalDate: (value: Date | null) => void; setIsTermsAgreed: (value: boolean) => void; + // 현재 상태를 외주 생성 요청 포맷으로 변환 + getOrderRequest: () => WriteOrderRequest; // API 제출 후 호출 → 스토어 초기화 + sessionStorage 키 삭제 clearAfterSubmit: () => void; } @@ -111,38 +113,57 @@ const buildOrderRequest = (state: WriteFormState): WriteOrderRequest => { if (result.length > 0) mappedColors = result; } + const files: WriteOrderRequest["files"] = []; + const materialKeys = state.materialFiles.map(f => f.key).filter((key): key is string => !!key); + if (materialKeys.length > 0) { + files.push({ + fileKind: "MATERIAL", + keys: materialKeys, + ...(state.materialDescription ? { description: state.materialDescription } : {}), + }); + } + const referenceKeys = state.referenceFiles.map(f => f.key).filter((key): key is string => !!key); + if (referenceKeys.length > 0) { + files.push({ + fileKind: "REFERENCE", + keys: referenceKeys, + ...(state.referenceDescription ? { description: state.referenceDescription } : {}), + }); + } + return { category: state.selectedCategory ? CATEGORY_API_MAP[state.selectedCategory.item] : "", designInfo: { - size: state.selectedSize ? SIZE_API_MAP[state.selectedSize] : "", + pageSize: state.selectedSize ? SIZE_API_MAP[state.selectedSize] : "", concepts: state.selectedKeywords.map(k => KEYWORD_API_MAP[k]), ...(state.additionalConcept ? { additionalConcept: state.additionalConcept } : {}), colorSelectionMode: state.colorMode === "designer" ? "DESIGNER_DELEGATED" : "USER_SELECTED", ...(mappedColors ? { colors: mappedColors } : {}), }, - textbookName: state.basicInfo.교재명, - instructorName: state.basicInfo.강사명, - subject: state.basicInfo.과목명, - requiredPages: state.selectedPages.map(page => ({ - pageType: PAGE_API_MAP[page], - description: state.pageDescriptions[page] || null, - })), - ...(state.materialDescription ? { materialDescription: state.materialDescription } : {}), - ...(state.referenceDescription ? { referenceDescription: state.referenceDescription } : {}), + textbook: { + textbookName: state.basicInfo.교재명, + instructorName: state.basicInfo.강사명, + subject: state.basicInfo.과목명, + requiredPages: state.selectedPages.map(page => ({ + pageType: PAGE_API_MAP[page], + description: state.pageDescriptions[page] || null, + })), + }, + ...(files.length > 0 ? { files } : {}), plan: state.selectedPlan?.code ?? "", - dates: [ - { - firstDraftDeadline: state.firstDate ? toApiDate(state.firstDate) : "", - finalDeadline: state.finalDate ? toApiDate(state.finalDate) : "", - }, - ], - term: [{ type: "SETTLEMENT", version: "V1.0", isAgreed: state.isTermsAgreed }], + date: { + firstDraftDeadline: state.firstDate ? toApiDate(state.firstDate) : "", + finalDeadline: state.finalDate ? toApiDate(state.finalDate) : "", + }, + term: { type: "SETTLEMENT", version: "V1.0", isAgreed: state.isTermsAgreed }, }; }; -export const useWriteFormStore = create()(set => ({ +export const useWriteFormStore = create()((set, get) => ({ ...initialState, + getOrderRequest: () => buildOrderRequest(get()), + setCurrentStep: value => set({ currentStep: value }), setSelectedCategory: value => set({ selectedCategory: value }), setSelectedSize: value => set({ selectedSize: value }), diff --git a/src/features/instructor/write/ui/PaymentModal/PaymentModal.tsx b/src/features/instructor/write/ui/PaymentModal/PaymentModal.tsx index f1905cf..4ab4000 100644 --- a/src/features/instructor/write/ui/PaymentModal/PaymentModal.tsx +++ b/src/features/instructor/write/ui/PaymentModal/PaymentModal.tsx @@ -2,14 +2,19 @@ import { useEffect, useState } from "react"; +import { postCommission } from "@/features/instructor/write/api/write"; import { useWriteFormStore } from "@/features/instructor/write/model/writeFormStore"; import Step1 from "@/features/instructor/write/ui/PaymentModal/Step1"; import Step2 from "@/features/instructor/write/ui/PaymentModal/Step2"; +import { getApiErrorMessage } from "@/shared/api/client"; import { CloseIcon } from "@/shared/assets/icons"; const PaymentModalContent = ({ onClose }: { onClose?: () => void }) => { const [step, setStep] = useState<1 | 2>(1); - const { basicInfo } = useWriteFormStore(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [commissionId, setCommissionId] = useState(null); + const { basicInfo, getOrderRequest } = useWriteFormStore(); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -19,6 +24,22 @@ const PaymentModalContent = ({ onClose }: { onClose?: () => void }) => { return () => window.removeEventListener("keydown", handleKeyDown); }, [onClose]); + const handlePay = async () => { + if (isSubmitting) return; + + setIsSubmitting(true); + setErrorMessage(null); + try { + const result = await postCommission(getOrderRequest()); + setCommissionId(result.commissionId); + setStep(2); + } catch (error) { + setErrorMessage(await getApiErrorMessage(error)); + } finally { + setIsSubmitting(false); + } + }; + return (
void }) => {

- {step === 1 ? setStep(2)} /> : setStep(1)} />} + {step === 1 ? ( + + ) : ( + setStep(1)} commissionId={commissionId} /> + )} ); diff --git a/src/features/instructor/write/ui/PaymentModal/Step1.tsx b/src/features/instructor/write/ui/PaymentModal/Step1.tsx index 6c2591c..af957b6 100644 --- a/src/features/instructor/write/ui/PaymentModal/Step1.tsx +++ b/src/features/instructor/write/ui/PaymentModal/Step1.tsx @@ -109,11 +109,20 @@ const TermsSection = ({ Step1 ───────────────────────────────────────────── */ -const Step1 = ({ onNext, errorMessage }: { onNext: () => void; errorMessage?: string | null }) => { +const Step1 = ({ + onNext, + isSubmitting, + errorMessage, +}: { + onNext: () => void; + isSubmitting?: boolean; + errorMessage?: string | null; +}) => { const { selectedCategory, selectedSize, selectedKeywords, + additionalConcept, colorMode, colors, selectedPages, @@ -153,11 +162,16 @@ const Step1 = ({ onNext, errorMessage }: { onNext: () => void; errorMessage?: st ))} )} - {selectedKeywords.length > 0 && ( + {(selectedKeywords.length > 0 || additionalConcept.trim() !== "") && ( {selectedKeywords.map(k => ( ))} + {selectedKeywords.length === 0 && additionalConcept.trim() !== "" && ( +

+ {additionalConcept} +

+ )}
)} {colorMode === "designer" && } @@ -195,8 +209,10 @@ const Step1 = ({ onNext, errorMessage }: { onNext: () => void; errorMessage?: st className="absolute -top-4 left-1/2 w-fit shrink-0 -translate-x-1/2 -translate-y-full whitespace-nowrap" /> +
+ + +
); diff --git a/src/shared/api/file.ts b/src/shared/api/file.ts new file mode 100644 index 0000000..41ff179 --- /dev/null +++ b/src/shared/api/file.ts @@ -0,0 +1,56 @@ +import { + api, + ApiError, + createApiPath, + getApiResponseMessage, + toApiError, +} from "@/shared/api/client"; +import type { ApiResponse } from "@/shared/api/commonType"; +import { + type FilePresignedUrlResult, + filePresignedUrlResultSchema, + type PostFilePresignedUrlBody, +} from "@/shared/api/fileTypes"; + +// 파일 업로드 presigned URL 발급 +export const postFilePresignedUrl = async ( + body: PostFilePresignedUrlBody, +): Promise => { + try { + const response = await api + .post(createApiPath("/api/v1/files/presigned-url"), { json: body }) + .json>(); + + if (!response.success) { + throw new ApiError(getApiResponseMessage(response), { + code: response.code, + response, + }); + } + + return filePresignedUrlResultSchema.parse(response.result); + } catch (error) { + throw await toApiError(error); + } +}; + +// presigned URL로 파일 업로드 +export const uploadFileToPresignedUrl = async ({ + file, + presignedUrl, + contentType, +}: { + file: File; + presignedUrl: string; + contentType: string; +}) => { + const response = await fetch(presignedUrl, { + body: file, + headers: { "Content-Type": contentType }, + method: "PUT", + }); + + if (!response.ok) { + throw new ApiError("파일 업로드에 실패했습니다", { status: response.status }); + } +}; diff --git a/src/shared/api/fileTypes.ts b/src/shared/api/fileTypes.ts new file mode 100644 index 0000000..429e679 --- /dev/null +++ b/src/shared/api/fileTypes.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +export const filePresignedUrlResultSchema = z.object({ + key: z.string(), + presignedUrl: z.string(), +}); + +export type FilePresignedUrlResult = z.infer; + +export type PostFilePresignedUrlBody = { + target: Target; + contentType: string; +}; diff --git a/src/shared/lib/hooks/useUploadedFiles.ts b/src/shared/lib/hooks/useUploadedFiles.ts index 5b3dd3e..b4c9944 100644 --- a/src/shared/lib/hooks/useUploadedFiles.ts +++ b/src/shared/lib/hooks/useUploadedFiles.ts @@ -3,9 +3,13 @@ import { useEffect, useRef, useState } from "react"; import { formatFileSize } from "@/shared/lib/utils/file"; import { UploadedFile } from "@/shared/types/file"; +type UploadFile = (file: File) => Promise; + export const useUploadedFiles = ( externalFiles?: UploadedFile[], setExternalFiles?: (files: UploadedFile[]) => void, + uploadFile?: UploadFile, + onUploadError?: (file: File) => void, ) => { const [localFiles, setLocalFiles] = useState([]); const externalFilesRef = useRef(externalFiles); @@ -34,8 +38,23 @@ export const useUploadedFiles = ( setFiles(prev => [...prev, ...newEntries]); - // 임시 업로드 시뮬레이션 (실제 API 연동 시 교체) 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 연동 시 교체) setTimeout(() => { setFiles(prev => prev.map(f => (f.id === entry.id ? { ...f, isUploading: false } : f))); }, 2000); diff --git a/src/shared/types/file.ts b/src/shared/types/file.ts index 68bcdb8..543f3d4 100644 --- a/src/shared/types/file.ts +++ b/src/shared/types/file.ts @@ -4,4 +4,5 @@ export interface UploadedFile { fileName: string; fileSize: string; isUploading: boolean; + key?: string; } diff --git a/src/widgets/instructor/my/api/my.ts b/src/widgets/instructor/my/api/my.ts new file mode 100644 index 0000000..2cffef1 --- /dev/null +++ b/src/widgets/instructor/my/api/my.ts @@ -0,0 +1,25 @@ +import type { CommissionHistoryItem } from "@/features/instructor/my"; +import { api } from "@/shared/api/client"; +import type { ApiResponse } from "@/shared/api/commonType"; +import type { GetCommissionsResult } from "@/widgets/instructor/my/api/myTypes"; + +const COMMISSIONS_SIZE = 3; + +// 외주 내역 조회 +export const getCommissions = async (page: number): Promise => { + const response = await api + .get("/api/v1/instructors/commissions", { + searchParams: { page, size: COMMISSIONS_SIZE }, + }) + .json>(); + + return ( + response.result ?? { + items: [] as CommissionHistoryItem[], + page, + size: COMMISSIONS_SIZE, + totalElements: 0, + totalPages: 0, + } + ); +}; diff --git a/src/widgets/instructor/my/api/myTypes.ts b/src/widgets/instructor/my/api/myTypes.ts new file mode 100644 index 0000000..590f0d1 --- /dev/null +++ b/src/widgets/instructor/my/api/myTypes.ts @@ -0,0 +1,9 @@ +import type { CommissionHistoryItem } from "@/features/instructor/my"; + +export type GetCommissionsResult = { + items: CommissionHistoryItem[]; + page: number; + size: number; + totalElements: number; + totalPages: number; +}; diff --git a/src/widgets/instructor/my/ui/CommissionsHistorySection.tsx b/src/widgets/instructor/my/ui/CommissionsHistorySection.tsx index 3ff6100..b920dd2 100644 --- a/src/widgets/instructor/my/ui/CommissionsHistorySection.tsx +++ b/src/widgets/instructor/my/ui/CommissionsHistorySection.tsx @@ -1,35 +1,52 @@ "use client"; -import { - commissionHistoryData, - CommissionsHeader, - CommissionsHistoryRow, -} from "@/features/instructor/my"; +import { useEffect, useState } from "react"; + +import type { CommissionHistoryItem } from "@/features/instructor/my"; +import { CommissionsHeader, CommissionsHistoryRow } from "@/features/instructor/my"; import { NextButton, PrevButton } from "@/shared/assets/icons"; -import usePagination from "@/shared/lib/hooks/usePagination"; import PageIndicator from "@/shared/ui/PageIndicator"; -import { COMMISSION_HISTORY_ITEMS_PER_PAGE } from "@/widgets/instructor/my/config/my"; +import { getCommissions } from "@/widgets/instructor/my/api/my"; const CommissionsHistorySection = () => { - const { current, totalPages, pageItems, handlePrev, handleNext } = usePagination( - commissionHistoryData, - COMMISSION_HISTORY_ITEMS_PER_PAGE, - ); + const [page, setPage] = useState(0); + const [items, setItems] = useState([]); + const [totalPages, setTotalPages] = useState(0); + + useEffect(() => { + getCommissions(page).then(result => { + setItems(result.items); + setTotalPages(result.totalPages); + }); + }, [page]); + + const handlePrev = () => setPage(prev => Math.max(0, prev - 1)); + const handleNext = () => setPage(prev => Math.min(totalPages - 1, prev + 1)); return (

외주 내역 확인

- {pageItems.map(item => ( - - ))} -
-
- - - + {items.length === 0 ? ( +
+

진행된 외주가 없습니다

+
+ ) : ( + <> + {items.map(item => ( + + ))} + + )}
+ {totalPages > 0 && ( +
+ + + +
+ )}
); }; diff --git a/src/widgets/instructor/my/ui/MyInfoSection.tsx b/src/widgets/instructor/my/ui/MyInfoSection.tsx index aa0b033..5033d97 100644 --- a/src/widgets/instructor/my/ui/MyInfoSection.tsx +++ b/src/widgets/instructor/my/ui/MyInfoSection.tsx @@ -1,16 +1,11 @@ import Image from "next/image"; -import { myInfoData } from "@/features/instructor/my/model/myMock"; +import type { MyInfo } from "@/features/instructor/my"; import { ProfileCircleIcon } from "@/shared/assets/icons"; -interface MyInfoSectionProps { - name: string; - profileImageUrl?: string; -} - -const MyInfoSection = ({ name, profileImageUrl }: MyInfoSectionProps) => { - const { stats } = myInfoData; +type MyInfoSectionProps = MyInfo; +const MyInfoSection = ({ name, profileImageUrl, stats }: MyInfoSectionProps) => { return (
diff --git a/src/widgets/instructor/write/ui/AttachFileSection.tsx b/src/widgets/instructor/write/ui/AttachFileSection.tsx index 3595c8e..c358734 100644 --- a/src/widgets/instructor/write/ui/AttachFileSection.tsx +++ b/src/widgets/instructor/write/ui/AttachFileSection.tsx @@ -2,7 +2,11 @@ import { useState } from "react"; -import { useWriteFormStore } from "@/features/instructor/write"; +import { + COMMISSION_FILE_TARGET, + uploadCommissionFile, + useWriteFormStore, +} from "@/features/instructor/write"; import { useUploadedFiles } from "@/shared/lib/hooks/useUploadedFiles"; import { isAllowedFileType, MAX_FILE_SIZE_BYTES } from "@/shared/lib/utils/file"; import FileDragAndDrop from "@/shared/ui/FileDragAndDrop"; @@ -15,12 +19,14 @@ const MAX_FILE_COUNT = 5; const AttachFileSection = () => { const { materialFiles, setMaterialFiles, materialDescription, setMaterialDescription } = useWriteFormStore(); + const [isInvalidFileModalOpen, setIsInvalidFileModalOpen] = useState(false); + const [isFileCountExceededModalOpen, setIsFileCountExceededModalOpen] = useState(false); const { uploadedFiles, handleFilesAdded, handleRemove } = useUploadedFiles( materialFiles, setMaterialFiles, + file => uploadCommissionFile(file, COMMISSION_FILE_TARGET.MATERIAL), + () => setIsInvalidFileModalOpen(true), ); - const [isInvalidFileModalOpen, setIsInvalidFileModalOpen] = useState(false); - const [isFileCountExceededModalOpen, setIsFileCountExceededModalOpen] = useState(false); const handleValidatedFilesAdded = (files: File[]) => { const validFiles = files.filter( diff --git a/src/widgets/instructor/write/ui/DeadlineChooseSection.tsx b/src/widgets/instructor/write/ui/DeadlineChooseSection.tsx index a77a2c2..c8c501e 100644 --- a/src/widgets/instructor/write/ui/DeadlineChooseSection.tsx +++ b/src/widgets/instructor/write/ui/DeadlineChooseSection.tsx @@ -4,8 +4,9 @@ import { useEffect, useRef, useState } from "react"; import { formatDate, + getFirstAvailableDate, getMinFinalDate, - getYesterday, + getMinFirstDate, useWriteFormStore, } from "@/features/instructor/write"; import DateDropdownBox from "@/shared/ui/dropdown/DateDropdownBox"; @@ -15,7 +16,8 @@ const DeadlineChooseSection = () => { const { firstDate, setFirstDate, finalDate, setFinalDate } = useWriteFormStore(); const [openMenu, setOpenMenu] = useState<"first" | "final" | null>(null); - const yesterday = getYesterday(); + const minFirstDate = getMinFirstDate(); + const firstAvailableDate = getFirstAvailableDate(); const containerRef = useRef(null); @@ -61,9 +63,9 @@ const DeadlineChooseSection = () => {
)} diff --git a/src/widgets/instructor/write/ui/DesignConceptSection.tsx b/src/widgets/instructor/write/ui/DesignConceptSection.tsx index 4e00c68..28448ce 100644 --- a/src/widgets/instructor/write/ui/DesignConceptSection.tsx +++ b/src/widgets/instructor/write/ui/DesignConceptSection.tsx @@ -86,7 +86,7 @@ const DesignConceptSection = () => { >
setAdditionalConcept(e.target.value)} /> diff --git a/src/widgets/instructor/write/ui/NecessaryPageChooseSection.tsx b/src/widgets/instructor/write/ui/NecessaryPageChooseSection.tsx index 50108ef..ebf735d 100644 --- a/src/widgets/instructor/write/ui/NecessaryPageChooseSection.tsx +++ b/src/widgets/instructor/write/ui/NecessaryPageChooseSection.tsx @@ -65,7 +65,9 @@ const NecessaryPageChooseSection = () => { ))}
) : ( -

상단에서 필수페이지를 우선 선택해주세요

+

+ 상단에서 외주 맡길 페이지를 우선 선택해주세요 +

)}
); diff --git a/src/widgets/instructor/write/ui/ReferenceSection.tsx b/src/widgets/instructor/write/ui/ReferenceSection.tsx index 1d565e8..3c35d48 100644 --- a/src/widgets/instructor/write/ui/ReferenceSection.tsx +++ b/src/widgets/instructor/write/ui/ReferenceSection.tsx @@ -2,7 +2,11 @@ import { useState } from "react"; -import { useWriteFormStore } from "@/features/instructor/write"; +import { + COMMISSION_FILE_TARGET, + uploadCommissionFile, + useWriteFormStore, +} from "@/features/instructor/write"; import { useUploadedFiles } from "@/shared/lib/hooks/useUploadedFiles"; import { isAllowedFileType, MAX_FILE_SIZE_BYTES } from "@/shared/lib/utils/file"; import FileDragAndDrop from "@/shared/ui/FileDragAndDrop"; @@ -15,12 +19,14 @@ const MAX_FILE_COUNT = 3; const ReferenceSection = () => { const { referenceFiles, setReferenceFiles, referenceDescription, setReferenceDescription } = useWriteFormStore(); + const [isInvalidFileModalOpen, setIsInvalidFileModalOpen] = useState(false); + const [isFileCountExceededModalOpen, setIsFileCountExceededModalOpen] = useState(false); const { uploadedFiles, handleFilesAdded, handleRemove } = useUploadedFiles( referenceFiles, setReferenceFiles, + file => uploadCommissionFile(file, COMMISSION_FILE_TARGET.REFERENCE), + () => setIsInvalidFileModalOpen(true), ); - const [isInvalidFileModalOpen, setIsInvalidFileModalOpen] = useState(false); - const [isFileCountExceededModalOpen, setIsFileCountExceededModalOpen] = useState(false); const handleValidatedFilesAdded = (files: File[]) => { const validFiles = files.filter( diff --git a/src/widgets/instructor/write/ui/Step1Content.tsx b/src/widgets/instructor/write/ui/Step1Content.tsx index fcfa311..0911873 100644 --- a/src/widgets/instructor/write/ui/Step1Content.tsx +++ b/src/widgets/instructor/write/ui/Step1Content.tsx @@ -8,15 +8,20 @@ import DesignConceptSection from "@/widgets/instructor/write/ui/DesignConceptSec import SizeSection from "@/widgets/instructor/write/ui/SizeSection"; const Step1Content = () => { - const { selectedCategory, selectedSize, selectedKeywords, colorMode, colors, setCurrentStep } = - useWriteFormStore(); + const { + selectedCategory, + selectedSize, + selectedKeywords, + additionalConcept, + colorMode, + colors, + setCurrentStep, + } = useWriteFormStore(); const isColorReady = colorMode === "designer" || colors.every(c => c !== null); + const isConceptReady = selectedKeywords.length >= 1 || additionalConcept.trim().length > 0; const isAllSelected = - selectedCategory !== null && - selectedSize !== null && - selectedKeywords.length >= 1 && - isColorReady; + selectedCategory !== null && selectedSize !== null && isConceptReady && isColorReady; return (
diff --git a/src/widgets/instructor/write/ui/Step2Content.tsx b/src/widgets/instructor/write/ui/Step2Content.tsx index 9996eb4..7a44291 100644 --- a/src/widgets/instructor/write/ui/Step2Content.tsx +++ b/src/widgets/instructor/write/ui/Step2Content.tsx @@ -8,13 +8,23 @@ import NecessaryPageChooseSection from "@/widgets/instructor/write/ui/NecessaryP import ReferenceSection from "@/widgets/instructor/write/ui/ReferenceSection"; const Step2Content = () => { - const { basicInfo, selectedPages, setCurrentStep } = useWriteFormStore(); + const { + basicInfo, + selectedPages, + materialFiles, + materialDescription, + referenceFiles, + referenceDescription, + setCurrentStep, + } = useWriteFormStore(); const isAllFilled = basicInfo.교재명.trim() !== "" && basicInfo.강사명.trim() !== "" && basicInfo.과목명.trim() !== "" && - selectedPages.length >= 1; + selectedPages.length >= 1 && + (materialFiles.length === 0 || materialDescription.trim() !== "") && + (referenceFiles.length === 0 || referenceDescription.trim() !== ""); return (