refactor: enhance error handling and user feedback in survey components

- API try-catch 구문 함수 구현
- 조사 매물 alert 처리 리팩토링
This commit is contained in:
Dayoung 2025-06-18 10:43:12 +09:00
parent 1a42848ae9
commit 12b9dd4216
8 changed files with 188 additions and 88 deletions

View File

@ -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<NextResponse> {
const { searchParams } = new URL(request.url)
const encodeFileNo = searchParams.get('encodeFileNo')

View File

@ -41,22 +41,17 @@ import { loggerWrapper } from '@/libs/api-wrapper'
* @apiError {String} error.message
*/
async function getSubmitTargetData(request: NextRequest): Promise<NextResponse> {
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)

View File

@ -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<any>): Promise<any> {
try {
return await func()
} catch (error) {
return this.handleRouteError(error)
}
}
}

View File

@ -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)
})
}

View File

@ -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<boolean>(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>(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
}
}

View File

@ -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)

View File

@ -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<SubmitTargetResponse[] | null>
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<any>, isList?: boolean, isThrow?: boolean): Promise<any> => {
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<SurveyBasicInfo>(`/api/survey-sales/${id}`, {
params: {
isPdf: isPdf,
},
})
return resp.data
} catch (error: any) {
handleError(error, false)
return null
}
return await tryFunction(
() =>
axiosInstance(null).get<SurveyBasicInfo>(`/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<ZipCode[] | null> => {
try {
const { data } = await axiosInstance(null).get<ZipCodeResponse>(
`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<ZipCodeResponse>(`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<SubmitTargetResponse[]|null>}
*/
const getSubmitTarget = async (params: { storeId: string; role: string }): Promise<SubmitTargetResponse[] | null> => {
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<SubmitTargetResponse[]>(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<SubmitTargetResponse[]>(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,
}
}

View File

@ -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個まで選択できます。',
}