diff --git a/src/app/api/qna/file/route.ts b/src/app/api/qna/file/route.ts index e050c3c..8d6ea8a 100644 --- a/src/app/api/qna/file/route.ts +++ b/src/app/api/qna/file/route.ts @@ -3,6 +3,24 @@ import { NextResponse } from 'next/server' import { loggerWrapper } from '@/libs/api-wrapper' import { ERROR_MESSAGES } from '@/utils/common-utils' +/** + * @api {GET} /api/qna/file 문의 첨부 파일 다운로드 API + * @apiName GET /api/qna/file + * @apiGroup Qna + * @apiDescription 문의 첨부 파일 다운로드 API + * + * @apiParam {String} encodeFileNo 인코딩 파일 번호 + * @apiParam {String} srcFileNm 소스 파일 이름 + * + * @apiExample {curl} Example usage: + * curl -X GET http://localhost:3000/api/qna/file?encodeFileNo=1234567890&srcFileNm=test.pdf + * + * @apiSuccessExample {octet-stream} Success-Response: + * file content + * + * @apiError {Number} 500 서버 오류 + * @apiError {Number} 400 잘못된 요청 + */ async function downloadFile(request: Request): Promise { const { searchParams } = new URL(request.url) const encodeFileNo = searchParams.get('encodeFileNo') diff --git a/src/app/api/submission/route.ts b/src/app/api/submission/route.ts index e0b62ee..8e1acd1 100644 --- a/src/app/api/submission/route.ts +++ b/src/app/api/submission/route.ts @@ -41,22 +41,17 @@ import { loggerWrapper } from '@/libs/api-wrapper' * @apiError {String} error.message 에러 메시지 */ async function getSubmitTargetData(request: NextRequest): Promise { - try { - const { searchParams } = new URL(request.url) - const storeId = searchParams.get('storeId') - const role = searchParams.get('role') + const { searchParams } = new URL(request.url) + const storeId = searchParams.get('storeId') + const role = searchParams.get('role') - if (!storeId || !role) { - return NextResponse.json({ error: ERROR_MESSAGES.BAD_REQUEST }, { status: HttpStatusCode.BadRequest }) - } - - const submissionService = new SubmissionService(storeId, role) - const data = await submissionService.getSubmissionTarget() - return NextResponse.json(data) - } catch (error) { - console.error('❌ API ROUTE ERROR:', error) - return NextResponse.json({ error: ERROR_MESSAGES.FETCH_ERROR }, { status: HttpStatusCode.InternalServerError }) + if (!storeId || !role) { + return NextResponse.json({ error: ERROR_MESSAGES.BAD_REQUEST }, { status: HttpStatusCode.BadRequest }) } + + const submissionService = new SubmissionService(storeId, role) + const data = await submissionService.tryFunction(() => submissionService.getSubmissionTarget()) + return NextResponse.json(data) } export const GET = loggerWrapper(getSubmitTargetData) diff --git a/src/app/api/submission/service.ts b/src/app/api/submission/service.ts index 7f7c86e..12ccbb8 100644 --- a/src/app/api/submission/service.ts +++ b/src/app/api/submission/service.ts @@ -1,5 +1,8 @@ import { prisma } from '@/libs/prisma' +import { ERROR_MESSAGES } from '@/utils/common-utils' import { SubmitTargetResponse } from '@/types/Survey' +import { HttpStatusCode } from 'axios' +import { NextResponse } from 'next/server' export class SubmissionService { private storeId: string @@ -70,4 +73,17 @@ export class SubmissionService { ` return await prisma.$queryRawUnsafe(this.BASE_QUERY + query, this.storeId) } + + handleRouteError(error: unknown): NextResponse { + console.error('❌ API ROUTE ERROR : ', error) + return NextResponse.json({ error: ERROR_MESSAGES.FETCH_ERROR }, { status: HttpStatusCode.InternalServerError }) + } + + async tryFunction(func: () => Promise): Promise { + try { + return await func() + } catch (error) { + return this.handleRouteError(error) + } + } } diff --git a/src/components/survey-sale/detail/ButtonForm.tsx b/src/components/survey-sale/detail/ButtonForm.tsx index c806139..5ca80a5 100644 --- a/src/components/survey-sale/detail/ButtonForm.tsx +++ b/src/components/survey-sale/detail/ButtonForm.tsx @@ -6,6 +6,7 @@ import { useEffect, useState } from 'react' import { useParams, useRouter } from 'next/navigation' import { requiredFields, useSurvey } from '@/hooks/useSurvey' import { usePopupController } from '@/store/popupController' +import { ALERT_MESSAGES } from '@/types/Survey' interface ButtonFormProps { mode: Mode @@ -48,7 +49,7 @@ export default function ButtonForm({ mode, setMode, data }: ButtonFormProps) { const isSubmit = data.basic.submissionStatus const { deleteSurvey, updateSurvey, isDeletingSurvey, isUpdatingSurvey } = useSurvey(id) - const { validateSurveyDetail, createSurvey, isCreatingSurvey } = useSurvey() + const { validateSurveyDetail, createSurvey, isCreatingSurvey, showSurveyAlert, showSurveyConfirm } = useSurvey() useEffect(() => { if (!session?.isLoggedIn) return @@ -116,7 +117,7 @@ export default function ButtonForm({ mode, setMode, data }: ButtonFormProps) { router.push(`/survey-sale/${savedId}`) } } - alert('一時保存されました。') + showSurveyAlert(ALERT_MESSAGES.TEMP_SAVE_SUCCESS) } /** 입력 필드 포커스 처리 */ @@ -128,7 +129,13 @@ export default function ButtonForm({ mode, setMode, data }: ButtonFormProps) { /** 저장 로직 */ const saveProcess = async (emptyField: string | null, isSubmitProcess?: boolean) => { if (emptyField?.trim() === '') { - await handleSuccessfulSave(isSubmitProcess) + if (!isSubmitProcess) { + showSurveyConfirm(ALERT_MESSAGES.SAVE_CONFIRM, async () => { + await handleSuccessfulSave(isSubmitProcess) + }) + } else { + await handleSuccessfulSave(isSubmitProcess) + } } else { handleFailedSave(emptyField) } @@ -147,6 +154,8 @@ export default function ButtonForm({ mode, setMode, data }: ButtonFormProps) { setMode('READ') if (isSubmitProcess) { popupController.setSurveySaleSubmitPopup(true) + } else { + showSurveyAlert(ALERT_MESSAGES.SAVE_SUCCESS) } } } else { @@ -156,7 +165,7 @@ export default function ButtonForm({ mode, setMode, data }: ButtonFormProps) { await router.push(`/survey-sale/${savedId}?show=true`) } else { await router.push(`/survey-sale/${savedId}`) - alert('保存されました。') + showSurveyAlert(ALERT_MESSAGES.SAVE_SUCCESS) } } } @@ -164,9 +173,10 @@ export default function ButtonForm({ mode, setMode, data }: ButtonFormProps) { /** 필수값 미입력 처리 */ const handleFailedSave = (emptyField: string | null) => { if (emptyField?.includes('Unit')) { - alert('電気契約容量の単位を入力してください。') + showSurveyAlert(ALERT_MESSAGES.UNIT_REQUIRED) } else { - alert(requiredFields.find((field) => field.field === emptyField)?.name + ' 項目が空です。') + const fieldInfo = requiredFields.find((field) => field.field === emptyField) + showSurveyAlert(ALERT_MESSAGES.REQUIRED_FIELD, fieldInfo?.name || '') } focusInput(emptyField as keyof SurveyDetailInfo) } @@ -174,10 +184,10 @@ export default function ButtonForm({ mode, setMode, data }: ButtonFormProps) { /** 삭제 로직 */ const handleDelete = async () => { if (!Number.isNaN(id)) { - window.neoConfirm('削除しますか?', async () => { + showSurveyConfirm(ALERT_MESSAGES.DELETE_CONFIRM, async () => { await deleteSurvey() if (!isDeletingSurvey) { - alert('削除されました。') + showSurveyAlert(ALERT_MESSAGES.DELETE_SUCCESS) router.push('/survey-sale') } }) @@ -187,16 +197,16 @@ export default function ButtonForm({ mode, setMode, data }: ButtonFormProps) { /** 제출 로직 */ const handleSubmit = async () => { if (data.basic.srlNo?.startsWith('一時保存') && Number.isNaN(id)) { - alert('一時保存されたデータは提出できません。') + showSurveyAlert(ALERT_MESSAGES.TEMP_SAVE_SUBMIT_ERROR) return } if (mode === 'READ') { - window.neoConfirm('提出しますか?', async () => { + showSurveyConfirm(ALERT_MESSAGES.SUBMIT_CONFIRM, async () => { popupController.setSurveySaleSubmitPopup(true) }) } else { - window.neoConfirm('記入した情報を保存して送信しますか?', async () => { + showSurveyConfirm(ALERT_MESSAGES.SAVE_AND_SUBMIT_CONFIRM, async () => { handleSave(false, true) }) } diff --git a/src/components/survey-sale/detail/RoofForm.tsx b/src/components/survey-sale/detail/RoofForm.tsx index e19d047..36527ae 100644 --- a/src/components/survey-sale/detail/RoofForm.tsx +++ b/src/components/survey-sale/detail/RoofForm.tsx @@ -1,5 +1,7 @@ import { useState } from 'react' import type { Mode, SurveyDetailInfo, SurveyDetailRequest } from '@/types/Survey' +import { useSurvey } from '@/hooks/useSurvey' +import { ALERT_MESSAGES } from '@/types/Survey' type RadioEtcKeys = | 'structureOrder' @@ -247,6 +249,7 @@ export default function RoofForm(props: { mode: Mode }) { const { roofInfo, setRoofInfo, mode } = props + const { showSurveyAlert } = useSurvey() const [isFlip, setIsFlip] = useState(true) const handleNumberInput = (key: keyof SurveyDetailRequest, value: number | string) => { @@ -254,13 +257,13 @@ export default function RoofForm(props: { if (key === 'roofSlope' || key === 'openFieldPlateThickness') { const stringValue = value.toString() if (stringValue.length > 5) { - alert('保存できるサイズを超えました。') + showSurveyAlert('保存できるサイズを超えました。') return } if (stringValue.includes('.')) { const decimalPlaces = stringValue.split('.')[1].length if (decimalPlaces > 1) { - alert('小数点以下1桁までしか許されません。') + showSurveyAlert('小数点以下1桁までしか許されません。') return } } @@ -732,6 +735,7 @@ const MultiCheck = ({ roofInfo: SurveyDetailInfo setRoofInfo: (roofInfo: SurveyDetailRequest) => void }) => { + const { showSurveyAlert } = useSurvey() const multiCheckData = column === 'supplementaryFacilities' ? supplementaryFacilities : roofMaterial const etcValue = roofInfo?.[`${column}Etc` as keyof SurveyDetailInfo] const [isOtherCheck, setIsOtherCheck] = useState(Boolean(etcValue)) @@ -751,7 +755,7 @@ const MultiCheck = ({ if (isRoofMaterial) { const totalSelected = selectedValues.length + (isOtherSelected || isOtherCheck ? 1 : 0) if (totalSelected >= 2) { - alert('屋根材は最大2個まで選択できます。') + showSurveyAlert(ALERT_MESSAGES.ROOF_MATERIAL_MAX_SELECT_ERROR) return } } @@ -765,7 +769,7 @@ const MultiCheck = ({ if (isRoofMaterial) { const currentSelected = selectedValues.length if (!isOtherCheck && currentSelected >= 2) { - alert('屋根材は最大2個まで選択できます。') + showSurveyAlert(ALERT_MESSAGES.ROOF_MATERIAL_MAX_SELECT_ERROR) return } } diff --git a/src/components/survey-sale/list/SearchForm.tsx b/src/components/survey-sale/list/SearchForm.tsx index 83943a9..86ad3c0 100644 --- a/src/components/survey-sale/list/SearchForm.tsx +++ b/src/components/survey-sale/list/SearchForm.tsx @@ -3,16 +3,18 @@ import { SEARCH_OPTIONS, SEARCH_OPTIONS_ENUM, SEARCH_OPTIONS_PARTNERS, useSurveyFilterStore } from '@/store/surveyFilterStore' import { useRouter } from 'next/navigation' import { useState } from 'react' +import { useSurvey } from '@/hooks/useSurvey' export default function SearchForm({ memberRole, userId }: { memberRole: string; userId: string }) { const router = useRouter() + const { showSurveyAlert } = useSurvey() const { setSearchOption, setSort, setIsMySurvey, setKeyword, reset, isMySurvey, keyword, searchOption, sort, setOffset } = useSurveyFilterStore() const [searchKeyword, setSearchKeyword] = useState(keyword) const [option, setOption] = useState(searchOption) const handleSearch = () => { if (option !== 'id' && searchKeyword.trim().length < 2) { - alert('2文字以上入力してください') + showSurveyAlert('2文字以上入力してください') return } setOffset(0) @@ -62,7 +64,7 @@ export default function SearchForm({ memberRole, userId }: { memberRole: string; placeholder="タイトルを入力してください. (2文字以上)" onChange={(e) => { if (e.target.value.length > 30) { - alert('30文字以内で入力してください') + showSurveyAlert('30文字以内で入力してください') return } setSearchKeyword(e.target.value) diff --git a/src/hooks/useSurvey.ts b/src/hooks/useSurvey.ts index 99bd072..fb62253 100644 --- a/src/hooks/useSurvey.ts +++ b/src/hooks/useSurvey.ts @@ -1,4 +1,4 @@ -import type { SubmitTargetResponse, SurveyBasicInfo, SurveyDetailRequest, SurveyRegistRequest } from '@/types/Survey' +import { ALERT_MESSAGES, type SubmitTargetResponse, type SurveyBasicInfo, type SurveyDetailRequest, type SurveyRegistRequest } from '@/types/Survey' import { useMemo } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useSurveyFilterStore } from '@/store/surveyFilterStore' @@ -93,6 +93,8 @@ export function useSurvey( refetchSurveyList: () => void refetchSurveyDetail: () => void getSubmitTarget: (params: { storeId: string; role: string }) => Promise + showSurveyAlert: (message: (typeof ALERT_MESSAGES)[keyof typeof ALERT_MESSAGES] | string, requiredField?: string) => void + showSurveyConfirm: (message: string, onConfirm: () => void, onCancel?: () => void) => void } { const queryClient = useQueryClient() const { keyword, searchOption, isMySurvey, sort, offset } = useSurveyFilterStore() @@ -111,7 +113,7 @@ export function useSurvey( const errorMsg = error.response?.data.error console.error('❌ API ERROR : ', error) if (errorMsg) { - alert(errorMsg) + showSurveyAlert(errorMsg) } if (isThrow) { throw new Error(error) @@ -138,6 +140,19 @@ export function useSurvey( } } + const tryFunction = async (func: () => Promise, isList?: boolean, isThrow?: boolean): Promise => { + try { + const resp = await func() + return resp.data + } catch (error) { + handleError(error, isThrow) + if (isList) { + return { data: [], count: 0 } + } + return null + } + } + /** * @description 조사 매물 목록 조회 * @@ -154,24 +169,23 @@ export function useSurvey( } = useQuery({ queryKey: ['survey', 'list', keyword, searchOption, isMySurvey, sort, offset, session?.storeNm, session?.builderId, session?.role], queryFn: async () => { - try { - const resp = await axiosInstance(null).get<{ data: SurveyBasicInfo[]; count: number }>('/api/survey-sales', { - params: { - keyword, - searchOption, - isMySurvey, - sort, - offset, - storeId: session?.storeId, - builderId: session?.builderId, - role: session?.role, - }, - }) - return resp.data - } catch (error: any) { - handleError(error, false) - return { data: [], count: 0 } - } + return await tryFunction( + () => + axiosInstance(null).get<{ data: SurveyBasicInfo[]; count: number }>('/api/survey-sales', { + params: { + keyword, + searchOption, + isMySurvey, + sort, + offset, + storeId: session?.storeId, + builderId: session?.builderId, + role: session?.role, + }, + }), + true, + false, + ) }, }) @@ -205,17 +219,16 @@ export function useSurvey( queryKey: ['survey', id], queryFn: async () => { if (Number.isNaN(id) || id === undefined || id === 0) return null - try { - const resp = await axiosInstance(null).get(`/api/survey-sales/${id}`, { - params: { - isPdf: isPdf, - }, - }) - return resp.data - } catch (error: any) { - handleError(error, false) - return null - } + return await tryFunction( + () => + axiosInstance(null).get(`/api/survey-sales/${id}`, { + params: { + isPdf: isPdf, + }, + }), + false, + false, + ) }, enabled: id !== 0 && id !== undefined && id !== null, }) @@ -294,7 +307,7 @@ export function useSurvey( queryClient.invalidateQueries({ queryKey: ['survey', 'list'] }) }, onError: (error: any) => { - alert(error.response?.data.error) + handleError(error, true) }, }) @@ -378,15 +391,12 @@ export function useSurvey( * @throws {Error} 우편번호 검색 실패 시 에러 발생 */ const getZipCode = async (zipCode: string): Promise => { - try { - const { data } = await axiosInstance(null).get( - `https://zipcloud.ibsnet.co.jp/api/search?${queryStringFormatter({ zipcode: zipCode.trim() })}`, - ) - return data.results - } catch (error: any) { - handleError(error, true) - return null - } + const data = await tryFunction( + () => axiosInstance(null).get(`https://zipcloud.ibsnet.co.jp/api/search?${queryStringFormatter({ zipcode: zipCode.trim() })}`), + false, + true, + ) + return data ? data.results : null } /** @@ -398,24 +408,38 @@ export function useSurvey( * @returns {Promise} 제출 대상 목록 */ const getSubmitTarget = async (params: { storeId: string; role: string }): Promise => { - try { - if (!params.storeId) { - /** 판매점 ID 없는 경우 */ - alert('販売店IDがありません。') - return null - } - const endpoint = `/api/submission?storeId=${params.storeId}&role=${params.role}` - if (!endpoint) { - /** 권한 오류 */ - alert('権限が間違っています。') - return null - } - const { data } = await axiosInstance(null).get(endpoint) - return data - } catch (error: any) { - handleError(error, true) + if (!params.storeId) { + /** 판매점 ID 없는 경우 */ + showSurveyAlert('販売店IDがありません。') return null } + const endpoint = `/api/submission?storeId=${params.storeId}&role=${params.role}` + if (!endpoint) { + /** 권한 오류 */ + showSurveyAlert('権限が間違っています。') + return null + } + return await tryFunction(() => axiosInstance(null).get(endpoint), false, true) + } + + const showSurveyAlert = (message: (typeof ALERT_MESSAGES)[keyof typeof ALERT_MESSAGES] | string, requiredField?: string) => { + if (requiredField) { + alert(`${requiredField} ${message}`) + } else { + alert(message) + } + } + const showSurveyConfirm = (message: string, onConfirm: () => void, onCancel?: () => void) => { + if (window.neoConfirm) { + window.neoConfirm(message, onConfirm) + } else { + const confirmed = confirm(message) + if (confirmed) { + onConfirm() + } else if (onCancel) { + onCancel() + } + } } return { @@ -436,5 +460,7 @@ export function useSurvey( getSubmitTarget, refetchSurveyList, refetchSurveyDetail, + showSurveyAlert, + showSurveyConfirm, } } diff --git a/src/types/Survey.ts b/src/types/Survey.ts index a653163..c2d80b6 100644 --- a/src/types/Survey.ts +++ b/src/types/Survey.ts @@ -323,3 +323,32 @@ export type SurveySearchParams = { /** 시공점 ID */ builderId?: string | null } + + +export const ALERT_MESSAGES = { + /** 기본 메세지 */ + /** 저장 성공 - "저장되었습니다." */ + SAVE_SUCCESS: '保存されました。', + /** 임시 저장 성공 - "임시 저장되었습니다." */ + TEMP_SAVE_SUCCESS: '一時保存されました。', + /** 삭제 성공 - "삭제되었습니다." */ + DELETE_SUCCESS: '削除されました。', + /** 제출 확인 - "제출하시겠습니까?" */ + SUBMIT_CONFIRM: '提出しますか?', + /** 저장 확인 - "저장하시겠습니까?" */ + SAVE_CONFIRM: '保存しますか?', + /** 삭제 확인 - "삭제하시겠습니까?" */ + DELETE_CONFIRM: '削除しますか?', + /** 저장 및 제출 확인 - "입력한 정보를 저장하고 보내시겠습니까?" */ + SAVE_AND_SUBMIT_CONFIRM: '記入した情報を保存して送信しますか?', + /** 임시 저장 제출 오류 - "임시 저장된 데이터는 제출할 수 없습니다." */ + TEMP_SAVE_SUBMIT_ERROR: '一時保存されたデータは提出できません。', + + /** 입력 오류 메세지 */ + /* 전기계약 용량 단위 입력 메세지 - "전기 계약 용량의 단위를 입력하세요."*/ + UNIT_REQUIRED: '電気契約容量の単位を入力してください。', + /** 필수 입력 메세지 - "항목이 비어 있습니다."*/ + REQUIRED_FIELD: '項目が空です。', + /** 최대 선택 오류 메세지 - "지붕재는 최대 2개까지 선택할 수 있습니다." */ + ROOF_MATERIAL_MAX_SELECT_ERROR: '屋根材は最大2個まで選択できます。', +} \ No newline at end of file