feature/survey-pdf-mail : 조사매물 pdf 메일 첨부 기능 구현 #94

Merged
seul merged 6 commits from feature/survey-pdf-mail into dev 2025-07-04 17:00:07 +09:00
7 changed files with 75 additions and 38 deletions

View File

@ -7,6 +7,7 @@ import { getIronSession } from 'iron-session'
import { SessionData } from '@/types/Auth' import { SessionData } from '@/types/Auth'
import { sessionOptions } from '@/libs/session' import { sessionOptions } from '@/libs/session'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import { ERROR_MESSAGE } from '@/hooks/useAlertMsg'
/** /**
* @api {POST} /api/qna/save API * @api {POST} /api/qna/save API
@ -37,7 +38,7 @@ async function setQna(request: Request): Promise<NextResponse> {
const formData = await request.formData() const formData = await request.formData()
const mailResult = await service.sendMail(formData) const mailResult = await service.sendMail(formData)
if (mailResult instanceof ApiError) { if (mailResult instanceof ApiError) {
return NextResponse.json({ error: mailResult.message }, { status: mailResult.statusCode }) return NextResponse.json({ error: ERROR_MESSAGE.EMAIL_SEND_ERROR }, { status: HttpStatusCode.InternalServerError })
} }
const result = await service.tryFunction(() => const result = await service.tryFunction(() =>

View File

@ -35,6 +35,9 @@ export class QnaService {
if (shouldThrowResult) return response if (shouldThrowResult) return response
return this.handleResult(response) return this.handleResult(response)
} catch (error) { } catch (error) {
if (shouldThrowResult) {
return new ApiError(HttpStatusCode.InternalServerError, ERROR_MESSAGE.SERVER_ERROR)
}
return this.handleRouteError(error) return this.handleRouteError(error)
} }
} }

View File

@ -5,7 +5,7 @@ import { useSessionStore } from '@/store/session'
import { InquiryRequest } from '@/types/Inquiry' import { InquiryRequest } from '@/types/Inquiry'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { CONFIRM_MESSAGE, ERROR_MESSAGE, SUCCESS_MESSAGE, useAlertMsg, WARNING_MESSAGE } from '@/hooks/useAlertMsg' import { CONFIRM_MESSAGE, SUCCESS_MESSAGE, useAlertMsg, WARNING_MESSAGE } from '@/hooks/useAlertMsg'
import { useInquiryFilterStore } from '@/store/inquiryFilterStore' import { useInquiryFilterStore } from '@/store/inquiryFilterStore'
export default function RegistForm() { export default function RegistForm() {
@ -106,12 +106,10 @@ export default function RegistForm() {
showConfirm( showConfirm(
CONFIRM_MESSAGE.SAVE_INQUIRY_CONFIRM, CONFIRM_MESSAGE.SAVE_INQUIRY_CONFIRM,
async () => { async () => {
try { const res = await saveInquiry(formData)
const res = await saveInquiry(formData) if (!isSavingInquiry) {
showSuccessAlert(SUCCESS_MESSAGE.SAVE_SUCCESS) showSuccessAlert(SUCCESS_MESSAGE.SAVE_SUCCESS)
router.push(`/inquiry/${res.qnaNo}`) router.push(`/inquiry/${res.qnaNo}`)
} catch (error) {
showErrorAlert(ERROR_MESSAGE.SERVER_ERROR)
} }
}, },
() => null, () => null,

View File

@ -19,6 +19,7 @@ interface SubmitFormData {
reference: string[] | null reference: string[] | null
title: string title: string
contents: string | null contents: string | null
srlNo: string | null
} }
interface FormField { interface FormField {
@ -35,7 +36,7 @@ export default function SurveySaleSubmitPopup() {
const { setIsShow } = useSpinnerStore() const { setIsShow } = useSpinnerStore()
const { getCommCode } = useCommCode() const { getCommCode } = useCommCode()
const { surveyDetail, getSubmitTarget } = useSurvey(Number(routeId)) const { surveyDetail, getSubmitTarget, isSubmittingSurvey, submitSurvey } = useSurvey(Number(routeId))
const { showErrorAlert, showSuccessAlert, showConfirm } = useAlertMsg() const { showErrorAlert, showSuccessAlert, showConfirm } = useAlertMsg()
const [submitData, setSubmitData] = useState<SubmitFormData>({ const [submitData, setSubmitData] = useState<SubmitFormData>({
@ -47,6 +48,7 @@ export default function SurveySaleSubmitPopup() {
reference: null, reference: null,
title: '', title: '',
contents: '', contents: '',
srlNo: null,
}) })
const [commCodeList, setCommCodeList] = useState<CommCode[]>([]) const [commCodeList, setCommCodeList] = useState<CommCode[]>([])
@ -56,6 +58,7 @@ export default function SurveySaleSubmitPopup() {
const baseUpdate = { const baseUpdate = {
sender: session?.email ?? '', sender: session?.email ?? '',
title: '[HANASYS現地調査] 調査物件が提出. (' + surveyDetail?.srlNo + ')', title: '[HANASYS現地調査] 調査物件が提出. (' + surveyDetail?.srlNo + ')',
srlNo: surveyDetail?.srlNo ?? null,
} }
/** Admin 제출 폼 데이터 삽입 - 1차 판매점*/ /** Admin 제출 폼 데이터 삽입 - 1차 판매점*/
if (session?.role === 'Admin') { if (session?.role === 'Admin') {
@ -106,8 +109,6 @@ export default function SurveySaleSubmitPopup() {
{ id: 'contents', name: '内容', required: false }, { id: 'contents', name: '内容', required: false },
] ]
const { submitSurvey, isSubmittingSurvey } = useSurvey(Number(routeId))
const handleInputChange = (field: keyof SubmitFormData, value: string) => { const handleInputChange = (field: keyof SubmitFormData, value: string) => {
setSubmitData((prev) => ({ ...prev, [field]: value })) setSubmitData((prev) => ({ ...prev, [field]: value }))
} }
@ -140,11 +141,15 @@ export default function SurveySaleSubmitPopup() {
cc: submitData.reference ?? '', cc: submitData.reference ?? '',
subject: submitData.title, subject: submitData.title,
content: generateEmailContent(), content: generateEmailContent(),
surveyPdf: {
id: surveyDetail?.id ?? 0,
filename: surveyDetail?.srlNo ?? 'hanasys_survey',
},
}) })
.then(() => { .then(() => {
submitSurvey({ targetId: submitData.targetId, targetNm: submitData.targetNm })
if (!isSubmittingSurvey) { if (!isSubmittingSurvey) {
showSuccessAlert(SUCCESS_MESSAGE.SUBMIT_SUCCESS) showSuccessAlert(SUCCESS_MESSAGE.SUBMIT_SUCCESS)
submitSurvey({ targetId: submitData.targetId, targetNm: submitData.targetNm })
popupController.setSurveySaleSubmitPopup(false) popupController.setSurveySaleSubmitPopup(false)
} }
}) })

View File

@ -48,10 +48,12 @@ export default function ButtonForm({ mode, setMode, data }: ButtonFormProps) {
const isSubmit = data.basic.submissionStatus const isSubmit = data.basic.submissionStatus
const { deleteSurvey, updateSurvey, isDeletingSurvey, isUpdatingSurvey } = useSurvey(id) const { deleteSurvey, updateSurvey, isDeletingSurvey, isUpdatingSurvey, isSubmittingSurvey, isLoadingSurveyDetail } = useSurvey(id)
const { validateSurveyDetail, createSurvey, isCreatingSurvey } = useSurvey() const { validateSurveyDetail, createSurvey, isCreatingSurvey } = useSurvey()
const { showErrorAlert, showSuccessAlert, showConfirm } = useAlertMsg() const { showErrorAlert, showSuccessAlert, showConfirm } = useAlertMsg()
const buttonDisabled = isLoadingSurveyDetail || isSubmittingSurvey || isCreatingSurvey || isUpdatingSurvey || isDeletingSurvey
useEffect(() => { useEffect(() => {
if (!session?.isLoggedIn) return if (!session?.isLoggedIn) return
@ -237,9 +239,9 @@ export default function ButtonForm({ mode, setMode, data }: ButtonFormProps) {
<div className="sale-form-btn-wrap"> <div className="sale-form-btn-wrap">
<div className="btn-flex-wrap"> <div className="btn-flex-wrap">
<ListButton /> <ListButton />
{(permissions.isWriter || permissions.isSubmiter || (permissions.isReceiver && isSubmit)) && <EditButton setMode={setMode} />} {(permissions.isWriter || permissions.isSubmiter || (permissions.isReceiver && isSubmit)) && <EditButton setMode={setMode} disabled={buttonDisabled} />}
{(permissions.isWriter || (permissions.isReceiver && isSubmit)) && <DeleteButton handleDelete={handleDelete} />} {(permissions.isWriter || (permissions.isReceiver && isSubmit)) && <DeleteButton handleDelete={handleDelete} disabled={buttonDisabled} />}
{!isSubmit && permissions.isSubmiter && <SubmitButton handleSubmit={handleSubmit} />} {!isSubmit && permissions.isSubmiter && <SubmitButton handleSubmit={handleSubmit} disabled={buttonDisabled} />}
</div> </div>
</div> </div>
)} )}
@ -252,9 +254,9 @@ export default function ButtonForm({ mode, setMode, data }: ButtonFormProps) {
<div className="sale-form-btn-wrap"> <div className="sale-form-btn-wrap">
<div className="btn-flex-wrap"> <div className="btn-flex-wrap">
<ListButton /> <ListButton />
<TempButton handleSave={() => handleSave(true, false)} /> <TempButton handleSave={() => handleSave(true, false)} disabled={buttonDisabled} />
<SaveButton handleSave={() => handleSave(false, false)} /> <SaveButton handleSave={() => handleSave(false, false)} disabled={buttonDisabled} />
{!isSubmit && permissions.isSubmiter && <SubmitButton handleSubmit={handleSubmit} />} {!isSubmit && permissions.isSubmiter && <SubmitButton handleSubmit={handleSubmit} disabled={buttonDisabled} />}
</div> </div>
</div> </div>
)} )}
@ -274,7 +276,7 @@ const ListButton = () => {
) )
} }
const EditButton = ({ setMode }: { setMode: (mode: Mode) => void }) => { const EditButton = ({ setMode, disabled }: { setMode: (mode: Mode) => void; disabled: boolean }) => {
return ( return (
<div className="btn-bx"> <div className="btn-bx">
<button <button
@ -282,6 +284,7 @@ const EditButton = ({ setMode }: { setMode: (mode: Mode) => void }) => {
onClick={() => { onClick={() => {
setMode('EDIT') setMode('EDIT')
}} }}
disabled={disabled}
> >
<i className="btn-arr"></i> <i className="btn-arr"></i>
</button> </button>
@ -289,33 +292,33 @@ const EditButton = ({ setMode }: { setMode: (mode: Mode) => void }) => {
) )
} }
const SubmitButton = ({ handleSubmit }: { handleSubmit: () => void }) => ( const SubmitButton = ({ handleSubmit, disabled }: { handleSubmit: () => void; disabled: boolean }) => (
<div className="btn-bx"> <div className="btn-bx">
<button className="btn-frame red icon" onClick={handleSubmit}> <button className="btn-frame red icon" onClick={handleSubmit} disabled={disabled}>
<i className="btn-arr"></i> <i className="btn-arr"></i>
</button> </button>
</div> </div>
) )
const DeleteButton = ({ handleDelete }: { handleDelete: () => void }) => ( const DeleteButton = ({ handleDelete, disabled }: { handleDelete: () => void; disabled: boolean }) => (
<div className="btn-bx"> <div className="btn-bx">
<button className="btn-frame n-blue icon" onClick={handleDelete}> <button className="btn-frame n-blue icon" onClick={handleDelete} disabled={disabled}>
<i className="btn-arr"></i> <i className="btn-arr"></i>
</button> </button>
</div> </div>
) )
const SaveButton = ({ handleSave }: { handleSave: () => void }) => ( const SaveButton = ({ handleSave, disabled }: { handleSave: () => void; disabled: boolean }) => (
<div className="btn-bx"> <div className="btn-bx">
<button className="btn-frame n-blue icon" onClick={handleSave}> <button className="btn-frame n-blue icon" onClick={handleSave} disabled={disabled}>
<i className="btn-arr"></i> <i className="btn-arr"></i>
</button> </button>
</div> </div>
) )
const TempButton = ({ handleSave }: { handleSave: () => void }) => ( const TempButton = ({ handleSave, disabled }: { handleSave: () => void; disabled: boolean }) => (
<div className="btn-bx"> <div className="btn-bx">
<button className="btn-frame n-blue icon" onClick={handleSave}> <button className="btn-frame n-blue icon" onClick={handleSave} disabled={disabled}>
<i className="btn-arr"></i> <i className="btn-arr"></i>
</button> </button>
</div> </div>

View File

@ -5,7 +5,7 @@ import { useInquiryFilterStore } from '@/store/inquiryFilterStore'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useAlertMsg } from '@/hooks/useAlertMsg' import { useAlertMsg } from '@/hooks/useAlertMsg'
import { CommCode } from '@/types/CommCode'
/** /**
* @description * @description

View File

@ -3,6 +3,15 @@
import nodemailer from 'nodemailer' import nodemailer from 'nodemailer'
import { Attachment } from 'nodemailer/lib/mailer' import { Attachment } from 'nodemailer/lib/mailer'
/**
* @description
* @param {string} from
* @param {string | string[]} to
* @param {string | string[]} cc
* @param {string} subject
* @param {string} content
* @param {Attachment[]} attachments
*/
interface EmailParams { interface EmailParams {
from: string from: string
to: string | string[] to: string | string[]
@ -10,10 +19,32 @@ interface EmailParams {
subject: string subject: string
content: string content: string
attachments?: Attachment[] attachments?: Attachment[]
surveyPdf?: {
id: number
filename: string
}
} }
export async function sendEmail({ from, to, cc, subject, content, attachments }: EmailParams): Promise<void> { export async function sendEmail({ from, to, cc, subject, content, attachments, surveyPdf }: EmailParams): Promise<void> {
// Create a transporter using SMTP /**
* @description pdf blob buffer
*/
let surveyPdfBuffer: Buffer | null = null
if (surveyPdf) {
const resp = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/survey-sales/${surveyPdf.id}?isPdf=true`, {
method: 'GET',
headers: {
'Content-Type': 'application/pdf',
},
})
const pdfBlob = await resp.blob()
surveyPdfBuffer = Buffer.from(await pdfBlob.arrayBuffer())
}
const surveyPdfAttachment = surveyPdfBuffer ? [{ filename: '[HANASYS現地調査]' + surveyPdf?.filename + '.pdf', content: surveyPdfBuffer }] : []
/**
* @description SMTP
*/
const transporter = nodemailer.createTransport({ const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST, host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT), port: Number(process.env.SMTP_PORT),
@ -22,21 +53,17 @@ export async function sendEmail({ from, to, cc, subject, content, attachments }:
tls: { rejectUnauthorized: false }, tls: { rejectUnauthorized: false },
}) })
// Email options /**
* @description
*/
const mailOptions = { const mailOptions = {
from, from,
to: Array.isArray(to) ? to.join(', ') : to, to: Array.isArray(to) ? to.join(', ') : to,
cc: cc ? (Array.isArray(cc) ? cc.join(', ') : cc) : undefined, cc: cc ? (Array.isArray(cc) ? cc.join(', ') : cc) : undefined,
subject, subject,
html: content, html: content,
attachments: attachments || [], attachments: surveyPdf ? surveyPdfAttachment : attachments || [],
} }
try { await transporter.sendMail(mailOptions)
// Send email
await transporter.sendMail(mailOptions)
} catch (error) {
console.error('Error sending email:', error)
throw new Error('Failed to send email')
}
} }