From 51207641085395cb702d5c9f1cfe713a20f26600 Mon Sep 17 00:00:00 2001 From: keyy1315 Date: Mon, 30 Jun 2025 17:45:08 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20surveySale=20pdf=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=EB=B0=A9=EC=8B=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pdf 생성 시 response에 blob으로 반환하도록 방식 변경 - 제출 시 메일 href pdf 생성 페이지 링크에서 request url로 변경 --- src/app/api/survey-sales/[id]/route.ts | 15 +- src/app/api/survey-sales/service.ts | 50 +- src/app/pdf/survey-sale/[id]/page.tsx | 9 - src/components/pdf/SurveySaleDownloadPdf.tsx | 849 ------------------ src/components/pdf/SurveySalePdf.tsx | 330 +++++++ .../popup/SurveySaleSubmitPopup.tsx | 2 +- .../survey-sale/detail/ButtonForm.tsx | 2 +- .../survey-sale/detail/DataTable.tsx | 15 +- .../survey-sale/detail/DetailForm.tsx | 2 +- .../survey-sale/detail/RoofForm.tsx | 234 +---- src/hooks/useSurvey.ts | 63 +- src/types/Survey.ts | 234 +++++ src/utils/common-utils.js | 24 + 13 files changed, 700 insertions(+), 1129 deletions(-) delete mode 100644 src/app/pdf/survey-sale/[id]/page.tsx delete mode 100644 src/components/pdf/SurveySaleDownloadPdf.tsx create mode 100644 src/components/pdf/SurveySalePdf.tsx diff --git a/src/app/api/survey-sales/[id]/route.ts b/src/app/api/survey-sales/[id]/route.ts index 73852d3..c2492b9 100644 --- a/src/app/api/survey-sales/[id]/route.ts +++ b/src/app/api/survey-sales/[id]/route.ts @@ -7,6 +7,7 @@ import { HttpStatusCode } from 'axios' import { loggerWrapper } from '@/libs/api-wrapper' import { SurveySalesService } from '../service' import { ApiError } from 'next/dist/server/api-utils' +import { SurveyBasicInfo } from '@/types/Survey' /** * @api {GET} /api/survey-sales/:id 조사 매물 조회 API @@ -17,7 +18,7 @@ import { ApiError } from 'next/dist/server/api-utils' * @apiParam {Number} id 조사 매물 PRIMARY KEY ID (required) * @apiParam {Boolean} isPdf pdf 데이터 조회 여부 (optional, default: false) * - * @apiSuccess {Object} SurveySaleBasicInfo 조사 매물 기본 정보 + * @apiSuccess {Object} SurveySaleBasicInfo 조사 매물 기본 정보 | Blob 파일 * * @apiError {Number} 401 세션 정보 없음 (로그인 필요) * @apiError {Number} 403 권한 없음 @@ -56,6 +57,18 @@ async function getSurveySaleDetail(request: NextRequest): Promise if (result instanceof ApiError) { return NextResponse.json({ error: result.message }, { status: result.statusCode }) } + if (isPdf) { + const pdfBlob = await service.createSurveyPdf(result as SurveyBasicInfo) + if (pdfBlob instanceof ApiError) { + return NextResponse.json({ error: pdfBlob.message }, { status: pdfBlob.statusCode }) + } + return new NextResponse(pdfBlob, { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename=${result.SRL_NO}.pdf; filename*=UTF-8''${encodeURIComponent(result.SRL_NO)}.pdf`, + }, + }) + } return NextResponse.json(result) } diff --git a/src/app/api/survey-sales/service.ts b/src/app/api/survey-sales/service.ts index ec7afb9..291a9f3 100644 --- a/src/app/api/survey-sales/service.ts +++ b/src/app/api/survey-sales/service.ts @@ -1,11 +1,14 @@ import { prisma } from '@/libs/prisma' import { SurveyBasicInfo, SurveyRegistRequest, SurveySearchParams } from '@/types/Survey' -import { convertToSnakeCase } from '@/utils/common-utils' +import { convertToCamelCase, convertToSnakeCase } 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' import { ERROR_MESSAGE } from '@/hooks/useAlertMsg' +import SurveySalePdf from '@/components/pdf/SurveySalePdf' +import React from 'react' +import { pdf, Document } from '@react-pdf/renderer' type WhereCondition = { AND: any[] @@ -115,6 +118,7 @@ export class SurveySalesService { where.OR = [ { AND: [{ STORE_ID: { equals: this.session?.storeId } }] }, { AND: [{ SUBMISSION_TARGET_ID: { equals: this.session?.storeId } }, { SUBMISSION_STATUS: { equals: true } }] }, + { AND: [{ SUBMISSION_TARGET_NM: { equals: this.session?.storeNm } }, { SUBMISSION_STATUS: { equals: true } }] }, ] break case 'Admin_Sub': @@ -130,6 +134,9 @@ export class SurveySalesService { { SUBMISSION_STATUS: { equals: true } }, ], }, + { + AND: [{ SUBMISSION_TARGET_NM: { equals: this.session?.storeNm } }, { SUBMISSION_STATUS: { equals: true } }], + }, ] break case 'Builder': @@ -259,7 +266,7 @@ export class SurveySalesService { * @param {number} id 조사 매물 ID * @returns {Promise} 조사 매물 데이터 */ - async fetchSurvey(id: number, isPdf: boolean): Promise { + async fetchSurvey(id: number, isPdf: boolean): Promise { // @ts-ignore const result = await prisma.SD_SURVEY_SALES_BASIC_INFO.findFirst({ where: { ID: id }, @@ -275,6 +282,23 @@ export class SurveySalesService { return result } + /** + * @description 조사 매물 PDF 생성 + * @param {SurveyBasicInfo} survey 조사 매물 데이터 + * @returns {Promise} PDF Blob + */ + async createSurveyPdf(survey: SurveyBasicInfo): Promise { + const convertedSurvey = convertToCamelCase(survey) as SurveyBasicInfo + const content = React.createElement(Document, null, React.createElement(SurveySalePdf, { survey: convertedSurvey })) + try { + const pdfBlob = await pdf(content).toBlob() + const arrayBuffer = await pdfBlob.arrayBuffer() + return new Blob([arrayBuffer], { type: 'application/pdf' }) + } catch (error) { + return new ApiError(HttpStatusCode.InternalServerError, ERROR_MESSAGE.PDF_GENERATION_ERROR) + } + } + /** * @description 조사 매물 수정 * @param {number} id 조사 매물 ID @@ -364,9 +388,9 @@ export class SurveySalesService { if (!survey || !session) return false const roleChecks = { - T01: () => this.checkT01Role(survey), - Admin: () => this.checkAdminRole(survey, session.storeId), - Admin_Sub: () => this.checkAdminSubRole(survey, session.storeId), + T01: () => this.checkT01Role(survey, session.userId), + Admin: () => this.checkAdminRole(survey, session.storeId, session.storeNm), + Admin_Sub: () => this.checkAdminSubRole(survey, session.storeId, session.storeNm), Partner: () => this.checkPartnerOrBuilderRole(survey, session.builderId), Builder: () => this.checkPartnerOrBuilderRole(survey, session.builderId), } @@ -381,8 +405,8 @@ export class SurveySalesService { * @param {any} survey 조사 매물 데이터 * @returns {boolean} 해당 매물의 조회 권한 여부 (true: 권한 있음, false: 권한 없음) */ - private checkT01Role(survey: any): boolean { - if (survey.REPRESENTATIVE_ID === this.session?.userId) { + private checkT01Role(survey: any, userId: string | null): boolean { + if (survey.REPRESENTATIVE_ID === userId) { return true } return survey.SRL_NO !== '一時保存' @@ -396,9 +420,11 @@ export class SurveySalesService { * @param {string | null} storeId 판매점 ID * @returns {boolean} 해당 매물의 조회 권한 여부 (true: 권한 있음, false: 권한 없음) */ - private checkAdminRole(survey: any, storeId: string | null): boolean { + private checkAdminRole(survey: any, storeId: string | null, storeNm: string | null): boolean { if (!storeId) return false - return survey.SUBMISSION_STATUS ? survey.SUBMISSION_TARGET_ID === storeId || survey.STORE_ID === storeId : survey.STORE_ID === storeId + return survey.SUBMISSION_STATUS + ? survey.SUBMISSION_TARGET_ID === storeId || survey.SUBMISSION_TARGET_NM === storeNm || survey.STORE_ID === storeId + : survey.STORE_ID === storeId } /** @@ -409,10 +435,12 @@ export class SurveySalesService { * @param {string | null} storeId 판매점 ID * @returns {boolean} 해당 매물의 조회 권한 여부 (true: 권한 있음, false: 권한 없음) */ - private checkAdminSubRole(survey: any, storeId: string | null): boolean { + private checkAdminSubRole(survey: any, storeId: string | null, storeNm: 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.SUBMISSION_TARGET_ID === storeId || + survey.SUBMISSION_TARGET_NM === storeNm || + (survey.STORE_ID === storeId && !survey.CONSTRUCTION_POINT_ID) : survey.STORE_ID === storeId && !survey.CONSTRUCTION_POINT_ID } diff --git a/src/app/pdf/survey-sale/[id]/page.tsx b/src/app/pdf/survey-sale/[id]/page.tsx deleted file mode 100644 index e1d79d6..0000000 --- a/src/app/pdf/survey-sale/[id]/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import SurveySaleDownloadPdf from '@/components/pdf/SurveySaleDownloadPdf' - -export default function page() { - return ( - <> - - - ) -} diff --git a/src/components/pdf/SurveySaleDownloadPdf.tsx b/src/components/pdf/SurveySaleDownloadPdf.tsx deleted file mode 100644 index 556abfc..0000000 --- a/src/components/pdf/SurveySaleDownloadPdf.tsx +++ /dev/null @@ -1,849 +0,0 @@ -'use client' - -import { useEffect, useRef } from 'react' -import generatePDF, { Margin, Resolution } from 'react-to-pdf' -import { useParams, useRouter } from 'next/navigation' -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 { ERROR_MESSAGE, SUCCESS_MESSAGE, useAlertMsg } from '@/hooks/useAlertMsg' - -export default function SurveySaleDownloadPdf() { - const params = useParams() - const id = params.id - const router = useRouter() - - const { surveyDetail, isLoadingSurveyDetail } = useSurvey(Number(id), true) - const { showErrorAlert, showSuccessAlert } = useAlertMsg() - const { setIsShow } = useSpinnerStore() - const { session } = useSessionStore() - - const targetRef = useRef(null) - const isGeneratedRef = useRef(false) - - /** 페이지 랜더링 이후 PDF 생성 */ - useEffect(() => { - if (isLoadingSurveyDetail || isGeneratedRef.current) return - isGeneratedRef.current = true - handleDownPdf() - }, [surveyDetail?.id, isLoadingSurveyDetail]) - - const handleDownPdf = () => { - setIsShow(true) - const options = { - method: 'open' as const, - resolution: Resolution.HIGH, - page: { - margin: Margin.SMALL, - format: 'A4', - orientation: 'portrait' as const, - }, - canvas: { - mimeType: 'image/png' as const, - qualityRatio: 1, - }, - overrides: { - pdf: { - compress: true, - }, - canvas: { - useCORS: true, - }, - }, - } - - /** PDF 생성 이후 세션 여부에 따른 라우팅 처리 */ - generatePDF(targetRef, options) - .then(() => { - setIsShow(false) - if (session?.isLoggedIn) { - router.replace(`/survey-sale/${id}`) - } else { - router.replace('/') - } - showSuccessAlert(SUCCESS_MESSAGE.PDF_GENERATION_SUCCESS) - }) - .catch((error: any) => { - console.error('❌ PDF GENERATION ERROR', error) - showErrorAlert(ERROR_MESSAGE.PDF_GENERATION_ERROR) - }) - } - - return ( - <> -
-
-
-
- HWJ 現地調査シート1/2 -
-
-
-

- 現地明登施工店名 -

-

- {surveyDetail?.store ?? '-'} -

-
-
-

現地阴買日

-

- {surveyDetail?.investigationDate ?? '-'} -

-
-
-
-
- - - - - - - - - - - -
- お客様名 - - {surveyDetail?.customerName ?? '-'} -
- ご住所 - - {surveyDetail?.postCode ? `(${surveyDetail?.postCode}) ${surveyDetail?.address} ${surveyDetail?.addressDetail}` : '-'} -
-
-
-
も気開係
- - - - - - - - - - - - - - - - - -
- 雨気契约容国 - - {surveyDetail?.detailInfo?.contractCapacity ?? '-'} - - 電気契約会社 - - {surveyDetail?.detailInfo?.retailCompany ?? '-'} -
- 電気付带設備 - - {surveyDetail?.detailInfo?.supplementaryFacilities - ? supplementaryFacilities - .filter((facility) => surveyDetail?.detailInfo?.supplementaryFacilities?.includes(facility.id.toString())) - .map((facility) => facility.name) - .join(', ') + - (surveyDetail?.detailInfo?.supplementaryFacilitiesEtc ? `, ${surveyDetail?.detailInfo?.supplementaryFacilitiesEtc}` : '') - : surveyDetail?.detailInfo?.supplementaryFacilitiesEtc - ? `${surveyDetail?.detailInfo?.supplementaryFacilitiesEtc}` - : '-'} -
- 設置希望システム - - {/* {selectBoxOptions.installationSystem.find ((system) => system.id.toString() === surveyDetail?.detailInfo?.installationSystem) - ?.name ?? surveyDetail?.detailInfo?.installationSystemEtc !== null - ? `${surveyDetail?.detailInfo?.installationSystemEtc}` - : '-'} */} - {surveyDetail?.detailInfo?.installationSystem === null && surveyDetail?.detailInfo?.installationSystemEtc === null - ? '-' - : surveyDetail?.detailInfo?.installationSystemEtc - ? `${surveyDetail?.detailInfo?.installationSystemEtc}` - : selectBoxOptions.installationSystem.find((system) => system.id.toString() === surveyDetail?.detailInfo?.installationSystem) - ?.name} -
-
-
-
屋根眀係
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- 築年数 - - {surveyDetail?.detailInfo?.constructionYear === '1' - ? '新築' - : surveyDetail?.detailInfo?.constructionYearEtc - ? `既築 (${surveyDetail?.detailInfo?.constructionYear}年)` - : '-'} - - 至根材 - - {surveyDetail?.detailInfo?.roofMaterial === null && surveyDetail?.detailInfo?.roofMaterialEtc === null - ? '-' - : roofMaterial - .filter((material) => surveyDetail?.detailInfo?.roofMaterial?.includes(material.id.toString())) - .map((material) => material.name) - .join(', ')} - {surveyDetail?.detailInfo?.roofMaterialEtc ? `, ${surveyDetail?.detailInfo?.roofMaterialEtc}` : ''} - - 座根形状 - - {selectBoxOptions.roofShape.find((shape) => shape.id.toString() === surveyDetail?.detailInfo?.roofShape)?.name ?? - (surveyDetail?.detailInfo?.roofShapeEtc ? ` ${surveyDetail?.detailInfo?.roofShapeEtc}` : '-')} -
- 座根勾配 - - {surveyDetail?.detailInfo?.roofSlope ? `${surveyDetail?.detailInfo?.roofSlope} 寸` : '-'} - - 住宅樠造 - - {radioEtcData.houseStructure.find((structure) => structure.id.toString() === surveyDetail?.detailInfo?.houseStructure)?.label ?? - (surveyDetail?.detailInfo?.houseStructureEtc ? ` ${surveyDetail?.detailInfo?.houseStructureEtc}` : '-')} -
- 並木材質 - - {/* {surveyDetail?.detailInfo?.rafterMaterial === null && surveyDetail?.detailInfo?.rafterMaterialEtc === null - ? '-' - : radioEtcData.rafterMaterial.find((material) => material.id.toString() === surveyDetail?.detailInfo?.rafterMaterial)?.label ?? - surveyDetail?.detailInfo?.rafterMaterialEtc} */} - {radioEtcData.rafterMaterial.find((material) => material.id.toString() === surveyDetail?.detailInfo?.rafterMaterial)?.label ?? - (surveyDetail?.detailInfo?.rafterMaterialEtc ? ` ${surveyDetail?.detailInfo?.rafterMaterialEtc}` : '-')} - - 垂木サイズ - - {selectBoxOptions.rafterSize.find((size) => size.id.toString() === surveyDetail?.detailInfo?.rafterSize)?.name ?? - (surveyDetail?.detailInfo?.rafterSizeEtc ? ` ${surveyDetail?.detailInfo?.rafterSizeEtc}` : '-')} -
- 垂木ピッチ - - {selectBoxOptions.rafterPitch.find((pitch) => pitch.id.toString() === surveyDetail?.detailInfo?.rafterPitch)?.name ?? - (surveyDetail?.detailInfo?.rafterPitchEtc ? ` ${surveyDetail?.detailInfo?.rafterPitchEtc}` : '-')} - - 垂木方向 - - {radioEtcData.rafterDirection.find((direction) => direction.id.toString() === surveyDetail?.detailInfo?.rafterDirection)?.label ?? - '-'} -
- 野地板種類 - - {selectBoxOptions.openFieldPlateKind.find((kind) => kind.id.toString() === surveyDetail?.detailInfo?.openFieldPlateKind)?.name ?? - (surveyDetail?.detailInfo?.openFieldPlateKindEtc ? `${surveyDetail?.detailInfo?.openFieldPlateKindEtc}` : '-')} - - 野地板厚さ - - {surveyDetail?.detailInfo?.openFieldPlateThickness ? `${surveyDetail?.detailInfo?.openFieldPlateThickness}mm` : '-'} -
- 兩漏の形跡 - - {surveyDetail?.detailInfo?.leakTrace ? 'あり' : 'なし'} -
- ルーフィング種類 - - {radioEtcData.waterproofMaterial.find((material) => material.id.toString() === surveyDetail?.detailInfo?.waterproofMaterial) - ?.label ?? (surveyDetail?.detailInfo?.waterproofMaterialEtc ? ` ${surveyDetail?.detailInfo?.waterproofMaterialEtc}` : '-')} -
- 断熱材の有無 - - { - radioEtcData.insulationPresence.find((presence) => presence.id.toString() === surveyDetail?.detailInfo?.insulationPresence) - ?.label - } - {surveyDetail?.detailInfo?.insulationPresenceEtc ? `, ${surveyDetail?.detailInfo?.insulationPresenceEtc}` : ''} -
- 屋根構造の順番 - - {radioEtcData.structureOrder.find((order) => order.id.toString() === surveyDetail?.detailInfo?.structureOrder)?.label ?? - (surveyDetail?.detailInfo?.structureOrderEtc ? `${surveyDetail?.detailInfo?.structureOrderEtc}` : '-')} -
-
-
- - - - - - - -
- 区根製品名設置可否確認 - - {surveyDetail?.detailInfo?.installationAvailability === null && surveyDetail.detailInfo?.installationAvailabilityEtc === null - ? '-' - : selectBoxOptions.installationAvailability.find( - (availability) => availability.id.toString() === surveyDetail?.detailInfo?.installationAvailability, - )?.name} - {surveyDetail?.detailInfo?.installationAvailabilityEtc ? `, ${surveyDetail?.detailInfo?.installationAvailabilityEtc}` : ''} -
-
-
-
メモ
-
- {surveyDetail?.detailInfo?.memo ?? '-'} -
-
-
-
- - ) -} diff --git a/src/components/pdf/SurveySalePdf.tsx b/src/components/pdf/SurveySalePdf.tsx new file mode 100644 index 0000000..1dff9c4 --- /dev/null +++ b/src/components/pdf/SurveySalePdf.tsx @@ -0,0 +1,330 @@ +import fs from 'fs' +import path from 'path' +import { Font, Page, Text, View, StyleSheet } from '@react-pdf/renderer' +import { radioEtcData, selectBoxOptions, supplementaryFacilities, roofMaterial } from '@/types/Survey' +import { SurveyBasicInfo } from '@/types/Survey' + +Font.register({ + family: 'NotoSansJP', + src: `data:font/ttf;base64,${fs.readFileSync(path.resolve(process.cwd(), 'src/components/pdf/NotoSansJP-Regular.ttf')).toString('base64')}`, +}) + +const styles = StyleSheet.create({ + page: { + padding: 15, + fontFamily: 'NotoSansJP', + fontSize: 8, + backgroundColor: '#fff', + }, + header: { + padding: '15px 15px 15px', + borderBottom: '2px solid #2E3A59', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + title: { + fontSize: 14, + color: '#101010', + fontWeight: 'bold', + fontFamily: 'NotoSansJP', + }, + headerRight: { + flexDirection: 'row', + alignItems: 'flex-start', + }, + headerLeft: { + marginRight: 15, + alignItems: 'flex-end', + }, + headerLabel: { + fontSize: 9, + color: '#101010', + fontWeight: 'bold', + fontFamily: 'NotoSansJP', + textAlign: 'right', + margin: 0, + }, + headerValue: { + fontSize: 9, + color: '#FF5656', + fontWeight: 400, + fontFamily: 'NotoSansJP', + margin: 0, + }, + section: { + padding: '20px 15px 8px', + }, + sectionTitle: { + fontSize: 10, + fontFamily: 'NotoSansJP', + color: '#101010', + fontWeight: 'bold', + marginBottom: 8, + }, + table: { + width: '100%', + }, + tableRow: { + flexDirection: 'row', + minHeight: 28, + }, + tableHeader: { + padding: 6, + backgroundColor: '#F5F6FA', + fontSize: 9, + fontWeight: 'bold', + color: '#101010', + border: '1px solid #2E3A59', + fontFamily: 'NotoSansJP', + justifyContent: 'flex-start', + alignItems: 'center', + }, + tableCell: { + padding: 6, + fontSize: 9, + fontWeight: 500, + color: '#FF5656', + border: '1px solid #2E3A59', + fontFamily: 'NotoSansJP', + justifyContent: 'flex-start', + alignItems: 'center', + }, + tableHeaderSmall: { + width: 50, + }, + tableHeaderMedium: { + width: 85, + }, + tableHeaderLarge: { + width: 110, + }, + tableCellFlex1: { + flex: 1, + }, + tableCellFlex2: { + flex: 2, + }, + tableCellFlex07: { + flex: 0.5745, + }, + memoBox: { + padding: 6, + fontSize: 9, + fontWeight: 400, + fontFamily: 'NotoSansJP', + color: '#FF5656', + border: '1px solid #2E3A59', + minHeight: 100, + width: '100%', + }, + sectionNoPadding: { + padding: '10px 15px', + }, + marginL: { + marginLeft: -1, + }, + marginT: { + marginTop: -1, + } +}) + +export default function SurveySalePdf({ survey }: { survey: SurveyBasicInfo }) { + return ( + + {/* Header */} + + HWJ 現地調査シート1/2 + + + 現地明登施工店名 + {survey?.store ?? '-'} + + + 現地阴買日 + {survey?.investigationDate ?? '-'} + + + + + {/* Customer Info */} + + + + お客様名 + {survey?.customerName ?? '-'} + + + ご住所 + + {survey?.postCode ? `(${survey?.postCode}) ${survey?.address} ${survey?.addressDetail}` : '-'} + + + + + + {/* Electric Info */} + + も気開係 + + + 雨気契约容国 + {survey?.detailInfo?.contractCapacity ?? '-'} + 電気契約会社 + {survey?.detailInfo?.retailCompany ?? '-'} + + + 電気付带設備 + + {survey?.detailInfo?.supplementaryFacilities + ? supplementaryFacilities + .filter((facility) => survey?.detailInfo?.supplementaryFacilities?.includes(facility.id.toString())) + .map((facility) => facility.name) + .join(', ') + (survey?.detailInfo?.supplementaryFacilitiesEtc ? `, ${survey?.detailInfo?.supplementaryFacilitiesEtc}` : '') + : survey?.detailInfo?.supplementaryFacilitiesEtc + ? `${survey?.detailInfo?.supplementaryFacilitiesEtc}` + : '-'} + + + + 設置希望システム + + {survey?.detailInfo?.installationSystem === null && survey?.detailInfo?.installationSystemEtc === null + ? '-' + : survey?.detailInfo?.installationSystemEtc + ? `${survey?.detailInfo?.installationSystemEtc}` + : selectBoxOptions.installationSystem.find((system) => system.id.toString() === survey?.detailInfo?.installationSystem)?.name} + + + + + + {/* Roof Info */} + + 屋根眀係 + + + 築年数 + + {survey?.detailInfo?.constructionYear === '1' + ? '新築' + : survey?.detailInfo?.constructionYearEtc + ? `既築 (${survey?.detailInfo?.constructionYear}年)` + : '-'} + + 至根材 + + {survey?.detailInfo?.roofMaterial === null && survey?.detailInfo?.roofMaterialEtc === null + ? '-' + : roofMaterial + .filter((material) => survey?.detailInfo?.roofMaterial?.includes(material.id.toString())) + .map((material) => material.name) + .join(', ')} + {survey?.detailInfo?.roofMaterialEtc ? `, ${survey?.detailInfo?.roofMaterialEtc}` : ''} + + 座根形状 + + {selectBoxOptions.roofShape.find((shape) => shape.id.toString() === survey?.detailInfo?.roofShape)?.name ?? + (survey?.detailInfo?.roofShapeEtc ? ` ${survey?.detailInfo?.roofShapeEtc}` : '-')} + + + + 座根勾配 + + {survey?.detailInfo?.roofSlope ? `${survey?.detailInfo?.roofSlope} 寸` : '-'} + + 住宅樠造 + + {radioEtcData.houseStructure.find((structure) => structure.id.toString() === survey?.detailInfo?.houseStructure)?.label ?? + (survey?.detailInfo?.houseStructureEtc ? ` ${survey?.detailInfo?.houseStructureEtc}` : '-')} + + + + 並木材質 + + {radioEtcData.rafterMaterial.find((material) => material.id.toString() === survey?.detailInfo?.rafterMaterial)?.label ?? + (survey?.detailInfo?.rafterMaterialEtc ? ` ${survey?.detailInfo?.rafterMaterialEtc}` : '-')} + + 垂木サイズ + + {selectBoxOptions.rafterSize.find((size) => size.id.toString() === survey?.detailInfo?.rafterSize)?.name ?? + (survey?.detailInfo?.rafterSizeEtc ? ` ${survey?.detailInfo?.rafterSizeEtc}` : '-')} + + + + 垂木ピッチ + + {selectBoxOptions.rafterPitch.find((pitch) => pitch.id.toString() === survey?.detailInfo?.rafterPitch)?.name ?? + (survey?.detailInfo?.rafterPitchEtc ? ` ${survey?.detailInfo?.rafterPitchEtc}` : '-')} + + 垂木方向 + + {radioEtcData.rafterDirection.find((direction) => direction.id.toString() === survey?.detailInfo?.rafterDirection)?.label ?? '-'} + + + + 野地板種類 + + {selectBoxOptions.openFieldPlateKind.find((kind) => kind.id.toString() === survey?.detailInfo?.openFieldPlateKind)?.name ?? + (survey?.detailInfo?.openFieldPlateKindEtc ? `${survey?.detailInfo?.openFieldPlateKindEtc}` : '-')} + + 野地板厚さ + + {survey?.detailInfo?.openFieldPlateThickness ? `${survey?.detailInfo?.openFieldPlateThickness}mm` : '-'} + + + + 兩漏の形跡 + {survey?.detailInfo?.leakTrace ? 'あり' : 'なし'} + + + ルーフィング種類 + + {radioEtcData.waterproofMaterial.find((material) => material.id.toString() === survey?.detailInfo?.waterproofMaterial)?.label ?? + (survey?.detailInfo?.waterproofMaterialEtc ? ` ${survey?.detailInfo?.waterproofMaterialEtc}` : '-')} + + + + 断熱材の有無 + + {radioEtcData.insulationPresence.find((presence) => presence.id.toString() === survey?.detailInfo?.insulationPresence)?.label} + {survey?.detailInfo?.insulationPresenceEtc ? `, ${survey?.detailInfo?.insulationPresenceEtc}` : ''} + + + + 屋根構造の順番 + + {radioEtcData.structureOrder.find((order) => order.id.toString() === survey?.detailInfo?.structureOrder)?.label ?? + (survey?.detailInfo?.structureOrderEtc ? `${survey?.detailInfo?.structureOrderEtc}` : '-')} + + + + + + {/* Installation Availability */} + + + + 区根製品名設置可否確認 + + {survey?.detailInfo?.installationAvailability === null && survey.detailInfo?.installationAvailabilityEtc === null + ? '-' + : selectBoxOptions.installationAvailability.find( + (availability) => availability.id.toString() === survey?.detailInfo?.installationAvailability, + )?.name} + {survey?.detailInfo?.installationAvailabilityEtc ? `, ${survey?.detailInfo?.installationAvailabilityEtc}` : ''} + + + + + + {/* Memo */} + + メモ + + {survey?.detailInfo?.memo ?? '-'} + + + + ) +} diff --git a/src/components/popup/SurveySaleSubmitPopup.tsx b/src/components/popup/SurveySaleSubmitPopup.tsx index e0bbd39..b482da5 100644 --- a/src/components/popup/SurveySaleSubmitPopup.tsx +++ b/src/components/popup/SurveySaleSubmitPopup.tsx @@ -181,7 +181,7 @@ export default function SurveySaleSubmitPopup() {

現地調査結果PDFダウンロード diff --git a/src/components/survey-sale/detail/ButtonForm.tsx b/src/components/survey-sale/detail/ButtonForm.tsx index e870a73..732b5ec 100644 --- a/src/components/survey-sale/detail/ButtonForm.tsx +++ b/src/components/survey-sale/detail/ButtonForm.tsx @@ -68,7 +68,7 @@ export default function ButtonForm({ mode, setMode, data }: ButtonFormProps) { const calculatePermissions = (session: any, basicData: SurveyBasicRequest): PermissionState => { const isSubmiter = calculateSubmitPermission(session, basicData) const isWriter = session.userId === basicData.representativeId - const isReceiver = session?.storeId === basicData.submissionTargetId + const isReceiver = session?.storeId === basicData.submissionTargetId || session?.storeNm === basicData.submissionTargetNm return { isSubmiter, isWriter, isReceiver } } diff --git a/src/components/survey-sale/detail/DataTable.tsx b/src/components/survey-sale/detail/DataTable.tsx index 38ca7db..a04f906 100644 --- a/src/components/survey-sale/detail/DataTable.tsx +++ b/src/components/survey-sale/detail/DataTable.tsx @@ -2,6 +2,7 @@ import { useRouter } from 'next/navigation' import { SurveyBasicInfo } from '@/types/Survey' +import { useSurvey } from '@/hooks/useSurvey' export default function DataTable({ surveyDetail }: { surveyDetail: SurveyBasicInfo }) { const router = useRouter() @@ -9,21 +10,19 @@ export default function DataTable({ surveyDetail }: { surveyDetail: SurveyBasicI /** 제출 상태 처리 */ const submitStatus = () => { const { submissionTargetNm, submissionTargetId } = surveyDetail ?? {} - - if (!submissionTargetNm) { - return null + if (!submissionTargetId && !submissionTargetNm) { + return

} - - if (!submissionTargetId) { - return
{submissionTargetNm}
+ if (!submissionTargetId && submissionTargetNm) { + return
( {submissionTargetNm} )
} - return (
({submissionTargetNm} - {submissionTargetId})
) } + const { downloadSurveyPdf } = useSurvey() return ( <> @@ -68,7 +67,7 @@ export default function DataTable({ surveyDetail }: { surveyDetail: SurveyBasicI ダウンロード - diff --git a/src/components/survey-sale/detail/DetailForm.tsx b/src/components/survey-sale/detail/DetailForm.tsx index 313a650..2034ede 100644 --- a/src/components/survey-sale/detail/DetailForm.tsx +++ b/src/components/survey-sale/detail/DetailForm.tsx @@ -74,7 +74,7 @@ export default function DetailForm() { const modeset = !Number.isNaN(Number(id)) ? 'READ' : 'CREATE' - const { surveyDetail, isLoadingSurveyDetail } = useSurvey(Number(id)) + const { surveyDetail, isLoadingSurveyDetail } = useSurvey(Number(id), false) const { session } = useSessionStore() const searchParams = useSearchParams() const popupController = usePopupController() diff --git a/src/components/survey-sale/detail/RoofForm.tsx b/src/components/survey-sale/detail/RoofForm.tsx index 693a22f..aa66cf5 100644 --- a/src/components/survey-sale/detail/RoofForm.tsx +++ b/src/components/survey-sale/detail/RoofForm.tsx @@ -1,239 +1,7 @@ import { useState } from 'react' import type { Mode, SurveyDetailInfo, SurveyDetailRequest } from '@/types/Survey' import { useAlertMsg, WARNING_MESSAGE } from '@/hooks/useAlertMsg' - -type RadioEtcKeys = - | 'structureOrder' - | 'houseStructure' - | 'rafterMaterial' - | 'waterproofMaterial' - | 'insulationPresence' - | 'rafterDirection' - | 'leakTrace' -type SelectBoxKeys = - | 'installationSystem' - | 'constructionYear' - | 'roofShape' - | 'rafterPitch' - | 'rafterSize' - | 'openFieldPlateKind' - | 'installationAvailability' - -export const supplementaryFacilities = [ - /** 에코큐트 */ - { id: 1, name: 'エコキュート' }, - /** 에네팜 */ - { id: 2, name: 'エネパーム' }, - /** 축전지시스템 */ - { id: 3, name: '蓄電池システム' }, - /** 태양광발전 */ - { id: 4, name: '太陽光発電' }, -] - -export const roofMaterial = [ - /** 슬레이트 */ - { id: 1, name: 'スレート' }, - /** 아스팔트 싱글 */ - { id: 2, name: 'アスファルトシングル' }, - /** 기와 */ - { id: 3, name: '瓦' }, - /** 금속지붕 */ - { id: 4, name: '金属屋根' }, -] - -export const selectBoxOptions: Record = { - installationSystem: [ - { - /** 태양광발전 */ - id: 1, - name: '太陽光発電', - }, - { - /** 하이브리드축전지시스템 */ - id: 2, - name: 'ハイブリッド蓄電システム', - }, - { - /** 축전지시스템 */ - id: 3, - name: '蓄電池システム', - }, - ], - constructionYear: [ - { - /** 신축 */ - id: 1, - name: '新築', - }, - { - /** 기축 */ - id: 2, - name: '既築', - }, - ], - roofShape: [ - { - /** 박공지붕 */ - id: 1, - name: '切妻', - }, - { - /** 기동 */ - id: 2, - name: '寄棟', - }, - { - /** 한쪽흐름 */ - id: 3, - name: '片流れ', - }, - ], - rafterSize: [ - { - /** 35mm 이상×48mm 이상 */ - id: 1, - name: '幅35mm以上×高さ48mm以上', - }, - { - /** 36mm 이상×46mm 이상 */ - id: 2, - name: '幅36mm以上×高さ46mm以上', - }, - { - /** 37mm 이상×43mm 이상 */ - id: 3, - name: '幅37mm以上×高さ43mm以上', - }, - { - /** 38mm 이상×40mm 이상 */ - id: 4, - name: '幅38mm以上×高さ40mm以上', - }, - ], - rafterPitch: [ - { - /** 455mm 이하 */ - id: 1, - name: '455mm以下', - }, - { - /** 500mm 이하 */ - id: 2, - name: '500mm以下', - }, - { - /** 606mm 이하 */ - id: 3, - name: '606mm以下', - }, - ], - openFieldPlateKind: [ - { - /** 구조용합판 */ - id: 1, - name: '構造用合板', - }, - { - /** OSB */ - id: 2, - name: 'OSB', - }, - { - /** 파티클보드 */ - id: 3, - name: 'パーティクルボード', - }, - { - /** 소판 */ - id: 4, - name: '小幅板', - }, - ], - installationAvailability: [ - { - /** 확인완료 */ - id: 1, - name: '確認済み', - }, - { - /** 미확인 */ - id: 2, - name: '未確認', - }, - ], -} - -export const radioEtcData: Record = { - structureOrder: [ - { - /** 지붕재 - 방수재 - 지붕의기초 - 서까래 */ - id: 1, - label: '屋根材 > 防水材 > 屋根の基礎 > 垂木', - }, - ], - houseStructure: [ - { - /** 목재 */ - id: 1, - label: '木製', - }, - ], - rafterMaterial: [ - { - /** 목재 */ - id: 1, - label: '木製', - }, - { - /** 강재 */ - id: 2, - label: '強制', - }, - ], - waterproofMaterial: [ - { - /** 아스팔트 지붕 940(22kg 이상) */ - id: 1, - label: 'アスファルト屋根940(22kg以上)', - }, - ], - insulationPresence: [ - { - /** 없음 */ - id: 1, - label: 'なし', - }, - { - /** 있음 */ - id: 2, - label: 'あり', - }, - ], - rafterDirection: [ - { - /** 수직 */ - id: 1, - label: '垂直垂木', - }, - { - /** 수평 */ - id: 2, - label: '水平垂木', - }, - ], - leakTrace: [ - { - /** 있음 */ - id: 1, - label: 'あり', - }, - { - /** 없음 */ - id: 2, - label: 'なし', - }, - ], -} +import { radioEtcData, selectBoxOptions, supplementaryFacilities, roofMaterial } from '@/types/Survey' const makeNumArr = (value: string) => { return value diff --git a/src/hooks/useSurvey.ts b/src/hooks/useSurvey.ts index 7824a9b..c76ce56 100644 --- a/src/hooks/useSurvey.ts +++ b/src/hooks/useSurvey.ts @@ -59,7 +59,6 @@ type ZipCode = { * @description 조사 매물 관련 기능을 제공하는 커스텀 훅 * * @param {number} [id] 조사 매물 ID - * @param {boolean} [isPdf] PDF 뷰 여부 * @returns {Object} 조사 매물 관련 기능과 데이터 * @returns {SurveyBasicInfo[]} surveyList - 조사 매물 목록 데이터 * @returns {SurveyBasicInfo} surveyDetail - 조사 매물 상세 데이터 @@ -75,7 +74,7 @@ type ZipCode = { */ export function useSurvey( id?: number, - isPdf?: boolean, + isList?: boolean, ): { surveyList: { data: SurveyBasicInfo[]; count: number } | {} surveyDetail: SurveyBasicInfo | null @@ -94,6 +93,7 @@ export function useSurvey( refetchSurveyList: () => void refetchSurveyDetail: () => void getSubmitTarget: (params: { storeId: string; role: string }) => Promise + downloadSurveyPdf: (id: number, filename: string) => Promise } { const queryClient = useQueryClient() const { keyword, searchOption, isMySurvey, sort, offset } = useSurveyFilterStore() @@ -148,11 +148,15 @@ export function useSurvey( * @param {boolean} isThrow 조사 매물 데이터 조회 에러 처리 여부 * @returns {Promise} API 응답 데이터 */ - const tryFunction = async (func: () => Promise, isList?: boolean, isThrow?: boolean): Promise => { + const tryFunction = async (func: () => Promise, isList?: boolean, isThrow?: boolean, isBlob?: boolean): Promise => { try { const resp = await func() - return resp.data + return isBlob ? resp : resp.data } catch (error) { + if (isBlob) { + showErrorAlert(ERROR_MESSAGE.PDF_GENERATION_ERROR) + return null + } handleError(error, isThrow) if (isList) { return { data: [], count: 0 } @@ -192,7 +196,7 @@ export function useSurvey( false, ) }, - enabled: !isPdf, + enabled: isList, }) /** @@ -225,19 +229,47 @@ export function useSurvey( queryKey: ['survey', id], queryFn: async () => { if (Number.isNaN(id) || id === undefined || id === 0) return null - return await tryFunction( - () => - axiosInstance(null).get(`/api/survey-sales/${id}`, { - params: { - isPdf: isPdf, - }, - }), - false, - false, - ) + return await tryFunction(() => axiosInstance(null).get(`/api/survey-sales/${id}`), false, false) }, enabled: id !== 0 && id !== undefined && id !== null, }) + /** + * @description 조사 매물 PDF 다운로드 + * + * @param {number} id 조사 매물 ID + * @param {string} filename 다운로드할 파일 이름 + * @returns {Promise} PDF 파일 데이터 + */ + const downloadSurveyPdf = async (id: number, filename: string) => { + const resp = await tryFunction( + () => + fetch(`/api/survey-sales/${id}?isPdf=true`, { + method: 'GET', + headers: { + 'Content-Type': 'application/pdf', + }, + }), + false, + false, + true, + ) + console.log(resp) + const blob = await resp.blob() + + if (!blob || blob.size === 0) { + showErrorAlert(ERROR_MESSAGE.PDF_GENERATION_ERROR) + return null + } + + const url = window.URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `${filename}.pdf` + a.click() + window.URL.revokeObjectURL(url) + + return blob + } /** * @description 조사 매물 생성 @@ -450,5 +482,6 @@ export function useSurvey( getSubmitTarget, refetchSurveyList, refetchSurveyDetail, + downloadSurveyPdf, } } diff --git a/src/types/Survey.ts b/src/types/Survey.ts index 3e10082..8cc8d55 100644 --- a/src/types/Survey.ts +++ b/src/types/Survey.ts @@ -322,4 +322,238 @@ export type SurveySearchParams = { storeId?: string | null /** 시공점 ID */ builderId?: string | null +} + + +type RadioEtcKeys = + | 'structureOrder' + | 'houseStructure' + | 'rafterMaterial' + | 'waterproofMaterial' + | 'insulationPresence' + | 'rafterDirection' + | 'leakTrace' +type SelectBoxKeys = + | 'installationSystem' + | 'constructionYear' + | 'roofShape' + | 'rafterPitch' + | 'rafterSize' + | 'openFieldPlateKind' + | 'installationAvailability' + +export const supplementaryFacilities = [ + /** 에코큐트 */ + { id: 1, name: 'エコキュート' }, + /** 에네팜 */ + { id: 2, name: 'エネパーム' }, + /** 축전지시스템 */ + { id: 3, name: '蓄電池システム' }, + /** 태양광발전 */ + { id: 4, name: '太陽光発電' }, +] + +export const roofMaterial = [ + /** 슬레이트 */ + { id: 1, name: 'スレート' }, + /** 아스팔트 싱글 */ + { id: 2, name: 'アスファルトシングル' }, + /** 기와 */ + { id: 3, name: '瓦' }, + /** 금속지붕 */ + { id: 4, name: '金属屋根' }, +] + +export const selectBoxOptions: Record = { + installationSystem: [ + { + /** 태양광발전 */ + id: 1, + name: '太陽光発電', + }, + { + /** 하이브리드축전지시스템 */ + id: 2, + name: 'ハイブリッド蓄電システム', + }, + { + /** 축전지시스템 */ + id: 3, + name: '蓄電池システム', + }, + ], + constructionYear: [ + { + /** 신축 */ + id: 1, + name: '新築', + }, + { + /** 기축 */ + id: 2, + name: '既築', + }, + ], + roofShape: [ + { + /** 박공지붕 */ + id: 1, + name: '切妻', + }, + { + /** 기동 */ + id: 2, + name: '寄棟', + }, + { + /** 한쪽흐름 */ + id: 3, + name: '片流れ', + }, + ], + rafterSize: [ + { + /** 35mm 이상×48mm 이상 */ + id: 1, + name: '幅35mm以上×高さ48mm以上', + }, + { + /** 36mm 이상×46mm 이상 */ + id: 2, + name: '幅36mm以上×高さ46mm以上', + }, + { + /** 37mm 이상×43mm 이상 */ + id: 3, + name: '幅37mm以上×高さ43mm以上', + }, + { + /** 38mm 이상×40mm 이상 */ + id: 4, + name: '幅38mm以上×高さ40mm以上', + }, + ], + rafterPitch: [ + { + /** 455mm 이하 */ + id: 1, + name: '455mm以下', + }, + { + /** 500mm 이하 */ + id: 2, + name: '500mm以下', + }, + { + /** 606mm 이하 */ + id: 3, + name: '606mm以下', + }, + ], + openFieldPlateKind: [ + { + /** 구조용합판 */ + id: 1, + name: '構造用合板', + }, + { + /** OSB */ + id: 2, + name: 'OSB', + }, + { + /** 파티클보드 */ + id: 3, + name: 'パーティクルボード', + }, + { + /** 소판 */ + id: 4, + name: '小幅板', + }, + ], + installationAvailability: [ + { + /** 확인완료 */ + id: 1, + name: '確認済み', + }, + { + /** 미확인 */ + id: 2, + name: '未確認', + }, + ], +} + +export const radioEtcData: Record = { + structureOrder: [ + { + /** 지붕재 - 방수재 - 지붕의기초 - 서까래 */ + id: 1, + label: '屋根材 > 防水材 > 屋根の基礎 > 垂木', + }, + ], + houseStructure: [ + { + /** 목재 */ + id: 1, + label: '木製', + }, + ], + rafterMaterial: [ + { + /** 목재 */ + id: 1, + label: '木製', + }, + { + /** 강재 */ + id: 2, + label: '強制', + }, + ], + waterproofMaterial: [ + { + /** 아스팔트 지붕 940(22kg 이상) */ + id: 1, + label: 'アスファルト屋根940(22kg以上)', + }, + ], + insulationPresence: [ + { + /** 없음 */ + id: 1, + label: 'なし', + }, + { + /** 있음 */ + id: 2, + label: 'あり', + }, + ], + rafterDirection: [ + { + /** 수직 */ + id: 1, + label: '垂直垂木', + }, + { + /** 수평 */ + id: 2, + label: '水平垂木', + }, + ], + leakTrace: [ + { + /** 있음 */ + id: 1, + label: 'あり', + }, + { + /** 없음 */ + id: 2, + label: 'なし', + }, + ], } \ No newline at end of file diff --git a/src/utils/common-utils.js b/src/utils/common-utils.js index a81373c..fdef02e 100644 --- a/src/utils/common-utils.js +++ b/src/utils/common-utils.js @@ -209,3 +209,27 @@ export const convertToSnakeCase = (obj) => { return obj } + +// 스네이크케이스를 카멜케이스로 변환하는 함수 +export const toCamelCase = (str) => { + return str.replace(/_([a-z])/g, (match, letter) => letter.toUpperCase()) +} + +// 객체의 키를 카멜케이스로 변환하는 함수 +export const convertToCamelCase = (obj) => { + if (obj === null || obj === undefined) return obj + + if (Array.isArray(obj)) { + return obj.map((item) => convertToCamelCase(item)) + } + + if (typeof obj === 'object') { + return Object.keys(obj).reduce((acc, key) => { + const camelKey = toCamelCase(key.toLowerCase()) + acc[camelKey] = convertToCamelCase(obj[key]) + return acc + }, {}) + } + + return obj +}