diff --git a/src/app/api/qna/detail/route.ts b/src/app/api/qna/detail/route.ts index 1ac937c..d1f8078 100644 --- a/src/app/api/qna/detail/route.ts +++ b/src/app/api/qna/detail/route.ts @@ -1,7 +1,8 @@ -import { queryStringFormatter } from '@/utils/common-utils' +import { ERROR_MESSAGES, queryStringFormatter } from '@/utils/common-utils' import axios from 'axios' import { NextResponse } from 'next/server' import { loggerWrapper } from '@/libs/api-wrapper' +import { HttpStatusCode } from 'axios' async function getQnaDetail(request: Request): Promise { const { searchParams } = new URL(request.url) @@ -19,8 +20,7 @@ async function getQnaDetail(request: Request): Promise { } return NextResponse.json({ error: response.data.result }, { status: response.status }) } catch (error: any) { - console.error(error.response) - return NextResponse.json({ error: 'route error' }, { status: 500 }) + return NextResponse.json({ error: error.response.data.result ?? ERROR_MESSAGES.FETCH_ERROR }, { status: HttpStatusCode.InternalServerError }) } } diff --git a/src/app/api/qna/file/route.ts b/src/app/api/qna/file/route.ts index 49c7209..8d6ea8a 100644 --- a/src/app/api/qna/file/route.ts +++ b/src/app/api/qna/file/route.ts @@ -1,51 +1,33 @@ -import axios from 'axios' +import { HttpStatusCode } from 'axios' import { NextResponse } from 'next/server' import { loggerWrapper } from '@/libs/api-wrapper' +import { ERROR_MESSAGES } from '@/utils/common-utils' -// export async function GET(request: Request) { -// const { searchParams } = new URL(request.url) -// const encodeFileNo = searchParams.get('encodeFileNo') -// const srcFileNm = searchParams.get('srcFileNm') - -// if (!encodeFileNo) { -// return NextResponse.json({ error: 'encodeFileNo is required' }, { status: 400 }) -// } - -// try { -// const response = await axios.get(`${process.env.NEXT_PUBLIC_INQUIRY_API_URL}/api/file/downloadFile2`, { -// params: { -// encodeFileNo, -// }, -// responseType: 'arraybuffer', -// }) - -// if (response.headers['content-type'] === 'text/html;charset=utf-8') { -// return NextResponse.json({ error: 'file not found' }, { status: 404 }) -// } - -// const contentType = response.headers['content-type'] || 'application/octet-stream' -// const contentDisposition = response.headers['content-disposition'] || 'inline' - -// return new NextResponse(response.data, { -// status: 200, -// headers: { -// 'Content-Type': contentType, -// 'Content-Disposition': contentDisposition, -// }, -// }) -// } catch (error: any) { -// console.error('File download error:', error) -// return NextResponse.json({ error: error.response?.data || 'Failed to download file' }, { status: 500 }) -// } -// } - +/** + * @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') const srcFileNm = searchParams.get('srcFileNm') || 'downloaded-file' if (!encodeFileNo) { - return NextResponse.json({ error: 'encodeFileNo is required' }, { status: 400 }) + return NextResponse.json({ error: ERROR_MESSAGES.BAD_REQUEST }, { status: HttpStatusCode.BadRequest }) } const url = `${process.env.NEXT_PUBLIC_INQUIRY_API_URL}/api/file/downloadFile2?encodeFileNo=${encodeFileNo}` @@ -54,7 +36,7 @@ async function downloadFile(request: Request): Promise { const resp = await fetch(url) if (!resp.ok) { - return NextResponse.json({ error: 'Failed to download file' }, { status: 500 }) + return NextResponse.json({ error: ERROR_MESSAGES.FETCH_ERROR }, { status: HttpStatusCode.InternalServerError }) } const contentType = resp.headers.get('content-type') || 'application/octet-stream' @@ -68,8 +50,7 @@ async function downloadFile(request: Request): Promise { }, }) } catch (error: any) { - console.error('File download error:', error) - return NextResponse.json({ error: error.response?.data || 'Failed to download file' }, { status: 500 }) + return NextResponse.json({ error: error.response?.data ?? ERROR_MESSAGES.FETCH_ERROR }, { status: HttpStatusCode.InternalServerError }) } } diff --git a/src/app/api/qna/list/route.ts b/src/app/api/qna/list/route.ts index 12cf96e..7e8807d 100644 --- a/src/app/api/qna/list/route.ts +++ b/src/app/api/qna/list/route.ts @@ -1,6 +1,6 @@ -import axios from 'axios' +import axios, { HttpStatusCode } from 'axios' import { NextResponse } from 'next/server' -import { queryStringFormatter } from '@/utils/common-utils' +import { ERROR_MESSAGES, queryStringFormatter } from '@/utils/common-utils' import { getIronSession } from 'iron-session' import { cookies } from 'next/headers' import { loggerWrapper } from '@/libs/api-wrapper' @@ -12,7 +12,7 @@ async function getQnaList(request: Request): Promise { const session = await getIronSession(cookieStore, sessionOptions) if (!session.isLoggedIn) { - return NextResponse.json({ error: 'ログインしていません。' }, { status: 401 }) + return NextResponse.json({ error: ERROR_MESSAGES.UNAUTHORIZED }, { status: HttpStatusCode.Unauthorized }) } const { searchParams } = new URL(request.url) @@ -32,10 +32,9 @@ async function getQnaList(request: Request): Promise { if (response.status === 200) { return NextResponse.json(response.data) } - return NextResponse.json({ error: 'Failed to fetch qna list' }, { status: response.status }) + return NextResponse.json({ error: response.data.result }, { status: response.status }) } catch (error: any) { - console.error('Error fetching qna list:', error.response.data) - return NextResponse.json({ error: 'route error' }, { status: 500 }) + return NextResponse.json({ error: error.response.data.result ?? ERROR_MESSAGES.FETCH_ERROR }, { status: HttpStatusCode.InternalServerError }) } } diff --git a/src/app/api/qna/save/route.ts b/src/app/api/qna/save/route.ts index 8cd1ad5..4575479 100644 --- a/src/app/api/qna/save/route.ts +++ b/src/app/api/qna/save/route.ts @@ -1,10 +1,10 @@ -import axios from 'axios' +import axios, { HttpStatusCode } from 'axios' import { NextResponse } from 'next/server' import { loggerWrapper } from '@/libs/api-wrapper' +import { ERROR_MESSAGES } from '@/utils/common-utils' async function setQna(request: Request): Promise { const formData = await request.formData() - console.log(formData) try { const response = await axios.post(`${process.env.NEXT_PUBLIC_INQUIRY_API_URL}/api/qna/save`, formData, { headers: { @@ -14,10 +14,9 @@ async function setQna(request: Request): Promise { if (response.status === 200) { return NextResponse.json(response.data) } - return NextResponse.json({ error: response.data }, { status: response.status }) + return NextResponse.json({ error: response.data.result }, { status: response.status }) } catch (error: any) { - console.error('error:: ', error.response) - return NextResponse.json({ error: 'Failed to save qna' }, { status: 500 }) + return NextResponse.json({ error: error.response.data.result ?? ERROR_MESSAGES.FETCH_ERROR }, { status: HttpStatusCode.InternalServerError }) } } diff --git a/src/app/api/submission/admin-sub/route.ts b/src/app/api/submission/admin-sub/route.ts deleted file mode 100644 index 345a3ff..0000000 --- a/src/app/api/submission/admin-sub/route.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { prisma } from '@/libs/prisma' -import { NextRequest, NextResponse } from 'next/server' -import { loggerWrapper } from '@/libs/api-wrapper' -import { SubmitTargetResponse } from '@/types/Survey' - -// 2차점이 자신에게 매핑 된 1차 판매점과 관리자 정보 조회 -async function getSubMissionAdminSub(request: NextRequest): Promise { - try { - const { searchParams } = new URL(request.url) - const id = searchParams.get('id') - - const query = ` - OPEN SYMMETRIC KEY SYMMETRICKEY DECRYPTION BY CERTIFICATE CERTI_QSPJP; - SELECT - MCS.STORE_ID AS targetStoreId - , MCS.STORE_QCAST_NM AS targetStoreNm - , MCP.EOS_LOGIN_ID AS repUserId - , CONVERT(NVARCHAR(100), DecryptByKey(MCP.EMAIL)) AS repUserEmail - , MCP.AUTHORITY AS auth - FROM MS_CUST_STOREID MCS WITH(NOLOCK) - LEFT OUTER JOIN MS_CUST_PERSON MCP WITH(NOLOCK) - ON MCS.COMP_CD = MCP.COMP_CD - AND MCS.STORE_ID = MCP.STORE_ID - AND MCP.DEL_YN = 'N' - WHERE MCS.COMP_CD = '5200' - AND MCS.STORE_ID = (SELECT STORE_ID FROM MS_CUST_AGENCY_STOREID WHERE AGENCY_STORE_ID = '${id}' AND DEL_YN = 'N') - AND MCP.EMAIL IS NOT NULL - AND MCS.DEL_YN = 'N'; - CLOSE SYMMETRIC KEY SYMMETRICKEY; - ` - const data: SubmitTargetResponse[] = await prisma.$queryRawUnsafe(query) - return NextResponse.json(data) - } catch (error) { - console.error('❌ 데이터 조회 중 오류가 발생했습니다:', error) - return NextResponse.json({ error: 'データの取得に失敗しました。' }, { status: 500 }) - } -} - -export const GET = loggerWrapper(getSubMissionAdminSub) diff --git a/src/app/api/submission/admin/route.ts b/src/app/api/submission/admin/route.ts deleted file mode 100644 index 7224546..0000000 --- a/src/app/api/submission/admin/route.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { prisma } from '@/libs/prisma' -import { NextRequest, NextResponse } from 'next/server' -import { loggerWrapper } from '@/libs/api-wrapper' - -type SuperPerson = { - storeId: string - salesOfficeCd: string - fromEmail: string - toEmail: string -} - -async function getSubmissionAdmin(request: NextRequest): Promise { - try { - const { searchParams } = new URL(request.url) - const id = searchParams.get('id') - - const query = ` - OPEN SYMMETRIC KEY SYMMETRICKEY DECRYPTION BY CERTIFICATE CERTI_QSPJP; - SELECT - MCSA.STORE_ID - , BCL.CODE AS SALES_OFFICE_CD - , REF_CHR1 AS FROM_E_MAIL - , REF_CHR2 AS TO_E_MAIL - FROM MS_CUST_STOREID_ADDITNL MCSA WITH(NOLOCK) - LEFT OUTER JOIN IF_PERSON_OFFICE_MAPPING IPOM WITH(NOLOCK) - ON MCSA.KAM_ID = IPOM.LIFNR - AND IF_STS = 'R' - AND VKBUR IS NOT NULL - AND VKBUR != '' - INNER JOIN BC_COMM_L BCL WITH(NOLOCK) - ON BCL.CODE = IPOM.VKBUR - AND BCL.HEAD_CD = '103200' - AND BCL.DEL_YN = 'N' - WHERE MCSA.COMP_CD = '5200' - AND MCSA.STORE_ID = 'A03' - AND MCSA.DEL_YN = 'N' - ; - CLOSE SYMMETRIC KEY SYMMETRICKEY; - ` - const data: SuperPerson[] = await prisma.$queryRawUnsafe(query) - return NextResponse.json(data) - } catch (error) { - console.error('❌ 데이터 조회 중 오류가 발생했습니다:', error) - return NextResponse.json({ error: 'データの取得に失敗しました。' }, { status: 500 }) - } -} - -export const GET = loggerWrapper(getSubmissionAdmin) diff --git a/src/app/api/submission/builder/route.ts b/src/app/api/submission/builder/route.ts deleted file mode 100644 index cf43b08..0000000 --- a/src/app/api/submission/builder/route.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { prisma } from '@/libs/prisma' -import { SubmitTargetResponse } from '@/types/Survey' -import { NextRequest, NextResponse } from 'next/server' -import { loggerWrapper } from '@/libs/api-wrapper' - -// 2차점의 시공권한 user가 해당 판매점의 관리자 정보 조회 -// N == 일반유저, S == 수퍼유저, B == 시공권한유저 -async function getSubmissionBuilder(request: NextRequest): Promise { - try { - const { searchParams } = new URL(request.url) - const id = searchParams.get('id') - - const query = ` - OPEN SYMMETRIC KEY SYMMETRICKEY DECRYPTION BY CERTIFICATE CERTI_QSPJP; - SELECT - MCAS.AGENCY_STORE_ID AS targetStoreId - , MCAS.AGENCY_QCAST_NM AS targetStoreNm - , BQU.USER_ID AS repUserId - , CONVERT(NVARCHAR(100), DecryptByKey(BQU.EMAIL)) AS repUserEmail - , BQU.USER_AUTH_CD AS auth - FROM MS_CUST_AGENCY_STOREID MCAS WITH(NOLOCK) - LEFT OUTER JOIN BC_QM_USER BQU WITH(NOLOCK) - ON MCAS.COMP_CD = BQU.COMP_CD - AND MCAS.AGENCY_STORE_ID = BQU.AGENCY_STORE_ID - AND MCAS.DEL_YN = 'N' - WHERE MCAS.COMP_CD = '5200' - AND MCAS.AGENCY_STORE_ID = '${id}' - AND BQU.EMAIL IS NOT NULL - AND BQU.USER_AUTH_CD != 'B' - AND MCAS.DEL_YN = 'N'; - CLOSE SYMMETRIC KEY SYMMETRICKEY; - ` - const data: SubmitTargetResponse[] = await prisma.$queryRawUnsafe(query) - return NextResponse.json(data) - } catch (error) { - console.error('❌ 데이터 조회 중 오류가 발생했습니다:', error) - return NextResponse.json({ error: 'データの取得に失敗しました。' }, { status: 500 }) - } -} - -export const GET = loggerWrapper(getSubmissionBuilder) diff --git a/src/app/api/submission/route.ts b/src/app/api/submission/route.ts new file mode 100644 index 0000000..00b06aa --- /dev/null +++ b/src/app/api/submission/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from 'next/server' +import { SubmissionService } from './service' +import { HttpStatusCode } from 'axios' +import { ERROR_MESSAGES } from '@/utils/common-utils' +import { loggerWrapper } from '@/libs/api-wrapper' +import { ApiError } from 'next/dist/server/api-utils' + +/** + * @api {GET} /api/submission 제출 대상 조회 + * @apiName GET /api/submission + * @apiGroup Submission + * @apiDescription 제출 대상 조회 + * + * @param {String} storeId 판매점 ID (required) + * @param {String} role 권한 (required) + * + * @apiSuccess {Object} data 제출 대상 목록 + * @apiSuccess {String} data.targetStoreId 판매점 ID + * @apiSuccess {String} data.salesOfficeCd 영업소 코드 + * @apiSuccess {String} data.fromEmail 발신자 이메일 + * @apiSuccess {String} data.toEmail 수신자 이메일 + * + * @returns {Promise} 제출 대상 목록 + * @apiSuccessExample {json} Success-Response: + * { + * "data": [ + * { + * "targetStoreId": "1234567890", + * "salesOfficeCd": "1234567890", + * "fromEmail": "1234567890", + * "toEmail": "1234567890" + * } + * ] + * } + * + * @apiExample {curl} Example usage: + * curl -X GET \ + * -H "Content-Type: application/json" \ + * http://localhost:3000/api/submission?storeId=1234567890&role=admin + * + * @apiError {Object} error 에러 객체 + * @apiError {String} error.message 에러 메시지 + */ +async function getSubmitTargetData(request: NextRequest): Promise { + 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 result = await submissionService.tryFunction(() => submissionService.getSubmissionTarget()) + if (result instanceof ApiError) { + return NextResponse.json({ error: result.message }, { status: result.statusCode }) + } + return NextResponse.json(result) +} + +export const GET = loggerWrapper(getSubmitTargetData) diff --git a/src/app/api/submission/service.ts b/src/app/api/submission/service.ts new file mode 100644 index 0000000..431436c --- /dev/null +++ b/src/app/api/submission/service.ts @@ -0,0 +1,121 @@ +import { prisma } from '@/libs/prisma' +import { ERROR_MESSAGES } from '@/utils/common-utils' +import { SubmitTargetResponse } from '@/types/Survey' +import { HttpStatusCode } from 'axios' +import { ApiError } from 'next/dist/server/api-utils' +import { Prisma } from '@prisma/client' + +export class SubmissionService { + private storeId: string + private role: string + private readonly BASE_QUERY = ` + DECLARE @storeId NVARCHAR(50); + SET @storeId = @p1; + OPEN SYMMETRIC KEY SYMMETRICKEY DECRYPTION BY CERTIFICATE CERTI_QSPJP; + ` + + constructor(storeId: string, role: string) { + this.storeId = storeId + this.role = role + } + + /** + * @description 제출 대상 조회 + * @returns {Promise} 제출 대상 데이터 + */ + async getSubmissionTarget(): Promise { + switch (this.role) { + case 'Admin_Sub': + return this.getSubmissionTargetAdminSub() + case 'Builder': + return this.getSubmissionTargetBuilder() + default: + return new ApiError(HttpStatusCode.BadRequest, ERROR_MESSAGES.BAD_REQUEST) + } + } + + /** + * @description 2차점의 매핑 된 제출 대상 판매점 조회 (Admin_Sub - Musubi) + * @returns {Promise} 제출 대상 데이터 + */ + private async getSubmissionTargetAdminSub(): Promise { + const query = ` + SELECT + MCS.STORE_ID AS targetStoreId + , MCS.STORE_QCAST_NM AS targetStoreNm + , MCP.EOS_LOGIN_ID AS repUserId + , CONVERT(NVARCHAR(100), DecryptByKey(MCP.EMAIL)) AS repUserEmail + , MCP.AUTHORITY AS auth + FROM MS_CUST_STOREID MCS WITH(NOLOCK) + LEFT OUTER JOIN MS_CUST_PERSON MCP WITH(NOLOCK) + ON MCS.COMP_CD = MCP.COMP_CD + AND MCS.STORE_ID = MCP.STORE_ID + AND MCP.DEL_YN = 'N' + WHERE MCS.COMP_CD = '5200' + AND MCS.STORE_ID IN (SELECT STORE_ID FROM MS_CUST_AGENCY_STOREID WHERE AGENCY_STORE_ID = @storeId AND DEL_YN = 'N') + AND MCP.EMAIL IS NOT NULL + AND MCS.DEL_YN = 'N'; + CLOSE SYMMETRIC KEY SYMMETRICKEY; + ` + return await prisma.$queryRawUnsafe(this.BASE_QUERY + query, this.storeId) + } + + /** + * @description 2차점 시공권한 user의 매핑 된 제출 대상 판매점 조회 (Builder - Musubi) + * @returns {Promise} 제출 대상 데이터 + */ + private async getSubmissionTargetBuilder(): Promise { + const query = ` + SELECT + MCAS.AGENCY_STORE_ID AS targetStoreId + , MCAS.AGENCY_QCAST_NM AS targetStoreNm + , BQU.USER_ID AS repUserId + , CONVERT(NVARCHAR(100), DecryptByKey(BQU.EMAIL)) AS repUserEmail + , BQU.USER_AUTH_CD AS auth + FROM MS_CUST_AGENCY_STOREID MCAS WITH(NOLOCK) + LEFT OUTER JOIN BC_QM_USER BQU WITH(NOLOCK) + ON MCAS.COMP_CD = BQU.COMP_CD + AND MCAS.AGENCY_STORE_ID = BQU.AGENCY_STORE_ID + AND MCAS.DEL_YN = 'N' + WHERE MCAS.COMP_CD = '5200' + AND MCAS.AGENCY_STORE_ID = @storeId + AND BQU.EMAIL IS NOT NULL + AND BQU.USER_AUTH_CD != 'B' + AND MCAS.DEL_YN = 'N'; + CLOSE SYMMETRIC KEY SYMMETRICKEY; + ` + return await prisma.$queryRawUnsafe(this.BASE_QUERY + query, this.storeId) + } + + /** + * @description API ROUTE 에러 처리 + * @param error 에러 객체 + * @returns {ApiError} 에러 객체 + */ + handleRouteError(error: any): ApiError { + console.error('❌ API ROUTE ERROR : ', error) + if ( + error instanceof Prisma.PrismaClientInitializationError || + error instanceof Prisma.PrismaClientUnknownRequestError || + error instanceof Prisma.PrismaClientRustPanicError || + error instanceof Prisma.PrismaClientValidationError || + error instanceof Prisma.PrismaClientUnknownRequestError + ) { + return new ApiError(HttpStatusCode.InternalServerError, ERROR_MESSAGES.PRISMA_ERROR) + } + return new ApiError(error.statusCode ?? HttpStatusCode.InternalServerError, error.message ?? ERROR_MESSAGES.FETCH_ERROR) + } + + /** + * @description 비동기 함수 try-catch 처리 함수 + * @param func 비동기 함수 + * @returns {Promise} 에러 객체 또는 함수 결과 + */ + async tryFunction(func: () => Promise): Promise { + try { + return await func() + } catch (error) { + return this.handleRouteError(error) + } + } +} diff --git a/src/app/api/submission/super/route.ts b/src/app/api/submission/super/route.ts deleted file mode 100644 index c950dfe..0000000 --- a/src/app/api/submission/super/route.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { prisma } from '@/libs/prisma' -import { NextRequest, NextResponse } from 'next/server' -import { loggerWrapper } from '@/libs/api-wrapper' - -type SuperPerson = { - storeId: string - userId: string - eMail: string -} - -async function getSubmissionSuper(request: NextRequest): Promise { - try { - const { searchParams } = new URL(request.url) - const id = searchParams.get('id') - - const query = ` - OPEN SYMMETRIC KEY SYMMETRICKEY DECRYPTION BY CERTIFICATE CERTI_QSPJP; - SELECT - MCSA.STORE_ID - , BU.USER_ID - , CONVERT(NVARCHAR(100), DecryptByKey(BU.E_MAIL)) AS E_MAIL - FROM MS_CUST_STOREID_ADDITNL MCSA WITH(NOLOCK) - LEFT OUTER JOIN BC_USER bu WITH(NOLOCK) - ON MCSA.COMP_CD = BU.COMP_CD - AND MCSA.KAM_ID = BU.KAM_ID - AND BU.STAT_CD = 'A' - AND BU.DEL_YN = 'N' - WHERE MCSA.COMP_CD = '5200' - AND MCSA.STORE_ID = 'A03' - AND MCSA.DEL_YN = 'N'; - CLOSE SYMMETRIC KEY SYMMETRICKEY; - ` - const data: SuperPerson[] = await prisma.$queryRawUnsafe(query) - return NextResponse.json(data) - } catch (error) { - console.error('❌ 데이터 조회 중 오류가 발생했습니다:', error); - return NextResponse.json({ error: 'データの取得に失敗しました。' }, { status: 500 }); - } -} - -export const GET = loggerWrapper(getSubmissionSuper) diff --git a/src/app/api/survey-sales/[id]/route.ts b/src/app/api/survey-sales/[id]/route.ts index c1a76bc..9b33d86 100644 --- a/src/app/api/survey-sales/[id]/route.ts +++ b/src/app/api/survey-sales/[id]/route.ts @@ -1,97 +1,12 @@ import { NextRequest, NextResponse } from 'next/server' -import { prisma } from '@/libs/prisma' -import { convertToSnakeCase } from '@/utils/common-utils' import { getIronSession } from 'iron-session' import { sessionOptions } from '@/libs/session' import { cookies } from 'next/headers' import type { SessionData } from '@/types/Auth' -import { Prisma } from '@prisma/client' +import { HttpStatusCode } from 'axios' import { loggerWrapper } from '@/libs/api-wrapper' - -/** - * @description 조사 매물 조회 에러 메시지 - */ -const ERROR_MESSAGES = { - NOT_FOUND: 'データが見つかりません。', - UNAUTHORIZED: 'Unauthorized', - NO_PERMISSION: '該当物件の照会権限がありません。', - FETCH_ERROR: 'データの取得に失敗しました。', -} as const - -/** - * @description T01 조회 권한 체크 - * @param {any} survey 조사 매물 데이터 - * @returns {boolean} 조사 매물 임시 저장 여부 - */ -const checkT01Role = (survey: any): boolean => survey.SRL_NO !== '一時保存' - -/** - * @description Admin (1차 판매점) 권한 체크 - * @param {any} survey 조사 매물 데이터 - * @param {string | null} storeId 판매점 ID - * @returns {boolean} 권한 존재 여부 - */ -const checkAdminRole = (survey: any, storeId: string | null): boolean => { - if (!storeId) return false - return survey.SUBMISSION_STATUS ? survey.SUBMISSION_TARGET_ID === storeId || survey.STORE_ID === storeId : survey.STORE_ID === storeId -} - -/** - * @description Admin_Sub (2차 판매점) 권한 체크 - * @param {any} survey 조사 매물 데이터 - * @param {string | null} storeId 판매점 ID - * @returns {boolean} 권한 존재 여부 - */ -const checkAdminSubRole = (survey: any, storeId: string | null): boolean => { - if (!storeId) return false - return survey.SUBMISSION_STATUS - ? survey.SUBMISSION_TARGET_ID === storeId || (survey.STORE_ID === storeId && !survey.CONSTRUCTION_POINT_ID) - : survey.STORE_ID === storeId && !survey.CONSTRUCTION_POINT_ID -} - -/** - * @description Partner (파트너) 또는 Builder (2차 판매점의 시공권한 회원) 권한 체크 - * @param {any} survey 조사 매물 데이터 - * @param {string | null} builderId 시공점 ID - * @returns {boolean} 권한 존재 여부 - */ -const checkPartnerOrBuilderRole = (survey: any, builderId: string | null): boolean => { - if (!builderId) return false - return survey.CONSTRUCTION_POINT_ID === builderId -} - -/** - * @description 권한 체크 - * @param {any} survey 조사 매물 데이터 - * @param {any} session 세션 데이터 - * @returns {boolean} 권한 존재 여부 - */ -const checkRole = (survey: any, session: any): boolean => { - if (!survey || !session.isLoggedIn) return false - - const roleChecks = { - T01: () => checkT01Role(survey), - Admin: () => checkAdminRole(survey, session.storeId), - Admin_Sub: () => checkAdminSubRole(survey, session.storeId), - Partner: () => checkPartnerOrBuilderRole(survey, session.builderId), - Builder: () => checkPartnerOrBuilderRole(survey, session.builderId), - } - - return roleChecks[session.role as keyof typeof roleChecks]?.() ?? false -} - -/** - * @description 조사 매물 조회 - * @param {number} id 조사 매물 ID - * @returns {Promise} 조사 매물 데이터 - */ -const fetchSurvey = async (id: number) => { - // @ts-ignore - return await prisma.SD_SURVEY_SALES_BASIC_INFO.findFirst({ - where: { ID: id }, - include: { DETAIL_INFO: true }, - }) -} +import { SurveySalesService } from '../service' +import { ApiError } from 'next/dist/server/api-utils' /** * @api {GET} /api/survey-sales/:id 조사 매물 조회 API @@ -130,70 +45,18 @@ const fetchSurvey = async (id: number) => { * } */ async function getSurveySaleDetail(request: NextRequest): Promise { - try { - const cookieStore = await cookies() - const session = await getIronSession(cookieStore, sessionOptions) - const id = request.nextUrl.pathname.split('/').pop() ?? '' - const { searchParams } = new URL(request.url) - const isPdf = searchParams.get('isPdf') === 'true' + const cookieStore = await cookies() + const session = await getIronSession(cookieStore, sessionOptions) + const id = request.nextUrl.pathname.split('/').pop() ?? '' + const { searchParams } = new URL(request.url) + const isPdf = searchParams.get('isPdf') === 'true' - const survey = await fetchSurvey(Number(id)) - if (!survey) { - return NextResponse.json({ error: ERROR_MESSAGES.NOT_FOUND }, { status: 404 }) - } - - /** pdf 데이터 요청 여부, 권한 여부 확인 */ - if (isPdf || checkRole(survey, session)) { - return NextResponse.json(survey) - } - - /** 로그인 여부 확인 */ - if (!session?.isLoggedIn || session?.role === null) { - return NextResponse.json({ error: ERROR_MESSAGES.UNAUTHORIZED }, { status: 401 }) - } - - /** 권한 없음 */ - return NextResponse.json({ error: ERROR_MESSAGES.NO_PERMISSION }, { status: 403 }) - } catch (error) { - console.error('Error fetching survey:', error) - return NextResponse.json({ error: ERROR_MESSAGES.FETCH_ERROR }, { status: 500 }) + const service = new SurveySalesService({}, session) + const result = await service.tryFunction(() => service.fetchSurvey(Number(id), isPdf)) + if (result instanceof ApiError) { + return NextResponse.json({ error: result.message }, { status: result.statusCode }) } -} - -/** - * @description 새로운 SRL_NO 생성 - * @param {string} srlNo 기존 SRL_NO - * @param {string} storeId 판매점 ID - * @param {string} role 권한 - * @returns {Promise} 새로운 SRL_NO - */ -const getNewSrlNo = async (srlNo: string, storeId: string, role: string) => { - const srlRole = role === 'T01' || role === 'Admin' ? 'HO' : role === 'Admin_Sub' || role === 'Builder' ? 'HM' : '' - - let newSrlNo = srlNo - if (srlNo.startsWith('一時保存')) { - //@ts-ignore - const lastSurvey = await prisma.SD_SURVEY_SALES_BASIC_INFO.findFirst({ - where: { - SRL_NO: { - startsWith: srlRole, - }, - }, - orderBy: { - ID: 'desc', - }, - }) - const lastNo = lastSurvey ? parseInt(lastSurvey.SRL_NO.slice(-3)) : 0 - - newSrlNo = - srlRole + - storeId + - new Date().getFullYear().toString().slice(-2) + - (new Date().getMonth() + 1).toString().padStart(2, '0') + - new Date().getDate().toString().padStart(2, '0') + - (lastNo + 1).toString().padStart(3, '0') - } - return newSrlNo + return NextResponse.json(result) } /** @@ -230,33 +93,15 @@ const getNewSrlNo = async (srlNo: string, storeId: string, role: string) => { * } * */ async function updateSurveySaleDetail(request: NextRequest): Promise { - try { - const id = request.nextUrl.pathname.split('/').pop() ?? '' - const body = await request.json() - const { detailInfo, ...basicInfo } = body.survey + const id = request.nextUrl.pathname.split('/').pop() ?? '' + const body = await request.json() + const service = new SurveySalesService({}) - // PUT 요청 시 임시저장 여부 확인 후 임시저장 시 기존 SRL_NO 사용, 기본 저장 시 새로운 SRL_NO 생성 - const newSrlNo = body.isTemporary ? body.survey.srlNo : await getNewSrlNo(body.survey.srlNo, body.storeId, body.role) - // @ts-ignore - const survey = await prisma.SD_SURVEY_SALES_BASIC_INFO.update({ - where: { ID: Number(id) }, - data: { - ...convertToSnakeCase(basicInfo), - SRL_NO: newSrlNo, - UPT_DT: new Date(), - DETAIL_INFO: { - update: convertToSnakeCase(detailInfo), - }, - }, - include: { - DETAIL_INFO: true, - }, - }) - return NextResponse.json(survey) - } catch (error) { - console.error('Error updating survey:', error) - return NextResponse.json({ error: 'データ保存に失敗しました。' }, { status: 500 }) + const result = await service.tryFunction(() => service.updateSurvey(Number(id), body.survey, body.isTemporary, body.storeId, body.role)) + if (result instanceof ApiError) { + return NextResponse.json({ error: result.message }, { status: result.statusCode }) } + return NextResponse.json(result) } /** @@ -278,36 +123,13 @@ async function updateSurveySaleDetail(request: NextRequest): Promise { - try { - const id = request.nextUrl.pathname.split('/').pop() ?? '' - - await prisma.$transaction(async (tx: Prisma.TransactionClient) => { - // @ts-ignore - const detailData = await tx.SD_SURVEY_SALES_BASIC_INFO.findUnique({ - where: { ID: Number(id) }, - select: { - DETAIL_INFO: true, - }, - }) - - if (detailData?.DETAIL_INFO?.ID) { - // @ts-ignore - await tx.SD_SURVEY_SALES_DETAIL_INFO.delete({ - where: { ID: Number(detailData.DETAIL_INFO.ID) }, - }) - } - - // @ts-ignore - await tx.SD_SURVEY_SALES_BASIC_INFO.delete({ - where: { ID: Number(id) }, - }) - }) - - return NextResponse.json({ message: 'success' }) - } catch (error) { - console.error('Error deleting survey:', error) - return NextResponse.json({ error: 'データ削除に失敗しました。' }, { status: 500 }) + const id = request.nextUrl.pathname.split('/').pop() ?? '' + const service = new SurveySalesService({}) + const result = await service.tryFunction(() => service.deleteSurvey(Number(id))) + if (result instanceof ApiError) { + return NextResponse.json({ error: result.message }, { status: result.statusCode }) } + return NextResponse.json({ status: HttpStatusCode.Ok }) } /** @@ -346,25 +168,15 @@ async function deleteSurveySaleDetail(request: NextRequest): Promise { - try { - const id = request.nextUrl.pathname.split('/').pop() ?? '' - const body = await request.json() - // @ts-ignore - const survey = await prisma.SD_SURVEY_SALES_BASIC_INFO.update({ - where: { ID: Number(id) }, - data: { - SUBMISSION_STATUS: true, - SUBMISSION_DATE: new Date(), - SUBMISSION_TARGET_ID: body.targetId, - SUBMISSION_TARGET_NM: body.targetNm, - UPT_DT: new Date(), - }, - }) - return NextResponse.json({ message: 'Survey confirmed successfully', data: survey }) - } catch (error) { - console.error('Error updating survey:', error) - return NextResponse.json({ error: 'データ保存に失敗しました。' }, { status: 500 }) + const id = request.nextUrl.pathname.split('/').pop() ?? '' + const body = await request.json() + const service = new SurveySalesService({}) + + const result = await service.tryFunction(() => service.submitSurvey(Number(id), body.targetId, body.targetNm)) + if (result instanceof ApiError) { + return NextResponse.json({ error: result.message }, { status: result.statusCode }) } + return NextResponse.json({ status: HttpStatusCode.Ok, data: result }) } export const GET = loggerWrapper(getSurveySaleDetail) diff --git a/src/app/api/survey-sales/route.ts b/src/app/api/survey-sales/route.ts index 1f2cfdb..c0acf3e 100644 --- a/src/app/api/survey-sales/route.ts +++ b/src/app/api/survey-sales/route.ts @@ -1,155 +1,12 @@ import { NextResponse } from 'next/server' -import { prisma } from '@/libs/prisma' -import { convertToSnakeCase } from '@/utils/common-utils' import { loggerWrapper } from '@/libs/api-wrapper' -/** - * @description 검색 파라미터 타입 - */ -type SearchParams = { - keyword?: string | null - searchOption?: string | null - isMySurvey?: string | null - sort?: string | null - offset?: string | null - role?: string | null - storeId?: string | null - builderId?: string | null -} - -type WhereCondition = { - AND: any[] - OR?: any[] - [key: string]: any -} - -/** 검색 가능한 필드 옵션 */ -const SEARCH_OPTIONS = [ - 'BUILDING_NAME', - 'REPRESENTATIVE', - 'STORE', - 'STORE_ID', - 'CONSTRUCTION_POINT', - 'CONSTRUCTION_POINT_ID', - 'CUSTOMER_NAME', - 'POST_CODE', - 'ADDRESS', - 'ADDRESS_DETAIL', - 'SRL_NO', -] as const - -/** 페이지당 기본 항목 수 */ -const ITEMS_PER_PAGE = 10 - -/** - * @description 키워드 검색 조건 생성 함수 - * @param {string} keyword 검색 키워드 - * @param {string} searchOption 검색 옵션 - * @returns {WhereCondition} 검색 조건 객체 - */ -const createKeywordSearchCondition = (keyword: string, searchOption: string): WhereCondition => { - const where: WhereCondition = { AND: [] } - - if (searchOption === 'all') { - /** 모든 필드 검색 시 OR 조건 사용 */ - where.OR = [] - - where.OR.push( - ...SEARCH_OPTIONS.map((field) => ({ - [field]: { contains: keyword }, - })), - ) - } else if (SEARCH_OPTIONS.includes(searchOption.toUpperCase() as any)) { - /** 특정 필드 검색 */ - where[searchOption.toUpperCase()] = { contains: keyword } - } - return where -} - -/** - * @description 회원 역할별 검색 조건 생성 함수 - * @param {SearchParams} params 검색 파라미터 - * @returns {WhereCondition} 검색 조건 객체 - */ -const createMemberRoleCondition = (params: SearchParams): WhereCondition => { - const where: WhereCondition = { AND: [] } - - switch (params.role) { - case 'Admin': - where.OR = [ - { - AND: [{ STORE_ID: { equals: params.storeId } }], - }, - { - AND: [{ SUBMISSION_TARGET_ID: { equals: params.storeId } }, { SUBMISSION_STATUS: { equals: true } }], - }, - ] - break - - case 'Admin_Sub': - where.OR = [ - { - AND: [{ STORE_ID: { equals: params.storeId } }, { CONSTRUCTION_POINT_ID: { equals: params.builderId } }], - }, - { - AND: [ - { SUBMISSION_TARGET_ID: { equals: params.storeId } }, - { CONSTRUCTION_POINT_ID: { not: null } }, - { CONSTRUCTION_POINT_ID: { not: '' } }, - { SUBMISSION_STATUS: { equals: true } }, - ], - }, - ] - break - - case 'Builder': - case 'Partner': - where.AND?.push({ - CONSTRUCTION_POINT_ID: { equals: params.builderId }, - }) - break - - case 'T01': - where.OR = [ - { - NOT: { - SRL_NO: { - startsWith: '一時保存', - }, - }, - }, - { - STORE_ID: { - equals: params.storeId, - }, - }, - ] - break - case 'User': - break - } - - return where -} -/** - * @description 권한 별 필수 값 존재 여부 확인, 없을 시 빈 데이터 반환 - * @param {SearchParams} params 검색 파라미터 - * @returns {NextResponse} 세션 체크 결과 - */ -const checkSession = (params: SearchParams) => { - if (params.role === null) { - return NextResponse.json({ data: [], count: 0 }) - } - if (params.role === 'Builder' || params.role === 'Partner') { - if (params.builderId === null) { - return NextResponse.json({ data: [], count: 0 }) - } - } else { - if (params.storeId === null) { - return NextResponse.json({ data: [], count: 0 }) - } - } - return null -} +import { SurveySearchParams } from '@/types/Survey' +import { SurveySalesService } from './service' +import { ApiError } from 'next/dist/server/api-utils' +import { getIronSession } from 'iron-session' +import { sessionOptions } from '@/libs/session' +import { cookies } from 'next/headers' +import { SessionData } from '@/types/Auth' /** * @api {GET} /api/survey-sales 설문 목록 조회 API @@ -194,110 +51,24 @@ const checkSession = (params: SearchParams) => { * */ async function getSurveySales(request: Request) { - try { - /** URL 파라미터 파싱 */ - const { searchParams } = new URL(request.url) - const params: SearchParams = { - keyword: searchParams.get('keyword'), - searchOption: searchParams.get('searchOption'), - isMySurvey: searchParams.get('isMySurvey'), - sort: searchParams.get('sort'), - offset: searchParams.get('offset'), - role: searchParams.get('role'), - storeId: searchParams.get('storeId'), - builderId: searchParams.get('builderId'), - } + const cookieStore = await cookies() + const session = await getIronSession(cookieStore, sessionOptions) - /** 세션 체크 결과 처리 */ - const sessionCheckResult = checkSession(params) - if (sessionCheckResult) { - return sessionCheckResult - } - - /** 검색 조건 구성 */ - const where: WhereCondition = { AND: [] } - - /** 내가 작성한 매물 조건 적용 */ - if (params.isMySurvey) { - where.AND.push({ REPRESENTATIVE_ID: params.isMySurvey }) - } - - /** 키워드 검색 조건 적용 */ - if (params.keyword && params.searchOption) { - where.AND.push(createKeywordSearchCondition(params.keyword, params.searchOption)) - } - - /** 회원 유형 조건 적용 */ - const roleCondition = createMemberRoleCondition(params) - if (Object.keys(roleCondition).length > 0) { - where.AND.push(roleCondition) - } - /** 페이지네이션 데이터 조회 */ - //@ts-ignore - const surveys = await prisma.SD_SURVEY_SALES_BASIC_INFO.findMany({ - where, - orderBy: params.sort === 'created' ? { REG_DT: 'desc' } : { UPT_DT: 'desc' }, - skip: Number(params.offset), - take: ITEMS_PER_PAGE, - }) - /** 전체 개수만 조회 */ - //@ts-ignore - const count = await prisma.SD_SURVEY_SALES_BASIC_INFO.count({ where }) - return NextResponse.json({ data: { data: surveys, count: count } }) - } catch (error) { - console.error(error) - return NextResponse.json({ error: 'データ照会に失敗しました。' }, { status: 500 }) + const { searchParams } = new URL(request.url) + const params: SurveySearchParams = { + keyword: searchParams.get('keyword'), + searchOption: searchParams.get('searchOption'), + isMySurvey: searchParams.get('isMySurvey'), + sort: searchParams.get('sort'), + offset: searchParams.get('offset'), } -} + const surveySalesService = new SurveySalesService(params, session) -/** - * @api {PUT} /api/survey-sales 설문 상세 정보 추가 API - * @apiName PUT /api/survey-sales - * @apiGroup SurveySales - * @apiDescription 설문 상세 정보 추가 API - * - * @apiParam {Number} id 설문 목록 ID (required) - * @apiBody {Object} detail_info 상세 정보 (required) - * - * @apiSuccess {String} message 성공 메시지 - * - * @apiExample {curl} Example usage: - * curl -X PUT \ - * -H "Content-Type: application/json" \ - * -d '{"id": 1, "detail_info": {"memo": "1234567890"}}' \ - * http://localhost:3000/api/survey-sales - * - * @apiSuccessExample {json} Success-Response: - * { - * "message": "Success Update Survey" - * } - * - * @apiError {Number} 500 서버 오류 - */ -async function updateSurveySales(request: Request) { - try { - /** 요청 바디 파싱 */ - const body = await request.json() - - /** 상세 정보 생성을 위한 데이터 구성 */ - const detailInfo = { - ...body.detail_info, - BASIC_INFO_ID: body.id, - } - - /** 상세 정보 생성 */ - //@ts-ignore - await prisma.SD_SURVEY_SALES_DETAIL_INFO.create({ - data: detailInfo, - }) - - return NextResponse.json({ - message: 'Success Update Survey', - }) - } catch (error) { - console.error(error) - return NextResponse.json({ error: 'Fail Update Survey' }, { status: 500 }) + const result = await surveySalesService.tryFunction(() => surveySalesService.getSurveySales()) + if (result instanceof ApiError) { + return NextResponse.json({ error: result.message }, { status: result.statusCode }) } + return NextResponse.json({ data: result }) } /** @@ -337,64 +108,14 @@ async function updateSurveySales(request: Request) { * @apiError {Number} 500 서버 오류 */ async function createSurveySales(request: Request) { - try { - const body = await request.json() - - const role = - body.role === 'T01' || body.role === 'Admin' - ? 'HO' - : body.role === 'Admin_Sub' || body.role === 'Builder' - ? 'HM' - : body.role === 'Partner' - ? '' - : null - - /** 임시 저장 시 임시저장으로 저장 */ - /** 기본 저장 시 (HO/HM) + 판매점ID + yyMMdd + 000 으로 저장 */ - const baseSrlNo = - body.survey.srlNo ?? - role + - body.storeId + - new Date().getFullYear().toString().slice(-2) + - (new Date().getMonth() + 1).toString().padStart(2, '0') + - new Date().getDate().toString().padStart(2, '0') - - // @ts-ignore - const lastSurvey = await prisma.SD_SURVEY_SALES_BASIC_INFO.findFirst({ - where: { - SRL_NO: { - startsWith: role + body.storeId, - }, - }, - orderBy: { - SRL_NO: 'desc', - }, - }) - - /** 마지막 번호 추출 */ - const lastNumber = lastSurvey ? parseInt(lastSurvey.SRL_NO.slice(-3)) : 0 - - /** 새로운 srlNo 생성 - 임시저장일 경우 '임시저장' 으로 저장 */ - const newSrlNo = baseSrlNo.startsWith('一時保存') ? baseSrlNo : baseSrlNo + (lastNumber + 1).toString().padStart(3, '0') - - const { detailInfo, ...basicInfo } = body.survey - // @ts-ignore - const result = await prisma.SD_SURVEY_SALES_BASIC_INFO.create({ - data: { - ...convertToSnakeCase(basicInfo), - SRL_NO: newSrlNo, - DETAIL_INFO: { - create: convertToSnakeCase(detailInfo), - }, - }, - }) - return NextResponse.json(result) - } catch (error) { - console.error(error) - return NextResponse.json({ error: 'データ保存に失敗しました。' }, { status: 500 }) + const body = await request.json() + const surveySalesService = new SurveySalesService({}) + const result = await surveySalesService.tryFunction(() => surveySalesService.createSurvey(body.survey, body.role, body.storeId)) + if (result instanceof ApiError) { + return NextResponse.json({ error: result.message }, { status: result.statusCode }) } + return NextResponse.json(result) } export const GET = loggerWrapper(getSurveySales) -export const PUT = loggerWrapper(updateSurveySales) -export const POST = loggerWrapper(createSurveySales) \ No newline at end of file +export const POST = loggerWrapper(createSurveySales) diff --git a/src/app/api/survey-sales/service.ts b/src/app/api/survey-sales/service.ts new file mode 100644 index 0000000..8b75fb0 --- /dev/null +++ b/src/app/api/survey-sales/service.ts @@ -0,0 +1,455 @@ +import { prisma } from '@/libs/prisma' +import { SurveyBasicInfo, SurveyRegistRequest, SurveySearchParams } from '@/types/Survey' +import { convertToSnakeCase, ERROR_MESSAGES } from '@/utils/common-utils' +import { Prisma } from '@prisma/client' +import type { SessionData } from '@/types/Auth' +import { HttpStatusCode } from 'axios' +import { ApiError } from 'next/dist/server/api-utils' + +type WhereCondition = { + AND: any[] + OR?: any[] + [key: string]: any +} + +/** 검색 옵션 */ +const SEARCH_OPTIONS = [ + 'BUILDING_NAME', + 'REPRESENTATIVE', + 'STORE', + 'STORE_ID', + 'CONSTRUCTION_POINT', + 'CONSTRUCTION_POINT_ID', + 'CUSTOMER_NAME', + 'POST_CODE', + 'ADDRESS', + 'ADDRESS_DETAIL', + 'SRL_NO', +] as const + +/** 페이지당 기본 항목 수 */ +const ITEMS_PER_PAGE = 10 + +/** + * @description 조사 매물 서비스 + * @param {SurveySearchParams} params 검색 파라미터 + */ +export class SurveySalesService { + private params!: SurveySearchParams + private session?: SessionData + + /** + * @description 생성자 + * @param {SurveySearchParams} params 검색 파라미터 + */ + constructor(params: SurveySearchParams, session?: SessionData) { + this.params = params + this.session = session + } + + /** + * @description 권한 별 필수 값 존재 여부 확인, 없을 시 빈 데이터 반환 + * @param {SearchParams} params 검색 파라미터 + * @returns {NextResponse} 세션 체크 결과 + */ + checkSession(): ApiError | null { + if (!this.session?.isLoggedIn || this.session?.role === null) { + return new ApiError(HttpStatusCode.Unauthorized, ERROR_MESSAGES.UNAUTHORIZED) + } + if (this.session?.role === 'Builder' || this.session?.role === 'Partner') { + if (this.params.builderId === null) { + return new ApiError(HttpStatusCode.Forbidden, ERROR_MESSAGES.NO_PERMISSION) + } + } else { + if (this.session?.storeId === null) { + return new ApiError(HttpStatusCode.Forbidden, ERROR_MESSAGES.NO_PERMISSION) + } + } + return null + } + + /** + * @description 내가 작성한 매물 조건 생성 + * @returns {WhereCondition} 내가 작성한 매물 조건 + */ + private createMySurveyCondition(): WhereCondition { + if (!this.params.isMySurvey) return { AND: [] } + return { AND: [{ REPRESENTATIVE_ID: this.params.isMySurvey }] } + } + + /** + * @description 키워드 검색 조건 생성 + * @returns {WhereCondition} 키워드 검색 조건 + */ + private createKeywordCondition(): WhereCondition { + if (!this.params.keyword || !this.params.searchOption) return { AND: [] } + + const where: WhereCondition = { AND: [] } + if (this.params.searchOption === 'all') { + where.OR = SEARCH_OPTIONS.map((field) => ({ + [field]: { contains: this.params.keyword }, + })) + } else if (SEARCH_OPTIONS.includes(this.params.searchOption?.toUpperCase() as (typeof SEARCH_OPTIONS)[number])) { + where[this.params.searchOption?.toUpperCase() as (typeof SEARCH_OPTIONS)[number]] = { contains: this.params.keyword } + } + return where + } + + /** + * @description 역할 기반 조건 생성 + * @returns {WhereCondition} 역할 기반 조건 + * @exampleResult { AND: [{ STORE_ID: { equals: '1234567890' } }] } + * + * @description T01 : 임시저장되지 않은 전체 매물 조회 + * @description Admin : 같은 판매점에서 작성된 매물, 2차점에게 제출받은 매물 조회 + * @description Admin_Sub : 같은 판매점에서 작성된 매물, 시공권한 user에게 제출받은 매물 조회 + * @description Builder : 같은 시공점에서 작성된 매물 조회 + * @description Partner : 같은 시공점에서 작성된 매물 조회 + */ + private createRoleCondition(): WhereCondition { + const where: WhereCondition = { AND: [] } + + switch (this.session?.role) { + case 'Admin': + where.OR = [ + { AND: [{ STORE_ID: { equals: this.session?.storeId } }] }, + { AND: [{ SUBMISSION_TARGET_ID: { equals: this.session?.storeId } }, { SUBMISSION_STATUS: { equals: true } }] }, + ] + break + case 'Admin_Sub': + where.OR = [ + { + AND: [{ STORE_ID: { equals: this.session?.storeId } }, { CONSTRUCTION_POINT_ID: { equals: this.session?.builderId } }], + }, + { + AND: [ + { SUBMISSION_TARGET_ID: { equals: this.session?.storeId } }, + { CONSTRUCTION_POINT_ID: { not: null } }, + { CONSTRUCTION_POINT_ID: { not: '' } }, + { SUBMISSION_STATUS: { equals: true } }, + ], + }, + ] + break + case 'Builder': + case 'Partner': + where.AND.push({ CONSTRUCTION_POINT_ID: { equals: this.session?.builderId } }) + break + case 'T01': + where.OR = [{ NOT: { SRL_NO: { startsWith: '一時保存' } } }, { STORE_ID: { equals: this.session?.storeId } }] + break + } + return where + } + + /** + * @description 조사 매물 검색 조건 생성 + * @returns {WhereCondition} 조사 매물 검색 조건 + */ + createFilterSurvey(): WhereCondition { + const where: WhereCondition = { AND: [] } + + /** 내가 작성한 매물 조건 */ + const mySurveyCondition = this.createMySurveyCondition() + if (mySurveyCondition.AND.length > 0) { + where.AND.push(mySurveyCondition) + } + + /** 키워드 검색 조건 */ + const keywordCondition = this.createKeywordCondition() + if (Object.keys(keywordCondition).length > 0) { + where.AND.push(keywordCondition) + } + + /** 역할 기반 조건 */ + const roleCondition = this.createRoleCondition() + if (Object.keys(roleCondition).length > 0) { + where.AND.push(roleCondition) + } + return where + } + + /** + * @description 조사 매물 검색 + * @param {WhereCondition} where 조사 매물 검색 조건 + * @returns {Promise<{ data: SurveyBasicInfo[], count: number }>} 조사 매물 데이터 + */ + async getSurveySales(): Promise<{ data: SurveyBasicInfo[]; count: number } | ApiError> { + const sessionCheckResult = this.checkSession() + if (sessionCheckResult) { + return sessionCheckResult + } + + const where = this.createFilterSurvey() + /** 조사 매물 조회 */ + //@ts-ignore + const surveys = await prisma.SD_SURVEY_SALES_BASIC_INFO.findMany({ + where, + orderBy: this.params.sort === 'created' ? { REG_DT: 'desc' } : { UPT_DT: 'desc' }, + skip: Number(this.params.offset), + take: ITEMS_PER_PAGE, + }) + + /** 조사 매물 개수 조회 */ + //@ts-ignore + const count = await prisma.SD_SURVEY_SALES_BASIC_INFO.count({ where }) + return { data: surveys as SurveyBasicInfo[], count } + } + + /** + * @description 조사 매물 생성 + * @param {SurveyRegistRequest} survey 조사 매물 데이터 + * @param {string} role 권한 + * @param {string} storeId 판매점 ID + * @returns {Promise} 생성된 조사 매물 데이터 + */ + async createSurvey(survey: SurveyRegistRequest, role: string, storeId: string) { + const { detailInfo, ...basicInfo } = survey + const newSrlNo = survey.srlNo ?? (await this.getNewSrlNo(storeId, role)) + // @ts-ignore + return await prisma.SD_SURVEY_SALES_BASIC_INFO.create({ + data: { + ...convertToSnakeCase(basicInfo), + SRL_NO: newSrlNo, + DETAIL_INFO: { + create: convertToSnakeCase(detailInfo), + }, + }, + }) + } + + /** + * @description 새로운 srlNo 생성 함수 + * @param {string} role 세션에 저장된 권한 + * @param {string} tempSrlNo 임시 srlNo (임시저장 시 사용) + * @param {string} storeId 세션에 저장된 판매점 ID + * @returns {Promise} 새로운 srlNo + * + * @exampleResult HO250617001 (HO + 250617 + 001) + */ + async getNewSrlNo(storeId: string, role: string) { + const srlRole = role === 'T01' || role === 'Admin' ? 'HO' : role === 'Admin_Sub' || role === 'Builder' ? 'HM' : '' + + //@ts-ignore + const index = await prisma.SD_SURVEY_SALES_BASIC_INFO.count({ + where: { + SRL_NO: { + startsWith: srlRole + storeId, + }, + }, + }) + + return ( + srlRole + + storeId + + new Date().getFullYear().toString().slice(-2) + + (new Date().getMonth() + 1).toString().padStart(2, '0') + + new Date().getDate().toString().padStart(2, '0') + + (index + 1).toString().padStart(3, '0') + ) + } + + /** + * @description 조사 매물 상세 조회 + * @param {number} id 조사 매물 ID + * @returns {Promise} 조사 매물 데이터 + */ + async fetchSurvey(id: number, isPdf: boolean): Promise { + // @ts-ignore + const result = await prisma.SD_SURVEY_SALES_BASIC_INFO.findFirst({ + where: { ID: id }, + include: { DETAIL_INFO: true }, + }) + if (!result) { + return new ApiError(HttpStatusCode.NotFound, ERROR_MESSAGES.NOT_FOUND) + } + if (!isPdf) { + if (!this.session?.isLoggedIn) return new ApiError(HttpStatusCode.Unauthorized, ERROR_MESSAGES.UNAUTHORIZED) + if (!this.checkRole(result, this.session as SessionData)) return new ApiError(HttpStatusCode.Forbidden, ERROR_MESSAGES.NO_PERMISSION) + } + return result + } + + /** + * @description 조사 매물 수정 + * @param {number} id 조사 매물 ID + * @param {SurveyRegistRequest} survey 조사 매물 데이터 + * @param {boolean} isTemporary 임시 저장 여부 + * @param {string} storeId 판매점 ID + * @param {string} role 권한 + * @returns {Promise} 수정된 조사 매물 데이터 + */ + async updateSurvey(id: number, survey: SurveyRegistRequest, isTemporary: boolean, storeId: string, role: string) { + const { detailInfo, ...basicInfo } = survey + const newSrlNo = isTemporary ? survey.srlNo ?? '一時保存' : await this.getNewSrlNo(storeId, role) + + // @ts-ignore + return (await prisma.SD_SURVEY_SALES_BASIC_INFO.update({ + where: { ID: Number(id) }, + data: { + ...convertToSnakeCase(basicInfo), + SRL_NO: newSrlNo, + UPT_DT: new Date(), + DETAIL_INFO: { + update: convertToSnakeCase(detailInfo), + }, + }, + include: { + DETAIL_INFO: true, + }, + })) as SurveyBasicInfo + } + + /** + * @description 조사 매물 삭제 + * @param {number} id 조사 매물 ID + */ + async deleteSurvey(id: number) { + await prisma.$transaction(async (tx: Prisma.TransactionClient) => { + // @ts-ignore + const detailData = await tx.SD_SURVEY_SALES_BASIC_INFO.findUnique({ + where: { ID: Number(id) }, + select: { + DETAIL_INFO: true, + }, + }) + + if (detailData?.DETAIL_INFO?.ID) { + // @ts-ignore + await tx.SD_SURVEY_SALES_DETAIL_INFO.delete({ + where: { ID: Number(detailData.DETAIL_INFO.ID) }, + }) + } + + // @ts-ignore + await tx.SD_SURVEY_SALES_BASIC_INFO.delete({ + where: { ID: Number(id) }, + }) + }) + } + + /** + * @description 조사 매물 제출 + * @param {number} id 조사 매물 ID + * @param {string} targetId 제출 대상 ID + * @param {string} targetNm 제출 대상 이름 + * @returns {Promise} 제출된 조사 매물 데이터 + */ + async submitSurvey(id: number, targetId: string, targetNm: string) { + // @ts-ignore + return (await prisma.SD_SURVEY_SALES_BASIC_INFO.update({ + where: { ID: Number(id) }, + data: { + SUBMISSION_STATUS: true, + SUBMISSION_DATE: new Date(), + SUBMISSION_TARGET_ID: targetId, + SUBMISSION_TARGET_NM: targetNm, + UPT_DT: new Date(), + }, + })) as SurveyBasicInfo + } + + /** + * @description 권한 체크 + * @param {any} survey 조사 매물 데이터 + * @param {SessionData} session 세션 데이터 + * @returns {boolean} 해당 매물의 조회 권한 여부 (true: 권한 있음, false: 권한 없음) + */ + checkRole(survey: any, session: SessionData): boolean { + if (!survey || !session) return false + + const roleChecks = { + T01: () => this.checkT01Role(survey), + Admin: () => this.checkAdminRole(survey, session.storeId), + Admin_Sub: () => this.checkAdminSubRole(survey, session.storeId), + Partner: () => this.checkPartnerOrBuilderRole(survey, session.builderId), + Builder: () => this.checkPartnerOrBuilderRole(survey, session.builderId), + } + + return roleChecks[session.role as keyof typeof roleChecks]?.() ?? false + } + + /** + * @description T01 권한 체크 + * - 임시저장 매물을 제외한 전 매물 조회 가능 + * + * @param {any} survey 조사 매물 데이터 + * @returns {boolean} 해당 매물의 조회 권한 여부 (true: 권한 있음, false: 권한 없음) + */ + private checkT01Role(survey: any): boolean { + return survey.SRL_NO !== '一時保存' + } + + /** + * @description Admin 권한 체크 (1차점 - Order) + * - 같은 판매점에서 작성한 매물, 제출 받은 매물 조회 가능 + * + * @param {any} survey 조사 매물 데이터 + * @param {string | null} storeId 판매점 ID + * @returns {boolean} 해당 매물의 조회 권한 여부 (true: 권한 있음, false: 권한 없음) + */ + private checkAdminRole(survey: any, storeId: string | null): boolean { + if (!storeId) return false + return survey.SUBMISSION_STATUS ? survey.SUBMISSION_TARGET_ID === storeId || survey.STORE_ID === storeId : survey.STORE_ID === storeId + } + + /** + * @description Admin_Sub 권한 체크 (2차점 - Musubi) + * - 같은 판매점에서 작성한 매물, 시공권한 user에게 제출받은 매물 조회 가능 + * + * @param {any} survey 조사 매물 데이터 + * @param {string | null} storeId 판매점 ID + * @returns {boolean} 해당 매물의 조회 권한 여부 (true: 권한 있음, false: 권한 없음) + */ + private checkAdminSubRole(survey: any, storeId: string | null): boolean { + if (!storeId) return false + return survey.SUBMISSION_STATUS + ? survey.SUBMISSION_TARGET_ID === storeId || (survey.STORE_ID === storeId && !survey.CONSTRUCTION_POINT_ID) + : survey.STORE_ID === storeId && !survey.CONSTRUCTION_POINT_ID + } + + /** + * @description Partner 또는 Builder 권한 체크 + * - 같은 시공점에서 작성한 매물 조회 가능 + * + * @param {any} survey 조사 매물 데이터 + * @param {string | null} builderId 시공점 ID + * @returns {boolean} 해당 매물의 조회 권한 여부 (true: 권한 있음, false: 권한 없음) + */ + private checkPartnerOrBuilderRole(survey: any, builderId: string | null): boolean { + if (!builderId) return false + return survey.CONSTRUCTION_POINT_ID === builderId + } + + /** + * @description API ROUTE 에러 처리 + * @param {any} error 에러 객체 + * @returns {ApiError} 에러 객체 + */ + handleRouteError(error: any): ApiError { + console.error('❌ API ROUTE ERROR : ', error) + if ( + error instanceof Prisma.PrismaClientInitializationError || + error instanceof Prisma.PrismaClientUnknownRequestError || + error instanceof Prisma.PrismaClientRustPanicError || + error instanceof Prisma.PrismaClientValidationError || + error instanceof Prisma.PrismaClientUnknownRequestError + ) { + return new ApiError(HttpStatusCode.InternalServerError, ERROR_MESSAGES.PRISMA_ERROR) + } + return new ApiError(error.statusCode ?? HttpStatusCode.InternalServerError, error.message ?? ERROR_MESSAGES.FETCH_ERROR) + } + + /** + * @description 비동기 함수 try-catch 처리 함수 + * @param {() => Promise} func 비동기 함수 + * @returns {Promise} 에러 객체 또는 함수 결과 + */ + async tryFunction(func: () => Promise): Promise { + try { + return await func() + } catch (error) { + return this.handleRouteError(error) + } + } +} diff --git a/src/components/pdf/SurveySaleDownloadPdf.tsx b/src/components/pdf/SurveySaleDownloadPdf.tsx index 0030612..0999906 100644 --- a/src/components/pdf/SurveySaleDownloadPdf.tsx +++ b/src/components/pdf/SurveySaleDownloadPdf.tsx @@ -7,13 +7,14 @@ import { useSurvey } from '@/hooks/useSurvey' import { radioEtcData, roofMaterial, selectBoxOptions, supplementaryFacilities } from '../survey-sale/detail/RoofForm' import { useSpinnerStore } from '@/store/spinnerStore' import { useSessionStore } from '@/store/session' +import { SURVEY_ALERT_MSG } from '@/types/Survey' export default function SurveySaleDownloadPdf() { const params = useParams() const id = params.id const router = useRouter() - const { surveyDetail, isLoadingSurveyDetail } = useSurvey(Number(id), true) + const { surveyDetail, isLoadingSurveyDetail, showSurveyAlert } = useSurvey(Number(id), true) const { setIsShow } = useSpinnerStore() const { session } = useSessionStore() @@ -23,11 +24,6 @@ export default function SurveySaleDownloadPdf() { /** 페이지 랜더링 이후 PDF 생성 */ useEffect(() => { if (isLoadingSurveyDetail || isGeneratedRef.current) return - if (surveyDetail === null) { - alert('データが見つかりません。') - router.replace('/') - return - } isGeneratedRef.current = true handleDownPdf() }, [surveyDetail?.id, isLoadingSurveyDetail]) @@ -65,10 +61,11 @@ export default function SurveySaleDownloadPdf() { } else { router.replace('/') } - alert('PDFの生成が完了しました。 ポップアップウィンドウからダウンロードしてください。') + showSurveyAlert('PDFの生成が完了しました。 ポップアップウィンドウからダウンロードしてください。') }) .catch((error: any) => { - console.error('error', error) + console.error('❌ PDF GENERATION ERROR', error) + showSurveyAlert(SURVEY_ALERT_MSG.PDF_GENERATION_ERROR) }) } diff --git a/src/components/survey-sale/detail/ButtonForm.tsx b/src/components/survey-sale/detail/ButtonForm.tsx index c806139..c731d98 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 { SURVEY_ALERT_MSG } 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(SURVEY_ALERT_MSG.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(SURVEY_ALERT_MSG.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(SURVEY_ALERT_MSG.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(SURVEY_ALERT_MSG.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(SURVEY_ALERT_MSG.UNIT_REQUIRED) } else { - alert(requiredFields.find((field) => field.field === emptyField)?.name + ' 項目が空です。') + const fieldInfo = requiredFields.find((field) => field.field === emptyField) + showSurveyAlert(SURVEY_ALERT_MSG.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(SURVEY_ALERT_MSG.DELETE_CONFIRM, async () => { await deleteSurvey() if (!isDeletingSurvey) { - alert('削除されました。') + showSurveyAlert(SURVEY_ALERT_MSG.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(SURVEY_ALERT_MSG.TEMP_SAVE_SUBMIT_ERROR) return } if (mode === 'READ') { - window.neoConfirm('提出しますか?', async () => { + showSurveyConfirm(SURVEY_ALERT_MSG.SUBMIT_CONFIRM, async () => { popupController.setSurveySaleSubmitPopup(true) }) } else { - window.neoConfirm('記入した情報を保存して送信しますか?', async () => { + showSurveyConfirm(SURVEY_ALERT_MSG.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..35d7828 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 { SURVEY_ALERT_MSG } 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(SURVEY_ALERT_MSG.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(SURVEY_ALERT_MSG.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 bee304a..98821a1 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 { SURVEY_ALERT_MSG, 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' @@ -7,6 +7,8 @@ import { useAxios } from './useAxios' import { queryStringFormatter } from '@/utils/common-utils' import { useRouter } from 'next/navigation' + + export const requiredFields = [ { field: 'installationSystem', @@ -93,6 +95,8 @@ export function useSurvey( refetchSurveyList: () => void refetchSurveyDetail: () => void getSubmitTarget: (params: { storeId: string; role: string }) => Promise + showSurveyAlert: (message: (typeof SURVEY_ALERT_MSG)[keyof typeof SURVEY_ALERT_MSG] | string, requiredField?: string) => void + showSurveyConfirm: (message: string, onConfirm: () => void, onCancel?: () => void) => void } { const queryClient = useQueryClient() const { keyword, searchOption, isMySurvey, sort, offset } = useSurveyFilterStore() @@ -104,12 +108,18 @@ export function useSurvey( * @description 조사 매물 목록, 상세 데이터 조회 에러 처리 * * @param {any} error 에러 객체 - * @returns {void} 라우팅 처리 + * @param {boolean} isThrow 에러 Throw 처리 여부 + * @returns {void} 라우팅 처리 / 에러 Throw 처리 */ - const errorRouter = (error: any) => { + const handleError = (error: any, isThrow?: boolean) => { const status = error.response?.status - if (error.response?.data.error) { - alert(error.response?.data.error) + const errorMsg = error.response?.data.error + console.error('❌ API ERROR : ', error) + if (errorMsg) { + showSurveyAlert(errorMsg) + } + if (isThrow) { + throw new Error(error) } switch (status) { /** session 없는 경우 */ @@ -133,6 +143,27 @@ export function useSurvey( } } + /** + * @description 조사 매물 try catch 처리 함수 + * + * @param {Function} func 조사 매물 API 함수 + * @param {boolean} isList 조사 매물 목록 여부 + * @param {boolean} isThrow 조사 매물 데이터 조회 에러 처리 여부 + * @returns {Promise} API 응답 데이터 + */ + 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 조사 매물 목록 조회 * @@ -147,27 +178,24 @@ export function useSurvey( isLoading: isLoadingSurveyList, refetch: refetchSurveyList, } = useQuery({ - queryKey: ['survey', 'list', keyword, searchOption, isMySurvey, sort, offset, session?.storeNm, session?.builderId, session?.role], + queryKey: ['survey', 'list', keyword, searchOption, isMySurvey, sort, offset], 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) { - errorRouter(error) - return { data: [], count: 0 } - } + return await tryFunction( + () => + axiosInstance(null).get<{ data: SurveyBasicInfo[]; count: number }>('/api/survey-sales', { + params: { + keyword, + searchOption, + isMySurvey, + sort, + offset, + }, + }), + true, + false, + ) }, + enabled: !isPdf, }) /** @@ -200,17 +228,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) { - errorRouter(error) - return null - } + return await tryFunction( + () => + axiosInstance(null).get(`/api/survey-sales/${id}`, { + params: { + isPdf: isPdf, + }, + }), + false, + false, + ) }, enabled: id !== 0 && id !== undefined && id !== null, }) @@ -234,6 +261,9 @@ export function useSurvey( queryClient.invalidateQueries({ queryKey: ['survey', 'list'] }) queryClient.invalidateQueries({ queryKey: ['survey', id] }) }, + onError: (error: any) => { + handleError(error, true) + }, }) /** @@ -248,7 +278,7 @@ export function useSurvey( */ const { mutate: updateSurvey, isPending: isUpdatingSurvey } = useMutation({ mutationFn: async ({ survey, isTemporary, storeId }: { survey: SurveyRegistRequest; isTemporary: boolean; storeId?: string | null }) => { - if (id === undefined) throw new Error('id is required') + if (id === undefined) throw new Error() const resp = await axiosInstance(null).put(`/api/survey-sales/${id}`, { survey: survey, isTemporary: isTemporary, @@ -262,7 +292,7 @@ export function useSurvey( queryClient.invalidateQueries({ queryKey: ['survey', 'list'] }) }, onError: (error: any) => { - alert(error.response?.data.error) + handleError(error, true) }, }) @@ -278,7 +308,7 @@ export function useSurvey( */ const { mutateAsync: deleteSurvey, isPending: isDeletingSurvey } = useMutation({ mutationFn: async () => { - if (id === null) throw new Error('id is required') + if (id === null) throw new Error() const resp = await axiosInstance(null).delete(`/api/survey-sales/${id}`) return resp.data }, @@ -286,7 +316,7 @@ export function useSurvey( queryClient.invalidateQueries({ queryKey: ['survey', 'list'] }) }, onError: (error: any) => { - alert(error.response?.data.error) + handleError(error, true) }, }) @@ -301,7 +331,7 @@ export function useSurvey( */ const { mutateAsync: submitSurvey, isPending: isSubmittingSurvey } = useMutation({ mutationFn: async ({ targetId, targetNm }: { targetId?: string | null; targetNm?: string | null }) => { - if (!id) throw new Error('id is required') + if (!id) throw new Error() const resp = await axiosInstance(null).patch(`/api/survey-sales/${id}`, { targetId, targetNm, @@ -313,7 +343,7 @@ export function useSurvey( queryClient.invalidateQueries({ queryKey: ['survey', id] }) }, onError: (error: any) => { - alert(error.response?.data.error) + handleError(error, true) }, }) @@ -370,16 +400,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) { - console.error('Failed to fetch zipcode data:', error) - alert(error.response?.data.error) - throw new Error('Failed to fetch zipcode data') - } + 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 } /** @@ -391,29 +417,55 @@ export function useSurvey( * @returns {Promise} 제출 대상 목록 */ const getSubmitTarget = async (params: { storeId: string; role: string }): Promise => { - try { - if (!params.storeId) { - alert('販売店IDがありません。') - return null - } - - const endpoints = { - Admin_Sub: `/api/submission/admin-sub?id=${params.storeId}`, - Builder: `/api/submission/builder?id=${params.storeId}`, - } as const - - const endpoint = endpoints[params.role as keyof typeof endpoints] - if (!endpoint) { - alert('権限が間違っています。') - return null - } - - const { data } = await axiosInstance(null).get(endpoint) - return data - } catch (error: any) { - alert(error.response?.data.error) + 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) + } + + /** + * @description 조사 매물 알림 메시지 출력 + * + * @param {string} message 알림 메시지 + * @param {string} [requiredField] 필수 필드 이름 + */ + const showSurveyAlert = ( + message: (typeof SURVEY_ALERT_MSG)[keyof typeof SURVEY_ALERT_MSG] | string, + requiredField?: (typeof requiredFields)[number]['field'], + ) => { + if (requiredField) { + alert(`${requiredField} ${message}`) + } else { + alert(message) + } + } + + /** + * @description 조사 매물 확인 메시지 출력 + * + * @param {string} message 확인 메시지 + * @param {Function} onConfirm 확인 함수 + * @param {Function} [onCancel] 취소 함수 + */ + 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 { @@ -434,5 +486,7 @@ export function useSurvey( getSubmitTarget, refetchSurveyList, refetchSurveyDetail, + showSurveyAlert, + showSurveyConfirm, } } diff --git a/src/types/Survey.ts b/src/types/Survey.ts index d46dae7..3802916 100644 --- a/src/types/Survey.ts +++ b/src/types/Survey.ts @@ -301,3 +301,57 @@ export type SubmitTargetResponse = { /* 권한 */ auth: string } + +/** + * @description 조사매물 검색 파라미터 타입 + */ +export type SurveySearchParams = { + /** 검색 키워드 */ + keyword?: string | null + /** 검색 옵션 */ + searchOption?: string | null + /** 내 조사매물 여부 */ + isMySurvey?: string | null + /** 정렬 옵션 */ + sort?: string | null + /** 페이지 번호 */ + offset?: string | null + /** 권한 */ + role?: string | null + /** 판매점 ID */ + storeId?: string | null + /** 시공점 ID */ + builderId?: string | null +} + + +export const SURVEY_ALERT_MSG = { + /** 기본 메세지 */ + /** 저장 성공 - "저장되었습니다." */ + 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個まで選択できます。', + + /** PDF 생성 오류 - "PDF 생성에 실패했습니다." */ + PDF_GENERATION_ERROR: 'PDF 生成に失敗しました。', +} \ No newline at end of file diff --git a/src/utils/common-utils.js b/src/utils/common-utils.js index d0c7f65..e4eb144 100644 --- a/src/utils/common-utils.js +++ b/src/utils/common-utils.js @@ -155,7 +155,7 @@ export const unescapeString = (str) => { */ while (regex.test(str)) { - str = str.replace(regex, (matched) => chars[matched] || matched); + str = str.replace(regex, (matched) => chars[matched] || matched) } return str } @@ -186,27 +186,44 @@ function isObject(value) { return value !== null && typeof value === 'object' } - // 카멜케이스를 스네이크케이스로 변환하는 함수 export const toSnakeCase = (str) => { - return str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); + return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`) } // 객체의 키를 스네이크케이스로 변환하는 함수 export const convertToSnakeCase = (obj) => { - if (obj === null || obj === undefined) return obj; - + if (obj === null || obj === undefined) return obj + if (Array.isArray(obj)) { return obj.map((item) => convertToSnakeCase(item)) } if (typeof obj === 'object') { return Object.keys(obj).reduce((acc, key) => { - const snakeKey = toSnakeCase(key).toUpperCase(); - acc[snakeKey] = convertToSnakeCase(obj[key]); - return acc; - }, {}); + const snakeKey = toSnakeCase(key).toUpperCase() + acc[snakeKey] = convertToSnakeCase(obj[key]) + return acc + }, {}) } - - return obj; + + return obj +} + +/** + * @description 조사 매물 조회 에러 메시지 + */ +export const ERROR_MESSAGES = { + /** 데이터를 찾을 수 없습니다. */ + NOT_FOUND: 'データが見つかりません。', + /** 승인되지 않았습니다. */ + UNAUTHORIZED: '承認されていません。', + /** 권한이 없습니다. */ + NO_PERMISSION: '権限がありません。', + /** 데이터의 조회에 실패했습니다. */ + FETCH_ERROR: 'データの取得に失敗しました。', + /** 잘못된 요청입니다. */ + BAD_REQUEST: '間違ったリクエストです。', + /** 데이터베이스 오류가 발생했습니다. */ + PRISMA_ERROR: 'データベース エラーが発生しました。', }