feat: 게시판관리(공지사항·1:1문의·FAQ) 화면 추가
사이드바 「게시판관리(고객센터)」 그룹과 3개 화면 신설 (/boards/notices, /boards/inquiries, /boards/faqs). 백엔드 최신화(develop 924db37)로 새로 생긴 관리자 게시판 API에 연동한다. 세 화면이 TB_COM_BBS 한 테이블을 stngId로만 구분하므로 도메인·Repository· Server Action·공용 컴포넌트를 한 벌로 두고 BoardType으로 분기한다. - 공지사항: 목록·등록·수정·삭제 (구분/사용여부 필터, 상단고정·노출기간·앱푸쉬) - 1:1문의: 목록·상세답변·삭제 (등록 없음, 전화번호 마스킹, 진행상태) - FAQ: 목록·등록·수정·삭제 (구분/유형 필터) - 첨부파일 업로드(Server Action 경유)와 다운로드 중계 라우트 추가 백엔드 계약 확인에 따른 결정(사용자 확인 완료): - totalCount·검색조건 한계로 전체를 받아 서버에서 검색·필터·페이징 - stngId·moduleId·구분/유형/진행상태 코드는 백엔드 미확정이라 상수 한 곳에 임시값을 두고 추후 교체(BOARD_SETTING_IDS 등) - 유형은 저장 필드가 없어 임시 파라미터명으로 전송(백엔드 추가 예정) - 노출기간·앱푸쉬는 조회 SELECT에 없어 전송만 하고 조회는 빈 값 - 본문은 리치 에디터 대신 글자수 카운트 textarea backend-fetch: PUT/DELETE·폼 인코딩·멀티파트 지원 추가. 쓰기 API가 success(null)을 반환해 기존 backendFetch로는 성공이 실패로 읽히므로 데이터 없는 쓰기 전용 backendCommand를 분리했다. Co-Authored-By: Claude Opus 5
@673e232dfded9ff3358317dc32d9f064ca851423
+++ app/(protected)/(basic)/boards/_actions.ts
... | ... | @@ -0,0 +1,256 @@ |
| 1 | +'use server'; | |
| 2 | + | |
| 3 | +import { revalidatePath } from 'next/cache'; | |
| 4 | +import { verifySession } from '@/lib/auth/dal'; | |
| 5 | +import { | |
| 6 | + createBoardPost, | |
| 7 | + deleteBoardPost, | |
| 8 | + saveInquiryAnswer, | |
| 9 | + updateBoardPost, | |
| 10 | +} from '@/lib/data/repositories/board-post-repository'; | |
| 11 | +import { | |
| 12 | + MAX_ATTACHMENT_BYTES, | |
| 13 | + uploadAttachment, | |
| 14 | +} from '@/lib/data/repositories/file-repository'; | |
| 15 | +import { | |
| 16 | + ANSWER_STATUS_DONE, | |
| 17 | + BOARD_FILE_MODULE_ID, | |
| 18 | + type BoardType, | |
| 19 | +} from '@/lib/domain/board-post'; | |
| 20 | +import { | |
| 21 | + validateBoardPostForm, | |
| 22 | + validateInquiryAnswerForm, | |
| 23 | + type BoardPostFormErrors, | |
| 24 | + type InquiryAnswerFormErrors, | |
| 25 | +} from '@/lib/domain/board-post-form'; | |
| 26 | +import { BOARD_PATHS } from '@/lib/domain/board-post-query'; | |
| 27 | + | |
| 28 | +/** | |
| 29 | + * 게시판(공지사항·1:1문의·FAQ) 등록/수정/삭제/답변 Server Action. | |
| 30 | + * | |
| 31 | + * 세 화면이 같은 API를 쓰므로 Action도 한 파일이 담당하고, 어느 게시판인지는 폼에 실린 | |
| 32 | + * `boardType`으로 가른다. **그 값도 신뢰하지 않는다** — 허용 목록에 없으면 즉시 거절한다. | |
| 33 | + * | |
| 34 | + * **모든 Action이 `verifySession()`으로 시작한다** — Server Action은 UI를 거치지 않고 직접 | |
| 35 | + * POST될 수 있어 이 확인이 유일한 최종 방어선이다(설계서 §8). | |
| 36 | + * | |
| 37 | + * 검증은 화면이 아니라 여기서 확정한다(`lib/domain/board-post-form.ts`의 규칙을 호출). | |
| 38 | + * | |
| 39 | + * 첨부파일은 **같은 폼에 실려 온 File을 이 Action이 직접 업로드**한다. 브라우저에서 먼저 업로드해 | |
| 40 | + * id를 받아 두는 방식보다 단순하고(왕복 1회), 업로드 API가 요구하는 관리자 토큰이 서버에만 | |
| 41 | + * 있으므로 자연스럽다 — 토큰은 httpOnly 세션 안에 있어 브라우저가 꺼낼 수 없다. | |
| 42 | + */ | |
| 43 | + | |
| 44 | +const BOARD_TYPES: readonly BoardType[] = ['notice', 'inquiry', 'faq']; | |
| 45 | + | |
| 46 | +export type BoardPostFormState = | |
| 47 | + | { status: 'idle' } | |
| 48 | + | { status: 'error'; message?: string; errors?: BoardPostFormErrors } | |
| 49 | + | { status: 'success' }; | |
| 50 | + | |
| 51 | +export type InquiryAnswerFormState = | |
| 52 | + | { status: 'idle' } | |
| 53 | + | { status: 'error'; message?: string; errors?: InquiryAnswerFormErrors } | |
| 54 | + | { status: 'success' }; | |
| 55 | + | |
| 56 | +export const INITIAL_BOARD_POST_FORM_STATE: BoardPostFormState = { | |
| 57 | + status: 'idle', | |
| 58 | +}; | |
| 59 | +export const INITIAL_INQUIRY_ANSWER_FORM_STATE: InquiryAnswerFormState = { | |
| 60 | + status: 'idle', | |
| 61 | +}; | |
| 62 | + | |
| 63 | +const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.'; | |
| 64 | +const UPLOAD_FAILED_MESSAGE = | |
| 65 | + '첨부파일을 업로드하지 못했습니다. 잠시 후 다시 시도해 주세요.'; | |
| 66 | +const OVERSIZE_MESSAGE = `첨부파일은 ${Math.floor( | |
| 67 | + MAX_ATTACHMENT_BYTES / (1024 * 1024) | |
| 68 | +)}MB 이하만 올릴 수 있습니다.`; | |
| 69 | + | |
| 70 | +function readString(formData: FormData, key: string): string { | |
| 71 | + const value = formData.get(key); | |
| 72 | + return typeof value === 'string' ? value : ''; | |
| 73 | +} | |
| 74 | + | |
| 75 | +/** 체크박스·라디오는 값이 있을 때만 전송되므로 "Y로 온 경우"만 참으로 본다. */ | |
| 76 | +function readBoolean(formData: FormData, key: string): boolean { | |
| 77 | + return readString(formData, key) === 'Y'; | |
| 78 | +} | |
| 79 | + | |
| 80 | +function readBoardType(formData: FormData): BoardType | null { | |
| 81 | + const raw = readString(formData, 'boardType'); | |
| 82 | + return BOARD_TYPES.find((type) => type === raw) ?? null; | |
| 83 | +} | |
| 84 | + | |
| 85 | +/** | |
| 86 | + * 새로 첨부된 파일이 있으면 업로드하고 새 id를, 없으면 기존 id를 그대로 돌려준다. | |
| 87 | + * | |
| 88 | + * 빈 파일(파일을 고르지 않으면 브라우저가 size 0으로 보낸다)은 "선택 안 함"이므로 무시한다 — | |
| 89 | + * 이때 기존 첨부를 지우지 않는 것이 의도다(수정 화면에서 첨부를 건드리지 않은 경우). | |
| 90 | + */ | |
| 91 | +async function resolveAttachmentId(formData: FormData): Promise<string> { | |
| 92 | + const existingId = readString(formData, 'attachmentId'); | |
| 93 | + const file = formData.get('attachment'); | |
| 94 | + | |
| 95 | + if (!(file instanceof File) || file.size === 0) { | |
| 96 | + return existingId; | |
| 97 | + } | |
| 98 | + | |
| 99 | + if (file.size > MAX_ATTACHMENT_BYTES) { | |
| 100 | + throw new Error(OVERSIZE_MESSAGE); | |
| 101 | + } | |
| 102 | + | |
| 103 | + return uploadAttachment(file, BOARD_FILE_MODULE_ID); | |
| 104 | +} | |
| 105 | + | |
| 106 | +/** 등록·수정이 공유하는 입력 읽기. */ | |
| 107 | +function readPostValues(formData: FormData, attachmentId: string) { | |
| 108 | + return { | |
| 109 | + categoryCode: readString(formData, 'categoryCode'), | |
| 110 | + typeCode: readString(formData, 'typeCode'), | |
| 111 | + title: readString(formData, 'title'), | |
| 112 | + content: readString(formData, 'content'), | |
| 113 | + attachmentId, | |
| 114 | + isPinned: readBoolean(formData, 'isPinned'), | |
| 115 | + isVisible: readBoolean(formData, 'isVisible'), | |
| 116 | + isPushEnabled: readBoolean(formData, 'isPushEnabled'), | |
| 117 | + startDate: readString(formData, 'startDate'), | |
| 118 | + endDate: readString(formData, 'endDate'), | |
| 119 | + }; | |
| 120 | +} | |
| 121 | + | |
| 122 | +/** 시안 A_BOA_007_p / 015_p — 공지사항·FAQ 신규 등록. */ | |
| 123 | +export async function createBoardPostAction( | |
| 124 | + _prevState: BoardPostFormState, | |
| 125 | + formData: FormData | |
| 126 | +): Promise<BoardPostFormState> { | |
| 127 | + await verifySession(); | |
| 128 | + | |
| 129 | + const boardType = readBoardType(formData); | |
| 130 | + if (boardType === null || boardType === 'inquiry') { | |
| 131 | + // 1:1문의는 사용자가 작성하는 글이라 관리자 등록 경로가 없다(사용자 확인 사항). | |
| 132 | + return { status: 'error', message: INVALID_REQUEST_MESSAGE }; | |
| 133 | + } | |
| 134 | + | |
| 135 | + let attachmentId: string; | |
| 136 | + try { | |
| 137 | + attachmentId = await resolveAttachmentId(formData); | |
| 138 | + } catch (error) { | |
| 139 | + return { | |
| 140 | + status: 'error', | |
| 141 | + message: error instanceof Error ? error.message : UPLOAD_FAILED_MESSAGE, | |
| 142 | + }; | |
| 143 | + } | |
| 144 | + | |
| 145 | + const validation = validateBoardPostForm( | |
| 146 | + readPostValues(formData, attachmentId), | |
| 147 | + { boardType, isCategoryEditable: true } | |
| 148 | + ); | |
| 149 | + if (!validation.ok) { | |
| 150 | + return { status: 'error', errors: validation.errors }; | |
| 151 | + } | |
| 152 | + | |
| 153 | + await createBoardPost(boardType, validation.values); | |
| 154 | + | |
| 155 | + revalidatePath(BOARD_PATHS[boardType]); | |
| 156 | + return { status: 'success' }; | |
| 157 | +} | |
| 158 | + | |
| 159 | +/** 시안 A_BOA_008_p / 016_p — 공지사항·FAQ 수정. 구분은 읽기 전용이라 검증 대상이 아니다. */ | |
| 160 | +export async function updateBoardPostAction( | |
| 161 | + _prevState: BoardPostFormState, | |
| 162 | + formData: FormData | |
| 163 | +): Promise<BoardPostFormState> { | |
| 164 | + await verifySession(); | |
| 165 | + | |
| 166 | + const boardType = readBoardType(formData); | |
| 167 | + const bbsId = readString(formData, 'id'); | |
| 168 | + if (boardType === null || boardType === 'inquiry' || !bbsId) { | |
| 169 | + return { status: 'error', message: INVALID_REQUEST_MESSAGE }; | |
| 170 | + } | |
| 171 | + | |
| 172 | + let attachmentId: string; | |
| 173 | + try { | |
| 174 | + attachmentId = await resolveAttachmentId(formData); | |
| 175 | + } catch (error) { | |
| 176 | + return { | |
| 177 | + status: 'error', | |
| 178 | + message: error instanceof Error ? error.message : UPLOAD_FAILED_MESSAGE, | |
| 179 | + }; | |
| 180 | + } | |
| 181 | + | |
| 182 | + const validation = validateBoardPostForm( | |
| 183 | + readPostValues(formData, attachmentId), | |
| 184 | + { boardType, isCategoryEditable: false } | |
| 185 | + ); | |
| 186 | + if (!validation.ok) { | |
| 187 | + return { status: 'error', errors: validation.errors }; | |
| 188 | + } | |
| 189 | + | |
| 190 | + await updateBoardPost(boardType, bbsId, validation.values); | |
| 191 | + | |
| 192 | + revalidatePath(BOARD_PATHS[boardType]); | |
| 193 | + return { status: 'success' }; | |
| 194 | +} | |
| 195 | + | |
| 196 | +/** 시안 A_BOA_013_p — 1:1문의 답변 저장. */ | |
| 197 | +export async function saveInquiryAnswerAction( | |
| 198 | + _prevState: InquiryAnswerFormState, | |
| 199 | + formData: FormData | |
| 200 | +): Promise<InquiryAnswerFormState> { | |
| 201 | + await verifySession(); | |
| 202 | + | |
| 203 | + const bbsId = readString(formData, 'id'); | |
| 204 | + if (!bbsId) { | |
| 205 | + return { status: 'error', message: INVALID_REQUEST_MESSAGE }; | |
| 206 | + } | |
| 207 | + | |
| 208 | + let attachmentId: string; | |
| 209 | + try { | |
| 210 | + attachmentId = await resolveAttachmentId(formData); | |
| 211 | + } catch (error) { | |
| 212 | + return { | |
| 213 | + status: 'error', | |
| 214 | + message: error instanceof Error ? error.message : UPLOAD_FAILED_MESSAGE, | |
| 215 | + }; | |
| 216 | + } | |
| 217 | + | |
| 218 | + const validation = validateInquiryAnswerForm( | |
| 219 | + { | |
| 220 | + answerStatusCode: readString(formData, 'answerStatusCode'), | |
| 221 | + answerContent: readString(formData, 'answerContent'), | |
| 222 | + attachmentId, | |
| 223 | + }, | |
| 224 | + { doneStatusCode: ANSWER_STATUS_DONE } | |
| 225 | + ); | |
| 226 | + if (!validation.ok) { | |
| 227 | + return { status: 'error', errors: validation.errors }; | |
| 228 | + } | |
| 229 | + | |
| 230 | + await saveInquiryAnswer(bbsId, validation.values); | |
| 231 | + | |
| 232 | + revalidatePath(BOARD_PATHS.inquiry); | |
| 233 | + return { status: 'success' }; | |
| 234 | +} | |
| 235 | + | |
| 236 | +/** | |
| 237 | + * 삭제(논리 삭제) — 확인 얼럿은 화면이 띄우고 여기서는 권한과 입력만 확인한다. | |
| 238 | + * | |
| 239 | + * 폼 제출이 아니라 얼럿의 [삭제] 클릭에 반응하는 단발 호출이라 `useActionState`의 | |
| 240 | + * (prevState, formData) 규약 대신 값을 직접 받는다. | |
| 241 | + */ | |
| 242 | +export async function deleteBoardPostAction( | |
| 243 | + boardType: BoardType, | |
| 244 | + bbsId: string | |
| 245 | +): Promise<BoardPostFormState> { | |
| 246 | + await verifySession(); | |
| 247 | + | |
| 248 | + if (!BOARD_TYPES.includes(boardType) || !bbsId) { | |
| 249 | + return { status: 'error', message: INVALID_REQUEST_MESSAGE }; | |
| 250 | + } | |
| 251 | + | |
| 252 | + await deleteBoardPost(bbsId); | |
| 253 | + | |
| 254 | + revalidatePath(BOARD_PATHS[boardType]); | |
| 255 | + return { status: 'success' }; | |
| 256 | +} |
+++ app/(protected)/(basic)/boards/_components/board-attachment-field.tsx
... | ... | @@ -0,0 +1,55 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { Field } from '@/components/ui/field'; | |
| 4 | + | |
| 5 | +interface BoardAttachmentFieldProps { | |
| 6 | + label?: string; | |
| 7 | + /** 이미 저장돼 있는 첨부파일 식별자(수정 화면). 없으면 신규 등록이다. */ | |
| 8 | + attachmentId?: string | null; | |
| 9 | +} | |
| 10 | + | |
| 11 | +/** 다운로드 라우트 — 백엔드 파일 API를 중계한다(`boards/attachments/route.ts`). */ | |
| 12 | +const ATTACHMENT_DOWNLOAD_PATH = '/boards/attachments'; | |
| 13 | + | |
| 14 | +/** | |
| 15 | + * 첨부파일 입력 — 파일을 고르면 **폼과 함께 그대로 제출**되고, Server Action이 업로드한 뒤 | |
| 16 | + * 게시물에 연결한다(브라우저에서 미리 업로드하지 않는다 — `_actions.ts` 주석 참조). | |
| 17 | + * | |
| 18 | + * 기존 첨부는 **다운로드 링크로만** 보여준다. 시안(A_BOA_008_p)은 `pic_0001.jpg 88KB`처럼 파일명과 | |
| 19 | + * 용량을 표기하지만, **백엔드에 atchFileId로 파일 정보를 조회하는 API가 없어** 저장된 첨부의 | |
| 20 | + * 이름·크기를 알 방법이 없다(업로드·다운로드·삭제·이미지·스트림만 존재). 그 API가 생기면 여기에 | |
| 21 | + * 파일명·용량 표기와 개별 삭제 버튼을 붙이면 된다. | |
| 22 | + * | |
| 23 | + * 새 파일을 고르면 기존 첨부는 교체된다(백엔드 필드가 `atchFileId` 하나뿐이다). 파일을 고르지 | |
| 24 | + * 않으면 기존 첨부가 그대로 유지된다. | |
| 25 | + */ | |
| 26 | +export function BoardAttachmentField({ | |
| 27 | + label = '첨부파일', | |
| 28 | + attachmentId, | |
| 29 | +}: BoardAttachmentFieldProps) { | |
| 30 | + return ( | |
| 31 | + <Field label={label}> | |
| 32 | + {/* 기존 첨부 id를 함께 제출해 "파일을 안 골랐으면 유지"를 서버가 판단할 수 있게 한다. */} | |
| 33 | + <input type="hidden" name="attachmentId" value={attachmentId ?? ''} /> | |
| 34 | + | |
| 35 | + {attachmentId ? ( | |
| 36 | + <p className="text-body-sm text-foreground-muted"> | |
| 37 | + 기존 첨부파일{' '} | |
| 38 | + <a | |
| 39 | + href={`${ATTACHMENT_DOWNLOAD_PATH}?atchFileId=${encodeURIComponent(attachmentId)}`} | |
| 40 | + className="text-primary underline underline-offset-2" | |
| 41 | + > | |
| 42 | + 다운로드 | |
| 43 | + </a>{' '} | |
| 44 | + — 새 파일을 선택하면 교체됩니다. | |
| 45 | + </p> | |
| 46 | + ) : null} | |
| 47 | + | |
| 48 | + <input | |
| 49 | + type="file" | |
| 50 | + name="attachment" | |
| 51 | + className="w-full text-body-sm text-foreground file:mr-3 file:rounded-md file:border file:border-border file:bg-surface file:px-3 file:py-1.5 file:text-body-sm file:text-foreground" | |
| 52 | + /> | |
| 53 | + </Field> | |
| 54 | + ); | |
| 55 | +} |
+++ app/(protected)/(basic)/boards/_components/board-content-field.tsx
... | ... | @@ -0,0 +1,49 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useState } from 'react'; | |
| 4 | +import { Field } from '@/components/ui/field'; | |
| 5 | + | |
| 6 | +interface BoardContentFieldProps { | |
| 7 | + name: string; | |
| 8 | + label: string; | |
| 9 | + defaultValue?: string; | |
| 10 | + maxLength: number; | |
| 11 | + placeholder?: string; | |
| 12 | +} | |
| 13 | + | |
| 14 | +/** | |
| 15 | + * 본문/답변 입력 — 글자수 카운트가 붙은 textarea(시안의 "50/1000자" 표기). | |
| 16 | + * | |
| 17 | + * 시안은 리치 텍스트 에디터(굵게·기울임·링크·목록)지만, 사용자 확인 결과 **이번 범위는 로직만** | |
| 18 | + * 이라 일반 textarea로 구현했다. 에디터 라이브러리 도입은 별도 협의 대상이다 — 도입하더라도 | |
| 19 | + * 값이 `name`으로 제출되는 계약만 지키면 이 컴포넌트만 교체하면 된다. | |
| 20 | + * | |
| 21 | + * 상한 초과는 `maxLength` 속성으로 입력 단계에서 막지만, 최종 판정은 Server Action의 검증이다 | |
| 22 | + * (화면 속성은 신뢰 경계가 아니다). | |
| 23 | + */ | |
| 24 | +export function BoardContentField({ | |
| 25 | + name, | |
| 26 | + label, | |
| 27 | + defaultValue = '', | |
| 28 | + maxLength, | |
| 29 | + placeholder, | |
| 30 | +}: BoardContentFieldProps) { | |
| 31 | + const [length, setLength] = useState(defaultValue.length); | |
| 32 | + | |
| 33 | + return ( | |
| 34 | + <Field label={label}> | |
| 35 | + <textarea | |
| 36 | + name={name} | |
| 37 | + defaultValue={defaultValue} | |
| 38 | + maxLength={maxLength} | |
| 39 | + placeholder={placeholder} | |
| 40 | + rows={6} | |
| 41 | + onChange={(event) => setLength(event.target.value.length)} | |
| 42 | + className="w-full rounded-md border border-border bg-surface px-3 py-2 text-body-md text-foreground placeholder:text-foreground-subtle focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" | |
| 43 | + /> | |
| 44 | + <p className="text-right text-body-sm text-foreground-muted"> | |
| 45 | + {length}/{maxLength}자 | |
| 46 | + </p> | |
| 47 | + </Field> | |
| 48 | + ); | |
| 49 | +} |
+++ app/(protected)/(basic)/boards/_components/board-filter-bar.tsx
... | ... | @@ -0,0 +1,144 @@ |
| 1 | +import Link from 'next/link'; | |
| 2 | +import { Button } from '@/components/ui/button'; | |
| 3 | +import { Field } from '@/components/ui/field'; | |
| 4 | +import { Input } from '@/components/ui/input'; | |
| 5 | +import { Select } from '@/components/ui/select'; | |
| 6 | +import { BOARD_CATEGORY_OPTIONS, type BoardType } from '@/lib/domain/board-post'; | |
| 7 | +import { | |
| 8 | + ANSWERED_FILTER_OPTIONS, | |
| 9 | + BOARD_PATHS, | |
| 10 | + BOARD_QUERY_FIELDS, | |
| 11 | + BOARD_SEARCH_FIELDS, | |
| 12 | + BOARD_TYPE_OPTIONS, | |
| 13 | + VISIBILITY_FILTER_OPTIONS, | |
| 14 | + buildBoardPostHref, | |
| 15 | + type BoardPostQuery, | |
| 16 | +} from '@/lib/domain/board-post-query'; | |
| 17 | + | |
| 18 | +interface BoardFilterBarProps { | |
| 19 | + boardType: BoardType; | |
| 20 | + query: BoardPostQuery; | |
| 21 | +} | |
| 22 | + | |
| 23 | +/** | |
| 24 | + * 검색·필터 영역 — 세 게시판이 공유한다. 어떤 필터를 그릴지는 `BOARD_QUERY_FIELDS`가 정한다 | |
| 25 | + * (공지사항: 구분·사용여부 / 1:1문의: 구분·질문유형·답변여부 / FAQ: 구분·유형). | |
| 26 | + * | |
| 27 | + * 순수 `<form method="get">` GET 제출로 URL을 갱신한다(자바스크립트 없이도 동작). `pageSize`는 | |
| 28 | + * hidden으로 함께 제출해 검색 후에도 유지되며, 검색 자체는 항상 1페이지로 돌아간다(hidden page를 | |
| 29 | + * 두지 않아 `parseBoardPostQuery`의 기본값 1로 자연스럽게 떨어진다). "초기화"는 폼 제출이 아니라 | |
| 30 | + * 기본값 링크라 현재 입력값과 무관하게 동작한다. | |
| 31 | + * | |
| 32 | + * 상호작용이 전부 네이티브 폼 제출/링크라 클라이언트 자바스크립트가 필요 없다 — Server Component. | |
| 33 | + */ | |
| 34 | +export function BoardFilterBar({ boardType, query }: BoardFilterBarProps) { | |
| 35 | + const fields = BOARD_QUERY_FIELDS[boardType]; | |
| 36 | + const searchFields = BOARD_SEARCH_FIELDS[boardType]; | |
| 37 | + const typeOptions = BOARD_TYPE_OPTIONS[boardType]; | |
| 38 | + | |
| 39 | + const resetHref = buildBoardPostHref(boardType, query, { | |
| 40 | + category: '', | |
| 41 | + type: '', | |
| 42 | + visibility: '', | |
| 43 | + answered: '', | |
| 44 | + searchField: searchFields[0].value, | |
| 45 | + keyword: '', | |
| 46 | + page: 1, | |
| 47 | + }); | |
| 48 | + | |
| 49 | + return ( | |
| 50 | + <form | |
| 51 | + method="get" | |
| 52 | + action={BOARD_PATHS[boardType]} | |
| 53 | + className="flex flex-wrap items-end gap-3" | |
| 54 | + > | |
| 55 | + <input type="hidden" name="pageSize" value={query.pageSize} /> | |
| 56 | + | |
| 57 | + {fields.category && ( | |
| 58 | + <Field label="구분"> | |
| 59 | + <Select name="category" defaultValue={query.category}> | |
| 60 | + <option value="">전체</option> | |
| 61 | + {BOARD_CATEGORY_OPTIONS.map((option) => ( | |
| 62 | + <option key={option.value} value={option.value}> | |
| 63 | + {option.label} | |
| 64 | + </option> | |
| 65 | + ))} | |
| 66 | + </Select> | |
| 67 | + </Field> | |
| 68 | + )} | |
| 69 | + | |
| 70 | + {fields.type && ( | |
| 71 | + <Field label={boardType === 'inquiry' ? '질문유형' : '유형'}> | |
| 72 | + <Select name="type" defaultValue={query.type}> | |
| 73 | + <option value="">전체</option> | |
| 74 | + {typeOptions.map((option) => ( | |
| 75 | + <option key={option.value} value={option.value}> | |
| 76 | + {option.label} | |
| 77 | + </option> | |
| 78 | + ))} | |
| 79 | + </Select> | |
| 80 | + </Field> | |
| 81 | + )} | |
| 82 | + | |
| 83 | + {fields.visibility && ( | |
| 84 | + <Field label="사용여부"> | |
| 85 | + <Select name="visibility" defaultValue={query.visibility}> | |
| 86 | + <option value="">전체</option> | |
| 87 | + {VISIBILITY_FILTER_OPTIONS.map((option) => ( | |
| 88 | + <option key={option.value} value={option.value}> | |
| 89 | + {option.label} | |
| 90 | + </option> | |
| 91 | + ))} | |
| 92 | + </Select> | |
| 93 | + </Field> | |
| 94 | + )} | |
| 95 | + | |
| 96 | + {fields.answered && ( | |
| 97 | + <Field label="답변여부"> | |
| 98 | + <Select name="answered" defaultValue={query.answered}> | |
| 99 | + <option value="">전체</option> | |
| 100 | + {ANSWERED_FILTER_OPTIONS.map((option) => ( | |
| 101 | + <option key={option.value} value={option.value}> | |
| 102 | + {option.label} | |
| 103 | + </option> | |
| 104 | + ))} | |
| 105 | + </Select> | |
| 106 | + </Field> | |
| 107 | + )} | |
| 108 | + | |
| 109 | + {/* 검색 대상이 하나뿐인 화면(공지사항)은 select를 그리지 않고 hidden으로만 싣는다. */} | |
| 110 | + {searchFields.length > 1 ? ( | |
| 111 | + <Field label="검색 대상"> | |
| 112 | + <Select name="searchField" defaultValue={query.searchField}> | |
| 113 | + {searchFields.map((option) => ( | |
| 114 | + <option key={option.value} value={option.value}> | |
| 115 | + {option.label} | |
| 116 | + </option> | |
| 117 | + ))} | |
| 118 | + </Select> | |
| 119 | + </Field> | |
| 120 | + ) : ( | |
| 121 | + <input type="hidden" name="searchField" value={searchFields[0].value} /> | |
| 122 | + )} | |
| 123 | + | |
| 124 | + <Field label="검색어"> | |
| 125 | + <Input | |
| 126 | + type="text" | |
| 127 | + name="keyword" | |
| 128 | + defaultValue={query.keyword} | |
| 129 | + placeholder="검색어를 입력하세요." | |
| 130 | + /> | |
| 131 | + </Field> | |
| 132 | + | |
| 133 | + <Button type="submit" variant="secondary"> | |
| 134 | + 검색 | |
| 135 | + </Button> | |
| 136 | + <Link | |
| 137 | + href={resetHref} | |
| 138 | + className="text-body-md text-foreground-muted underline-offset-2 hover:underline" | |
| 139 | + > | |
| 140 | + 초기화 | |
| 141 | + </Link> | |
| 142 | + </form> | |
| 143 | + ); | |
| 144 | +} |
+++ app/(protected)/(basic)/boards/_components/board-list-toolbar.tsx
... | ... | @@ -0,0 +1,78 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useRouter } from 'next/navigation'; | |
| 4 | +import { useState, type ChangeEvent } from 'react'; | |
| 5 | +import { Button } from '@/components/ui/button'; | |
| 6 | +import { Select } from '@/components/ui/select'; | |
| 7 | +import type { BoardType } from '@/lib/domain/board-post'; | |
| 8 | +import { | |
| 9 | + BOARD_PAGE_SIZE_OPTIONS, | |
| 10 | + buildBoardPostHref, | |
| 11 | + type BoardPageSize, | |
| 12 | + type BoardPostQuery, | |
| 13 | +} from '@/lib/domain/board-post-query'; | |
| 14 | +import { BoardPostFormModal } from './board-post-form-modal'; | |
| 15 | + | |
| 16 | +interface BoardListToolbarProps { | |
| 17 | + boardType: BoardType; | |
| 18 | + query: BoardPostQuery; | |
| 19 | + /** 1:1문의는 관리자가 글을 등록하지 않으므로 false다(사용자 확인 사항). */ | |
| 20 | + canCreate: boolean; | |
| 21 | +} | |
| 22 | + | |
| 23 | +/** | |
| 24 | + * 목록 툴바 — [신규 등록]과 페이지 크기 select. 페이지 크기가 바뀌면 1페이지로 되돌린다 | |
| 25 | + * (기존 페이지 번호가 새 크기 기준으로는 의미가 달라지므로). | |
| 26 | + * | |
| 27 | + * 시안의 "최근등록순" 정렬 select는 두지 않았다 — 백엔드 목록 SQL의 정렬이 최신순으로 고정돼 | |
| 28 | + * 있고 시안의 select에도 다른 선택지가 없어, 선택지가 하나뿐인 컨트롤이 되기 때문이다. | |
| 29 | + * 정렬 기준이 추가되면 그때 select를 붙인다. | |
| 30 | + */ | |
| 31 | +export function BoardListToolbar({ | |
| 32 | + boardType, | |
| 33 | + query, | |
| 34 | + canCreate, | |
| 35 | +}: BoardListToolbarProps) { | |
| 36 | + const router = useRouter(); | |
| 37 | + const [isCreateOpen, setIsCreateOpen] = useState(false); | |
| 38 | + | |
| 39 | + function handlePageSizeChange(event: ChangeEvent<HTMLSelectElement>) { | |
| 40 | + const pageSize = Number(event.target.value) as BoardPageSize; | |
| 41 | + router.replace(buildBoardPostHref(boardType, query, { pageSize, page: 1 })); | |
| 42 | + } | |
| 43 | + | |
| 44 | + return ( | |
| 45 | + <div className="flex flex-wrap items-center justify-end gap-3"> | |
| 46 | + {canCreate && ( | |
| 47 | + <Button | |
| 48 | + type="button" | |
| 49 | + variant="primary" | |
| 50 | + onClick={() => setIsCreateOpen(true)} | |
| 51 | + > | |
| 52 | + + 신규 등록 | |
| 53 | + </Button> | |
| 54 | + )} | |
| 55 | + | |
| 56 | + <Select | |
| 57 | + name="pageSize" | |
| 58 | + aria-label="페이지 크기" | |
| 59 | + defaultValue={query.pageSize} | |
| 60 | + onChange={handlePageSizeChange} | |
| 61 | + > | |
| 62 | + {BOARD_PAGE_SIZE_OPTIONS.map((size) => ( | |
| 63 | + <option key={size} value={size}> | |
| 64 | + {size}개씩 | |
| 65 | + </option> | |
| 66 | + ))} | |
| 67 | + </Select> | |
| 68 | + | |
| 69 | + {isCreateOpen && ( | |
| 70 | + <BoardPostFormModal | |
| 71 | + boardType={boardType} | |
| 72 | + mode="create" | |
| 73 | + onClose={() => setIsCreateOpen(false)} | |
| 74 | + /> | |
| 75 | + )} | |
| 76 | + </div> | |
| 77 | + ); | |
| 78 | +} |
+++ app/(protected)/(basic)/boards/_components/board-post-form-modal.tsx
... | ... | @@ -0,0 +1,258 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useActionState, useEffect } from 'react'; | |
| 4 | +import { Button } from '@/components/ui/button'; | |
| 5 | +import { Field } from '@/components/ui/field'; | |
| 6 | +import { Input } from '@/components/ui/input'; | |
| 7 | +import { Modal } from '@/components/ui/modal'; | |
| 8 | +import { RadioGroup } from '@/components/ui/radio-group'; | |
| 9 | +import { Select } from '@/components/ui/select'; | |
| 10 | +import { useFeedback } from '@/app/_hooks/use-feedback'; | |
| 11 | +import { | |
| 12 | + BOARD_CATEGORY_OPTIONS, | |
| 13 | + formatBoardCategoryLabel, | |
| 14 | + formatOptionalValue, | |
| 15 | + type BoardPost, | |
| 16 | + type BoardType, | |
| 17 | +} from '@/lib/domain/board-post'; | |
| 18 | +import { | |
| 19 | + CONTENT_MAX_LENGTH, | |
| 20 | + TITLE_MAX_LENGTH, | |
| 21 | +} from '@/lib/domain/board-post-form'; | |
| 22 | +import { BOARD_TYPE_OPTIONS } from '@/lib/domain/board-post-query'; | |
| 23 | +import { | |
| 24 | + INITIAL_BOARD_POST_FORM_STATE, | |
| 25 | + createBoardPostAction, | |
| 26 | + updateBoardPostAction, | |
| 27 | +} from '../_actions'; | |
| 28 | +import { BoardAttachmentField } from './board-attachment-field'; | |
| 29 | +import { BoardContentField } from './board-content-field'; | |
| 30 | + | |
| 31 | +interface BoardPostFormModalProps { | |
| 32 | + boardType: BoardType; | |
| 33 | + mode: 'create' | 'edit'; | |
| 34 | + /** 수정 모드의 기존 값. 등록 모드는 넘기지 않는다. */ | |
| 35 | + post?: BoardPost; | |
| 36 | + onClose: () => void; | |
| 37 | +} | |
| 38 | + | |
| 39 | +const FORM_ID = 'board-post-form'; | |
| 40 | + | |
| 41 | +const PINNED_OPTIONS = [ | |
| 42 | + { value: 'N', label: '사용안함' }, | |
| 43 | + { value: 'Y', label: '사용' }, | |
| 44 | +]; | |
| 45 | +const VISIBILITY_OPTIONS = [ | |
| 46 | + { value: 'Y', label: '노출' }, | |
| 47 | + { value: 'N', label: '미노출' }, | |
| 48 | +]; | |
| 49 | +const PUSH_OPTIONS = [ | |
| 50 | + { value: 'Y', label: '발송' }, | |
| 51 | + { value: 'N', label: '미발송' }, | |
| 52 | +]; | |
| 53 | + | |
| 54 | +const BOARD_TITLES: Record<BoardType, string> = { | |
| 55 | + notice: '공지사항', | |
| 56 | + inquiry: '1:1문의', | |
| 57 | + faq: 'FAQ', | |
| 58 | +}; | |
| 59 | + | |
| 60 | +/** | |
| 61 | + * 공지사항·FAQ의 등록/수정 팝업 — 시안 A_BOA_007_p·008_p(공지사항), 015_p·016_p(FAQ). | |
| 62 | + * | |
| 63 | + * 네 시안이 같은 폼의 변형이라 한 컴포넌트가 게시판 종류(`boardType`)와 모드(`mode`)로 분기한다. | |
| 64 | + * 게시판별 차이는 두 가지뿐이다 — 공지사항에만 상단고정·노출기간·앱푸쉬가, FAQ에만 유형이 있다. | |
| 65 | + * | |
| 66 | + * **수정 시 구분은 읽기 전용이다**(시안 ①). 값 자체를 제출하지 않고, Server Action도 수정 | |
| 67 | + * 경로에서는 구분을 검증하지 않는다 — 직접 POST로 다른 값을 보내도 반영되지 않는다. | |
| 68 | + * | |
| 69 | + * 첨부파일이 있어 폼은 multipart로 제출된다(Server Action은 이를 자동으로 처리한다). | |
| 70 | + * 저장 버튼은 footer에서 `form={FORM_ID}`로 연결한다 — 네이티브 HTML의 `form` 속성은 버튼이 | |
| 71 | + * DOM상 form의 자손이 아니어도 같은 문서 안에서 id만 일치하면 그 form을 제출한다. | |
| 72 | + */ | |
| 73 | +export function BoardPostFormModal({ | |
| 74 | + boardType, | |
| 75 | + mode, | |
| 76 | + post, | |
| 77 | + onClose, | |
| 78 | +}: BoardPostFormModalProps) { | |
| 79 | + const { showToast } = useFeedback(); | |
| 80 | + const isCreate = mode === 'create'; | |
| 81 | + | |
| 82 | + const [state, formAction, isPending] = useActionState( | |
| 83 | + isCreate ? createBoardPostAction : updateBoardPostAction, | |
| 84 | + INITIAL_BOARD_POST_FORM_STATE | |
| 85 | + ); | |
| 86 | + | |
| 87 | + useEffect(() => { | |
| 88 | + if (state.status === 'success') { | |
| 89 | + showToast({ | |
| 90 | + variant: 'success', | |
| 91 | + message: isCreate ? '게시물을 등록했습니다.' : '게시물을 수정했습니다.', | |
| 92 | + }); | |
| 93 | + onClose(); | |
| 94 | + } | |
| 95 | + }, [state, showToast, onClose, isCreate]); | |
| 96 | + | |
| 97 | + const errors = state.status === 'error' ? (state.errors ?? {}) : {}; | |
| 98 | + const typeOptions = BOARD_TYPE_OPTIONS[boardType]; | |
| 99 | + const hasNoticeOnlyFields = boardType === 'notice'; | |
| 100 | + | |
| 101 | + return ( | |
| 102 | + <Modal | |
| 103 | + title={`${BOARD_TITLES[boardType]} ${isCreate ? '신규 등록' : '수정'}`} | |
| 104 | + onClose={onClose} | |
| 105 | + footer={ | |
| 106 | + <> | |
| 107 | + <Button type="button" variant="ghost" onClick={onClose}> | |
| 108 | + 취소 | |
| 109 | + </Button> | |
| 110 | + <Button | |
| 111 | + type="submit" | |
| 112 | + form={FORM_ID} | |
| 113 | + variant="primary" | |
| 114 | + disabled={isPending} | |
| 115 | + > | |
| 116 | + {isPending ? '저장 중...' : isCreate ? '등록' : '수정'} | |
| 117 | + </Button> | |
| 118 | + </> | |
| 119 | + } | |
| 120 | + > | |
| 121 | + <form id={FORM_ID} action={formAction} className="flex flex-col gap-4"> | |
| 122 | + <input type="hidden" name="boardType" value={boardType} /> | |
| 123 | + {!isCreate && post && <input type="hidden" name="id" value={post.id} />} | |
| 124 | + | |
| 125 | + <p className="text-right text-body-sm text-danger"> | |
| 126 | + * 는 필수 항목입니다. | |
| 127 | + </p> | |
| 128 | + | |
| 129 | + <Field label={isCreate ? '구분 *' : '구분'}> | |
| 130 | + {isCreate ? ( | |
| 131 | + <Select name="categoryCode" defaultValue={BOARD_CATEGORY_OPTIONS[0].value}> | |
| 132 | + {BOARD_CATEGORY_OPTIONS.map((option) => ( | |
| 133 | + <option key={option.value} value={option.value}> | |
| 134 | + {option.label} | |
| 135 | + </option> | |
| 136 | + ))} | |
| 137 | + </Select> | |
| 138 | + ) : ( | |
| 139 | + <Input | |
| 140 | + value={formatBoardCategoryLabel(post?.categoryCode ?? null)} | |
| 141 | + readOnly | |
| 142 | + /> | |
| 143 | + )} | |
| 144 | + </Field> | |
| 145 | + <FieldError message={errors.categoryCode} /> | |
| 146 | + | |
| 147 | + {typeOptions.length > 0 && ( | |
| 148 | + <> | |
| 149 | + <Field label="유형 *"> | |
| 150 | + <Select name="typeCode" defaultValue={post?.typeCode ?? ''}> | |
| 151 | + <option value="">선택</option> | |
| 152 | + {typeOptions.map((option) => ( | |
| 153 | + <option key={option.value} value={option.value}> | |
| 154 | + {option.label} | |
| 155 | + </option> | |
| 156 | + ))} | |
| 157 | + </Select> | |
| 158 | + </Field> | |
| 159 | + <FieldError message={errors.typeCode} /> | |
| 160 | + </> | |
| 161 | + )} | |
| 162 | + | |
| 163 | + <Field label="제목 *"> | |
| 164 | + <Input | |
| 165 | + type="text" | |
| 166 | + name="title" | |
| 167 | + defaultValue={post?.title ?? ''} | |
| 168 | + maxLength={TITLE_MAX_LENGTH} | |
| 169 | + placeholder="제목을 입력하세요." | |
| 170 | + /> | |
| 171 | + </Field> | |
| 172 | + <FieldError message={errors.title} /> | |
| 173 | + | |
| 174 | + <BoardContentField | |
| 175 | + name="content" | |
| 176 | + label="내용 *" | |
| 177 | + defaultValue={post?.content ?? ''} | |
| 178 | + maxLength={CONTENT_MAX_LENGTH} | |
| 179 | + placeholder="내용을 입력하세요." | |
| 180 | + /> | |
| 181 | + <FieldError message={errors.content} /> | |
| 182 | + | |
| 183 | + <BoardAttachmentField attachmentId={post?.attachmentId} /> | |
| 184 | + | |
| 185 | + {hasNoticeOnlyFields && ( | |
| 186 | + <> | |
| 187 | + <Field label="상단고정"> | |
| 188 | + <RadioGroup | |
| 189 | + name="isPinned" | |
| 190 | + options={PINNED_OPTIONS} | |
| 191 | + defaultValue={post?.isPinned ? 'Y' : 'N'} | |
| 192 | + /> | |
| 193 | + </Field> | |
| 194 | + | |
| 195 | + <Field label="노출기간"> | |
| 196 | + <div className="flex items-center gap-2"> | |
| 197 | + <Input | |
| 198 | + type="date" | |
| 199 | + name="startDate" | |
| 200 | + defaultValue={post?.startDate ?? ''} | |
| 201 | + aria-label="게시시작일시" | |
| 202 | + /> | |
| 203 | + <span className="text-foreground-muted">~</span> | |
| 204 | + <Input | |
| 205 | + type="date" | |
| 206 | + name="endDate" | |
| 207 | + defaultValue={post?.endDate ?? ''} | |
| 208 | + aria-label="게시종료일시" | |
| 209 | + /> | |
| 210 | + </div> | |
| 211 | + </Field> | |
| 212 | + <FieldError message={errors.startDate} /> | |
| 213 | + <FieldError message={errors.endDate} /> | |
| 214 | + </> | |
| 215 | + )} | |
| 216 | + | |
| 217 | + <Field label="사용여부 *"> | |
| 218 | + <RadioGroup | |
| 219 | + name="isVisible" | |
| 220 | + options={VISIBILITY_OPTIONS} | |
| 221 | + defaultValue={post?.isVisible === false ? 'N' : 'Y'} | |
| 222 | + /> | |
| 223 | + </Field> | |
| 224 | + | |
| 225 | + {hasNoticeOnlyFields && ( | |
| 226 | + <Field label="앱푸쉬 설정 *"> | |
| 227 | + <RadioGroup | |
| 228 | + name="isPushEnabled" | |
| 229 | + options={PUSH_OPTIONS} | |
| 230 | + defaultValue={post?.isPushEnabled === false ? 'N' : 'Y'} | |
| 231 | + /> | |
| 232 | + </Field> | |
| 233 | + )} | |
| 234 | + | |
| 235 | + {!isCreate && post && ( | |
| 236 | + <Field label="작성자 / 작성일"> | |
| 237 | + <div className="flex items-center gap-2"> | |
| 238 | + <Input value={formatOptionalValue(post.authorName)} readOnly /> | |
| 239 | + <Input value={formatOptionalValue(post.createdAt)} readOnly /> | |
| 240 | + </div> | |
| 241 | + </Field> | |
| 242 | + )} | |
| 243 | + | |
| 244 | + {state.status === 'error' && state.message && ( | |
| 245 | + <p className="text-body-sm text-danger">{state.message}</p> | |
| 246 | + )} | |
| 247 | + </form> | |
| 248 | + </Modal> | |
| 249 | + ); | |
| 250 | +} | |
| 251 | + | |
| 252 | +/** 필드 하단 오류 문구. 값이 없으면 아무것도 그리지 않아 레이아웃을 차지하지 않는다. */ | |
| 253 | +export function FieldError({ message }: { message?: string }) { | |
| 254 | + if (!message) { | |
| 255 | + return null; | |
| 256 | + } | |
| 257 | + return <p className="text-body-sm text-danger">{message}</p>; | |
| 258 | +} |
+++ app/(protected)/(basic)/boards/_components/board-row-actions.tsx
... | ... | @@ -0,0 +1,94 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useState, useTransition } from 'react'; | |
| 4 | +import { Button } from '@/components/ui/button'; | |
| 5 | +import { useFeedback } from '@/app/_hooks/use-feedback'; | |
| 6 | +import type { BoardPost, BoardType } from '@/lib/domain/board-post'; | |
| 7 | +import { deleteBoardPostAction } from '../_actions'; | |
| 8 | +import { BoardPostFormModal } from './board-post-form-modal'; | |
| 9 | + | |
| 10 | +interface BoardRowActionsProps { | |
| 11 | + boardType: BoardType; | |
| 12 | + post: BoardPost; | |
| 13 | +} | |
| 14 | + | |
| 15 | +/** | |
| 16 | + * 목록 행의 "관리" 셀(시안 A_BOA_006 ④ / 014 ④) — 수정 팝업의 열림 상태만 소유하는 최말단 | |
| 17 | + * 상호작용 경계다(테이블 전체를 클라이언트로 내리지 않기 위해 이 셀만 분리했다). | |
| 18 | + * | |
| 19 | + * 삭제는 시안대로 **확인 얼럿을 거친 뒤** 실행한다. 백엔드는 물리 삭제가 아니라 `DEL_YN='Y'`로 | |
| 20 | + * 표시만 바꾸지만, 관리자 화면에서 다시 볼 방법이 없으므로 사용자에게는 되돌릴 수 없는 동작으로 | |
| 21 | + * 안내한다. | |
| 22 | + * | |
| 23 | + * 시안은 연필·휴지통 아이콘 버튼을 지정하지만 `components/ui/icons/`에 두 아이콘이 아직 없어 | |
| 24 | + * (신규 아이콘 추가는 design 레인 소관) 텍스트 버튼으로 대체했다 — 관리자 회원 목록과 같은 처리다. | |
| 25 | + */ | |
| 26 | +export function BoardRowActions({ boardType, post }: BoardRowActionsProps) { | |
| 27 | + const { showAlert, hideAlert, showToast } = useFeedback(); | |
| 28 | + const [isEditOpen, setIsEditOpen] = useState(false); | |
| 29 | + const [isDeleting, startDeleting] = useTransition(); | |
| 30 | + | |
| 31 | + function runDelete() { | |
| 32 | + hideAlert(); | |
| 33 | + startDeleting(async () => { | |
| 34 | + const result = await deleteBoardPostAction(boardType, post.id); | |
| 35 | + if (result.status === 'error') { | |
| 36 | + showToast({ | |
| 37 | + variant: 'danger', | |
| 38 | + message: result.message ?? '삭제하지 못했습니다.', | |
| 39 | + }); | |
| 40 | + return; | |
| 41 | + } | |
| 42 | + showToast({ variant: 'success', message: '게시물을 삭제했습니다.' }); | |
| 43 | + }); | |
| 44 | + } | |
| 45 | + | |
| 46 | + function confirmDelete() { | |
| 47 | + showAlert({ | |
| 48 | + variant: 'danger', | |
| 49 | + title: '게시물을 삭제하시겠습니까?', | |
| 50 | + message: `"${post.title}" 게시물을 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`, | |
| 51 | + actions: ( | |
| 52 | + <> | |
| 53 | + <Button type="button" variant="ghost" onClick={hideAlert}> | |
| 54 | + 취소 | |
| 55 | + </Button> | |
| 56 | + <Button type="button" variant="primary" onClick={runDelete}> | |
| 57 | + 삭제 | |
| 58 | + </Button> | |
| 59 | + </> | |
| 60 | + ), | |
| 61 | + }); | |
| 62 | + } | |
| 63 | + | |
| 64 | + return ( | |
| 65 | + <div className="flex items-center gap-1"> | |
| 66 | + <Button | |
| 67 | + type="button" | |
| 68 | + variant="ghost" | |
| 69 | + size="sm" | |
| 70 | + onClick={() => setIsEditOpen(true)} | |
| 71 | + > | |
| 72 | + 수정 | |
| 73 | + </Button> | |
| 74 | + <Button | |
| 75 | + type="button" | |
| 76 | + variant="ghost" | |
| 77 | + size="sm" | |
| 78 | + onClick={confirmDelete} | |
| 79 | + disabled={isDeleting} | |
| 80 | + > | |
| 81 | + {isDeleting ? '삭제 중...' : '삭제'} | |
| 82 | + </Button> | |
| 83 | + | |
| 84 | + {isEditOpen && ( | |
| 85 | + <BoardPostFormModal | |
| 86 | + boardType={boardType} | |
| 87 | + mode="edit" | |
| 88 | + post={post} | |
| 89 | + onClose={() => setIsEditOpen(false)} | |
| 90 | + /> | |
| 91 | + )} | |
| 92 | + </div> | |
| 93 | + ); | |
| 94 | +} |
+++ app/(protected)/(basic)/boards/attachments/route.ts
... | ... | @@ -0,0 +1,64 @@ |
| 1 | +import { getSessionAccessToken, verifySession } from '@/lib/auth/dal'; | |
| 2 | +import { backendFetchStream } from '@/lib/http/backend-fetch'; | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * 게시판 첨부파일 다운로드 — 백엔드 파일을 브라우저로 중계한다. | |
| 6 | + * | |
| 7 | + * 왜 라우트 핸들러인가: 브라우저는 백엔드를 직접 호출하지 않는다(설계서 §7 BFF 전제). 백엔드 파일 | |
| 8 | + * API는 토큰을 요구하는데 그 토큰은 httpOnly 세션 안에만 있어 브라우저가 꺼낼 수 없다. 학생 회원 | |
| 9 | + * 엑셀 다운로드와 같은 구조다. | |
| 10 | + * | |
| 11 | + * 본문은 파싱하지 않고 업스트림 스트림을 그대로 흘려보낸다(서버 메모리에 파일 전체를 올리지 | |
| 12 | + * 않는다). 파일명·MIME도 백엔드 헤더를 그대로 전달한다. | |
| 13 | + */ | |
| 14 | + | |
| 15 | +const FILE_DOWNLOAD_PATH = '/api/v1/common/file/download'; | |
| 16 | +const DOWNLOAD_FAILED_MESSAGE = | |
| 17 | + '첨부파일을 내려받지 못했습니다. 잠시 후 다시 시도해 주세요.'; | |
| 18 | +const DOWNLOAD_TIMEOUT_MS = 60_000; | |
| 19 | + | |
| 20 | +export async function GET(request: Request) { | |
| 21 | + // 라우트 핸들러는 UI를 거치지 않고 직접 호출될 수 있으므로 여기서 직접 인증을 확인한다. | |
| 22 | + await verifySession(); | |
| 23 | + | |
| 24 | + const url = new URL(request.url); | |
| 25 | + const attachmentId = url.searchParams.get('atchFileId'); | |
| 26 | + if (!attachmentId) { | |
| 27 | + return new Response('첨부파일 식별자가 없습니다.', { | |
| 28 | + status: 400, | |
| 29 | + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, | |
| 30 | + }); | |
| 31 | + } | |
| 32 | + | |
| 33 | + // 백엔드는 파일 일련번호(fileSn)로 개별 파일을 지목한다. 게시판은 파일 하나만 붙이므로 1이 | |
| 34 | + // 기본이며, 여러 개를 붙이게 되면 호출부가 번호를 넘긴다. | |
| 35 | + const fileSn = url.searchParams.get('fileSn') ?? '1'; | |
| 36 | + | |
| 37 | + const accessToken = await getSessionAccessToken(); | |
| 38 | + | |
| 39 | + const result = await backendFetchStream(FILE_DOWNLOAD_PATH, { | |
| 40 | + query: { atchFileId: attachmentId, fileSn }, | |
| 41 | + accessToken: accessToken ?? undefined, | |
| 42 | + timeoutMs: DOWNLOAD_TIMEOUT_MS, | |
| 43 | + }); | |
| 44 | + | |
| 45 | + if (!result.ok) { | |
| 46 | + // 실패 사유는 backendFetchStream이 서버 콘솔에 남긴다. 화면에는 일반화된 문구만 내보낸다. | |
| 47 | + return new Response(DOWNLOAD_FAILED_MESSAGE, { | |
| 48 | + status: 502, | |
| 49 | + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, | |
| 50 | + }); | |
| 51 | + } | |
| 52 | + | |
| 53 | + const upstream = result.data; | |
| 54 | + | |
| 55 | + return new Response(upstream.body, { | |
| 56 | + status: 200, | |
| 57 | + headers: { | |
| 58 | + 'Content-Type': | |
| 59 | + upstream.headers.get('content-type') ?? 'application/octet-stream', | |
| 60 | + 'Content-Disposition': | |
| 61 | + upstream.headers.get('content-disposition') ?? 'attachment', | |
| 62 | + }, | |
| 63 | + }); | |
| 64 | +} |
+++ app/(protected)/(basic)/boards/error.tsx
... | ... | @@ -0,0 +1,32 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useEffect } from 'react'; | |
| 4 | + | |
| 5 | +interface ErrorPageProps { | |
| 6 | + error: Error & { digest?: string }; | |
| 7 | + unstable_retry: () => void; | |
| 8 | +} | |
| 9 | + | |
| 10 | +// 게시판 세 화면이 공유하는 에러 경계. 원본 메시지·스택은 화면에 내지 않는다 — 개발자 진단용으로만 | |
| 11 | +// 콘솔에 남기고(프로덕션에서는 Next.js가 Server Component 에러를 이미 digest 포함 일반화 메시지로 | |
| 12 | +// 치환해 전달한다), 사용자에게는 일반화된 문구 + 재시도만 제공한다. Next.js 16.2 문서 권고에 따라 | |
| 13 | +// 세그먼트 재조회까지 수행하는 `unstable_retry()`를 `reset()` 대신 사용한다. | |
| 14 | +export default function ErrorPage({ error, unstable_retry }: ErrorPageProps) { | |
| 15 | + useEffect(() => { | |
| 16 | + console.error(error); | |
| 17 | + }, [error]); | |
| 18 | + | |
| 19 | + return ( | |
| 20 | + <section className="flex flex-1 flex-col items-center justify-center gap-2 p-8"> | |
| 21 | + <h1 className="text-lg text-foreground">문제가 발생했습니다.</h1> | |
| 22 | + <p className="text-sm text-foreground/60">잠시 후 다시 시도해 주세요.</p> | |
| 23 | + <button | |
| 24 | + type="button" | |
| 25 | + onClick={() => unstable_retry()} | |
| 26 | + className="mt-4 bg-foreground px-4 py-2 text-background" | |
| 27 | + > | |
| 28 | + 다시 시도 | |
| 29 | + </button> | |
| 30 | + </section> | |
| 31 | + ); | |
| 32 | +} |
+++ app/(protected)/(basic)/boards/faqs/_components/faq-table.tsx
... | ... | @@ -0,0 +1,73 @@ |
| 1 | +import { | |
| 2 | + Table, | |
| 3 | + TableBody, | |
| 4 | + TableCell, | |
| 5 | + TableHead, | |
| 6 | + TableHeaderCell, | |
| 7 | + TableRow, | |
| 8 | +} from '@/components/ui/table'; | |
| 9 | +import { | |
| 10 | + formatBoardCategoryLabel, | |
| 11 | + formatFaqTypeLabel, | |
| 12 | + formatOptionalValue, | |
| 13 | + formatVisibilityLabel, | |
| 14 | + type BoardPost, | |
| 15 | +} from '@/lib/domain/board-post'; | |
| 16 | +import { BoardRowActions } from '../../_components/board-row-actions'; | |
| 17 | + | |
| 18 | +interface FaqTableProps { | |
| 19 | + items: BoardPost[]; | |
| 20 | + page: number; | |
| 21 | + pageSize: number; | |
| 22 | + totalCount: number; | |
| 23 | +} | |
| 24 | + | |
| 25 | +const COLUMN_HEADERS = [ | |
| 26 | + '번호', | |
| 27 | + '구분', | |
| 28 | + '유형', | |
| 29 | + '제목', | |
| 30 | + '작성자', | |
| 31 | + '등록일', | |
| 32 | + '사용여부', | |
| 33 | + '관리', | |
| 34 | +]; | |
| 35 | + | |
| 36 | +/** | |
| 37 | + * FAQ 목록 테이블 — 시안 A_BOA_014 ③. | |
| 38 | + * | |
| 39 | + * "유형"은 현재 항상 `-`다 — 백엔드에 유형 저장 필드가 아직 없어 조회 응답에 값이 없다 | |
| 40 | + * (`board-post.ts`의 `BOARD_TYPE_CODE_PARAM` 주석). 열은 유지해 두었으므로 백엔드에 필드가 | |
| 41 | + * 추가되면 Repository 매핑만 늘리면 그대로 채워진다. | |
| 42 | + */ | |
| 43 | +export function FaqTable({ items, page, pageSize, totalCount }: FaqTableProps) { | |
| 44 | + const offset = (page - 1) * pageSize; | |
| 45 | + | |
| 46 | + return ( | |
| 47 | + <Table> | |
| 48 | + <TableHead> | |
| 49 | + <TableRow> | |
| 50 | + {COLUMN_HEADERS.map((header) => ( | |
| 51 | + <TableHeaderCell key={header}>{header}</TableHeaderCell> | |
| 52 | + ))} | |
| 53 | + </TableRow> | |
| 54 | + </TableHead> | |
| 55 | + <TableBody> | |
| 56 | + {items.map((post, index) => ( | |
| 57 | + <TableRow key={post.id}> | |
| 58 | + <TableCell>{totalCount - offset - index}</TableCell> | |
| 59 | + <TableCell>{formatBoardCategoryLabel(post.categoryCode)}</TableCell> | |
| 60 | + <TableCell>{formatFaqTypeLabel(post.typeCode)}</TableCell> | |
| 61 | + <TableCell>{formatOptionalValue(post.title)}</TableCell> | |
| 62 | + <TableCell>{formatOptionalValue(post.authorName)}</TableCell> | |
| 63 | + <TableCell>{formatOptionalValue(post.createdAt)}</TableCell> | |
| 64 | + <TableCell>{formatVisibilityLabel(post.isVisible)}</TableCell> | |
| 65 | + <TableCell> | |
| 66 | + <BoardRowActions boardType="faq" post={post} /> | |
| 67 | + </TableCell> | |
| 68 | + </TableRow> | |
| 69 | + ))} | |
| 70 | + </TableBody> | |
| 71 | + </Table> | |
| 72 | + ); | |
| 73 | +} |
+++ app/(protected)/(basic)/boards/faqs/page.tsx
... | ... | @@ -0,0 +1,78 @@ |
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { Alert } from '@/components/ui/alert'; | |
| 4 | +import { Pagination } from '@/components/ui/pagination'; | |
| 5 | +import { verifySession } from '@/lib/auth/dal'; | |
| 6 | +import { fetchBoardPosts } from '@/lib/data/repositories/board-post-repository'; | |
| 7 | +import { | |
| 8 | + buildBoardPostHref, | |
| 9 | + parseBoardPostQuery, | |
| 10 | +} from '@/lib/domain/board-post-query'; | |
| 11 | +import { BoardFilterBar } from '../_components/board-filter-bar'; | |
| 12 | +import { BoardListToolbar } from '../_components/board-list-toolbar'; | |
| 13 | +import { FaqTable } from './_components/faq-table'; | |
| 14 | + | |
| 15 | +export const metadata: Metadata = { | |
| 16 | + title: 'FAQ', | |
| 17 | +}; | |
| 18 | + | |
| 19 | +interface PageProps { | |
| 20 | + searchParams: Promise<Record<string, string | string[] | undefined>>; | |
| 21 | +} | |
| 22 | + | |
| 23 | +/** FAQ 목록 — 시안 A_BOA_014. 구조는 공지사항과 같고 컬럼·필터만 다르다. */ | |
| 24 | +export default async function Page({ searchParams }: PageProps) { | |
| 25 | + await verifySession(); | |
| 26 | + | |
| 27 | + const query = parseBoardPostQuery('faq', await searchParams); | |
| 28 | + const { items, totalCount } = await fetchBoardPosts('faq', query); | |
| 29 | + | |
| 30 | + const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize)); | |
| 31 | + const currentPage = Math.min(query.page, totalPages); | |
| 32 | + | |
| 33 | + return ( | |
| 34 | + <section className="flex flex-1 flex-col gap-6 p-8"> | |
| 35 | + <h1 className="text-headline-sm text-foreground">FAQ 목록</h1> | |
| 36 | + | |
| 37 | + <BoardFilterBar boardType="faq" query={query} /> | |
| 38 | + | |
| 39 | + <BoardListToolbar boardType="faq" query={query} canCreate /> | |
| 40 | + | |
| 41 | + <p className="text-body-md text-foreground-muted"> | |
| 42 | + 총 {totalCount}개 | 현재페이지 {currentPage}/{totalPages} | |
| 43 | + </p> | |
| 44 | + | |
| 45 | + {items.length === 0 ? ( | |
| 46 | + <Alert | |
| 47 | + title="검색 결과가 없습니다." | |
| 48 | + actions={ | |
| 49 | + <Link | |
| 50 | + href={buildBoardPostHref('faq', query, { keyword: '', page: 1 })} | |
| 51 | + className="bg-foreground px-4 py-2 text-background" | |
| 52 | + > | |
| 53 | + 확인 | |
| 54 | + </Link> | |
| 55 | + } | |
| 56 | + > | |
| 57 | + 다른 검색어로 다시 시도해 주세요. | |
| 58 | + </Alert> | |
| 59 | + ) : ( | |
| 60 | + <> | |
| 61 | + <FaqTable | |
| 62 | + items={items} | |
| 63 | + page={currentPage} | |
| 64 | + pageSize={query.pageSize} | |
| 65 | + totalCount={totalCount} | |
| 66 | + /> | |
| 67 | + <Pagination | |
| 68 | + currentPage={currentPage} | |
| 69 | + totalPages={totalPages} | |
| 70 | + buildHref={(page: number) => | |
| 71 | + buildBoardPostHref('faq', query, { page }) | |
| 72 | + } | |
| 73 | + /> | |
| 74 | + </> | |
| 75 | + )} | |
| 76 | + </section> | |
| 77 | + ); | |
| 78 | +} |
+++ app/(protected)/(basic)/boards/inquiries/_components/inquiry-answer-modal.tsx
... | ... | @@ -0,0 +1,163 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useActionState, useEffect } from 'react'; | |
| 4 | +import { Button } from '@/components/ui/button'; | |
| 5 | +import { Field } from '@/components/ui/field'; | |
| 6 | +import { Input } from '@/components/ui/input'; | |
| 7 | +import { Modal } from '@/components/ui/modal'; | |
| 8 | +import { Select } from '@/components/ui/select'; | |
| 9 | +import { useFeedback } from '@/app/_hooks/use-feedback'; | |
| 10 | +import { | |
| 11 | + ANSWER_STATUS_OPTIONS, | |
| 12 | + formatInquiryTypeLabel, | |
| 13 | + formatOptionalValue, | |
| 14 | + type BoardPost, | |
| 15 | +} from '@/lib/domain/board-post'; | |
| 16 | +import { ANSWER_MAX_LENGTH } from '@/lib/domain/board-post-form'; | |
| 17 | +import { | |
| 18 | + INITIAL_INQUIRY_ANSWER_FORM_STATE, | |
| 19 | + saveInquiryAnswerAction, | |
| 20 | +} from '../../_actions'; | |
| 21 | +import { BoardAttachmentField } from '../../_components/board-attachment-field'; | |
| 22 | +import { BoardContentField } from '../../_components/board-content-field'; | |
| 23 | +import { FieldError } from '../../_components/board-post-form-modal'; | |
| 24 | + | |
| 25 | +interface InquiryAnswerModalProps { | |
| 26 | + post: BoardPost; | |
| 27 | + onClose: () => void; | |
| 28 | +} | |
| 29 | + | |
| 30 | +const FORM_ID = 'inquiry-answer-form'; | |
| 31 | + | |
| 32 | +/** | |
| 33 | + * 1:1문의 상세 및 답변 팝업 — 시안 A_BOA_013_p. | |
| 34 | + * | |
| 35 | + * 질문자가 작성한 항목(질문유형·제목·질문자명·작성일·이메일·전화번호·질문내용)은 **전부 읽기 | |
| 36 | + * 전용**이고, 관리자가 바꾸는 것은 진행상태·답변내용·답변첨부 셋뿐이다. 그래서 읽기 전용 값은 | |
| 37 | + * 폼에 싣지 않는다 — Server Action도 그 값들을 읽지 않으므로 위조해 보내도 반영되지 않는다. | |
| 38 | + * | |
| 39 | + * 전화번호는 **마스킹하지 않고 원본을 보여준다** — 목록에서는 마스킹하지만(시안 A_BOA_012), | |
| 40 | + * 상세는 답변을 위해 연락처를 확인하는 화면이라 시안에도 원본(`010-1234-5678`)으로 표기돼 있다. | |
| 41 | + * | |
| 42 | + * 첨부는 **답변 첨부만** 다룬다(사용자 확인 사항). 백엔드 필드가 `atchFileId` 하나뿐이라 질문 | |
| 43 | + * 첨부와 공유되며, 시안에도 "질문 첨부파일 → 일단 삭제" 메모가 있다. | |
| 44 | + * | |
| 45 | + * 시안 메모의 "답변완료 시 사용자에게 앱푸쉬 전송"은 **백엔드 미구현**이다 — 게시판 서비스의 푸시 | |
| 46 | + * 분기가 빈 블록이라 프론트가 할 수 있는 일이 없다. 별도로 보고한 사항이다. | |
| 47 | + */ | |
| 48 | +export function InquiryAnswerModal({ post, onClose }: InquiryAnswerModalProps) { | |
| 49 | + const { showToast } = useFeedback(); | |
| 50 | + const [state, formAction, isPending] = useActionState( | |
| 51 | + saveInquiryAnswerAction, | |
| 52 | + INITIAL_INQUIRY_ANSWER_FORM_STATE | |
| 53 | + ); | |
| 54 | + | |
| 55 | + useEffect(() => { | |
| 56 | + if (state.status === 'success') { | |
| 57 | + showToast({ variant: 'success', message: '답변을 저장했습니다.' }); | |
| 58 | + onClose(); | |
| 59 | + } | |
| 60 | + }, [state, showToast, onClose]); | |
| 61 | + | |
| 62 | + const errors = state.status === 'error' ? (state.errors ?? {}) : {}; | |
| 63 | + | |
| 64 | + return ( | |
| 65 | + <Modal | |
| 66 | + title="1:1문의 상세 및 답변" | |
| 67 | + onClose={onClose} | |
| 68 | + footer={ | |
| 69 | + <> | |
| 70 | + <Button type="button" variant="ghost" onClick={onClose}> | |
| 71 | + 취소 | |
| 72 | + </Button> | |
| 73 | + <Button | |
| 74 | + type="submit" | |
| 75 | + form={FORM_ID} | |
| 76 | + variant="primary" | |
| 77 | + disabled={isPending} | |
| 78 | + > | |
| 79 | + {isPending ? '저장 중...' : '저장'} | |
| 80 | + </Button> | |
| 81 | + </> | |
| 82 | + } | |
| 83 | + > | |
| 84 | + <form id={FORM_ID} action={formAction} className="flex flex-col gap-4"> | |
| 85 | + <input type="hidden" name="id" value={post.id} /> | |
| 86 | + | |
| 87 | + <Field label="질문유형"> | |
| 88 | + <Input value={formatInquiryTypeLabel(post.typeCode)} readOnly /> | |
| 89 | + </Field> | |
| 90 | + | |
| 91 | + <p className="text-body-sm text-foreground-muted"> | |
| 92 | + 아래 질문 항목은 조회 전용입니다. | |
| 93 | + </p> | |
| 94 | + | |
| 95 | + <Field label="질문제목"> | |
| 96 | + <Input value={formatOptionalValue(post.title)} readOnly /> | |
| 97 | + </Field> | |
| 98 | + | |
| 99 | + <div className="flex gap-2"> | |
| 100 | + <Field label="질문자명" className="flex-1"> | |
| 101 | + <Input value={formatOptionalValue(post.authorName)} readOnly /> | |
| 102 | + </Field> | |
| 103 | + <Field label="작성일" className="flex-1"> | |
| 104 | + <Input value={formatOptionalValue(post.createdAt)} readOnly /> | |
| 105 | + </Field> | |
| 106 | + </div> | |
| 107 | + | |
| 108 | + <div className="flex gap-2"> | |
| 109 | + <Field label="이메일" className="flex-1"> | |
| 110 | + <Input value={formatOptionalValue(post.authorEmail)} readOnly /> | |
| 111 | + </Field> | |
| 112 | + <Field label="전화번호" className="flex-1"> | |
| 113 | + <Input value={formatOptionalValue(post.authorPhoneNumber)} readOnly /> | |
| 114 | + </Field> | |
| 115 | + </div> | |
| 116 | + | |
| 117 | + <Field label="질문내용"> | |
| 118 | + <textarea | |
| 119 | + value={post.content} | |
| 120 | + readOnly | |
| 121 | + rows={5} | |
| 122 | + className="w-full cursor-default rounded-md border border-border bg-surface-muted px-3 py-2 text-body-md text-foreground" | |
| 123 | + /> | |
| 124 | + </Field> | |
| 125 | + | |
| 126 | + <Field label="진행상태 *"> | |
| 127 | + <Select | |
| 128 | + name="answerStatusCode" | |
| 129 | + defaultValue={post.answerStatusCode ?? ANSWER_STATUS_OPTIONS[0].value} | |
| 130 | + > | |
| 131 | + {ANSWER_STATUS_OPTIONS.map((option) => ( | |
| 132 | + <option key={option.value} value={option.value}> | |
| 133 | + {option.label} | |
| 134 | + </option> | |
| 135 | + ))} | |
| 136 | + </Select> | |
| 137 | + </Field> | |
| 138 | + <p className="text-body-sm text-foreground-muted"> | |
| 139 | + 답변완료를 선택한 경우에만 사용자 화면에 답변 내용이 노출됩니다. | |
| 140 | + </p> | |
| 141 | + <FieldError message={errors.answerStatusCode} /> | |
| 142 | + | |
| 143 | + <BoardContentField | |
| 144 | + name="answerContent" | |
| 145 | + label="답변내용" | |
| 146 | + defaultValue={post.answerContent ?? ''} | |
| 147 | + maxLength={ANSWER_MAX_LENGTH} | |
| 148 | + placeholder="답변 내용을 입력하세요." | |
| 149 | + /> | |
| 150 | + <FieldError message={errors.answerContent} /> | |
| 151 | + | |
| 152 | + <BoardAttachmentField | |
| 153 | + label="답변 첨부파일" | |
| 154 | + attachmentId={post.attachmentId} | |
| 155 | + /> | |
| 156 | + | |
| 157 | + {state.status === 'error' && state.message && ( | |
| 158 | + <p className="text-body-sm text-danger">{state.message}</p> | |
| 159 | + )} | |
| 160 | + </form> | |
| 161 | + </Modal> | |
| 162 | + ); | |
| 163 | +} |
+++ app/(protected)/(basic)/boards/inquiries/_components/inquiry-row-actions.tsx
... | ... | @@ -0,0 +1,83 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useState, useTransition } from 'react'; | |
| 4 | +import { Button } from '@/components/ui/button'; | |
| 5 | +import { useFeedback } from '@/app/_hooks/use-feedback'; | |
| 6 | +import type { BoardPost } from '@/lib/domain/board-post'; | |
| 7 | +import { deleteBoardPostAction } from '../../_actions'; | |
| 8 | +import { InquiryAnswerModal } from './inquiry-answer-modal'; | |
| 9 | + | |
| 10 | +interface InquiryRowActionsProps { | |
| 11 | + post: BoardPost; | |
| 12 | +} | |
| 13 | + | |
| 14 | +/** | |
| 15 | + * 1:1문의 행의 "관리" 셀 — 시안 A_BOA_012 ③. | |
| 16 | + * | |
| 17 | + * 공지사항·FAQ의 `BoardRowActions`와 나눈 이유: 여기서 여는 팝업이 수정 폼이 아니라 **상세 및 답변** | |
| 18 | + * 팝업이고(관리자는 질문 글을 수정하지 않는다), 등록 경로도 없다. 삭제만 동작이 같다. | |
| 19 | + */ | |
| 20 | +export function InquiryRowActions({ post }: InquiryRowActionsProps) { | |
| 21 | + const { showAlert, hideAlert, showToast } = useFeedback(); | |
| 22 | + const [isAnswerOpen, setIsAnswerOpen] = useState(false); | |
| 23 | + const [isDeleting, startDeleting] = useTransition(); | |
| 24 | + | |
| 25 | + function runDelete() { | |
| 26 | + hideAlert(); | |
| 27 | + startDeleting(async () => { | |
| 28 | + const result = await deleteBoardPostAction('inquiry', post.id); | |
| 29 | + if (result.status === 'error') { | |
| 30 | + showToast({ | |
| 31 | + variant: 'danger', | |
| 32 | + message: result.message ?? '삭제하지 못했습니다.', | |
| 33 | + }); | |
| 34 | + return; | |
| 35 | + } | |
| 36 | + showToast({ variant: 'success', message: '문의를 삭제했습니다.' }); | |
| 37 | + }); | |
| 38 | + } | |
| 39 | + | |
| 40 | + function confirmDelete() { | |
| 41 | + showAlert({ | |
| 42 | + variant: 'danger', | |
| 43 | + title: '문의를 삭제하시겠습니까?', | |
| 44 | + message: `"${post.title}" 문의를 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`, | |
| 45 | + actions: ( | |
| 46 | + <> | |
| 47 | + <Button type="button" variant="ghost" onClick={hideAlert}> | |
| 48 | + 취소 | |
| 49 | + </Button> | |
| 50 | + <Button type="button" variant="primary" onClick={runDelete}> | |
| 51 | + 삭제 | |
| 52 | + </Button> | |
| 53 | + </> | |
| 54 | + ), | |
| 55 | + }); | |
| 56 | + } | |
| 57 | + | |
| 58 | + return ( | |
| 59 | + <div className="flex items-center gap-1"> | |
| 60 | + <Button | |
| 61 | + type="button" | |
| 62 | + variant="ghost" | |
| 63 | + size="sm" | |
| 64 | + onClick={() => setIsAnswerOpen(true)} | |
| 65 | + > | |
| 66 | + 답변 | |
| 67 | + </Button> | |
| 68 | + <Button | |
| 69 | + type="button" | |
| 70 | + variant="ghost" | |
| 71 | + size="sm" | |
| 72 | + onClick={confirmDelete} | |
| 73 | + disabled={isDeleting} | |
| 74 | + > | |
| 75 | + {isDeleting ? '삭제 중...' : '삭제'} | |
| 76 | + </Button> | |
| 77 | + | |
| 78 | + {isAnswerOpen && ( | |
| 79 | + <InquiryAnswerModal post={post} onClose={() => setIsAnswerOpen(false)} /> | |
| 80 | + )} | |
| 81 | + </div> | |
| 82 | + ); | |
| 83 | +} |
+++ app/(protected)/(basic)/boards/inquiries/_components/inquiry-table.tsx
... | ... | @@ -0,0 +1,84 @@ |
| 1 | +import { | |
| 2 | + Table, | |
| 3 | + TableBody, | |
| 4 | + TableCell, | |
| 5 | + TableHead, | |
| 6 | + TableHeaderCell, | |
| 7 | + TableRow, | |
| 8 | +} from '@/components/ui/table'; | |
| 9 | +import { | |
| 10 | + formatAnswerStatusLabel, | |
| 11 | + formatInquiryTypeLabel, | |
| 12 | + formatOptionalValue, | |
| 13 | + maskPhoneNumber, | |
| 14 | + type BoardPost, | |
| 15 | +} from '@/lib/domain/board-post'; | |
| 16 | +import { InquiryRowActions } from './inquiry-row-actions'; | |
| 17 | + | |
| 18 | +interface InquiryTableProps { | |
| 19 | + items: BoardPost[]; | |
| 20 | + page: number; | |
| 21 | + pageSize: number; | |
| 22 | + totalCount: number; | |
| 23 | +} | |
| 24 | + | |
| 25 | +const COLUMN_HEADERS = [ | |
| 26 | + '번호', | |
| 27 | + '질문유형', | |
| 28 | + '질문제목', | |
| 29 | + '질문자명', | |
| 30 | + '전화번호', | |
| 31 | + '질문일', | |
| 32 | + '상태', | |
| 33 | + '답변일', | |
| 34 | + '관리', | |
| 35 | +]; | |
| 36 | + | |
| 37 | +/** | |
| 38 | + * 1:1문의 목록 테이블 — 시안 A_BOA_012 ②. | |
| 39 | + * | |
| 40 | + * **전화번호는 마스킹해서 표시한다**(사용자 확인 사항 — 시안의 `010-12**-**78`). 상세 팝업에서는 | |
| 41 | + * 답변을 위해 연락처를 확인해야 하므로 원본을 보여준다. | |
| 42 | + * | |
| 43 | + * "답변일"은 답변이 실제로 달린 경우에만 값이 있고 그 외에는 `-`다(시안 ② "답변일은 답변완료 | |
| 44 | + * 시에만 표시"). | |
| 45 | + */ | |
| 46 | +export function InquiryTable({ | |
| 47 | + items, | |
| 48 | + page, | |
| 49 | + pageSize, | |
| 50 | + totalCount, | |
| 51 | +}: InquiryTableProps) { | |
| 52 | + const offset = (page - 1) * pageSize; | |
| 53 | + | |
| 54 | + return ( | |
| 55 | + <Table> | |
| 56 | + <TableHead> | |
| 57 | + <TableRow> | |
| 58 | + {COLUMN_HEADERS.map((header) => ( | |
| 59 | + <TableHeaderCell key={header}>{header}</TableHeaderCell> | |
| 60 | + ))} | |
| 61 | + </TableRow> | |
| 62 | + </TableHead> | |
| 63 | + <TableBody> | |
| 64 | + {items.map((post, index) => ( | |
| 65 | + <TableRow key={post.id}> | |
| 66 | + <TableCell>{totalCount - offset - index}</TableCell> | |
| 67 | + <TableCell>{formatInquiryTypeLabel(post.typeCode)}</TableCell> | |
| 68 | + <TableCell>{formatOptionalValue(post.title)}</TableCell> | |
| 69 | + <TableCell>{formatOptionalValue(post.authorName)}</TableCell> | |
| 70 | + <TableCell>{maskPhoneNumber(post.authorPhoneNumber)}</TableCell> | |
| 71 | + <TableCell>{formatOptionalValue(post.createdAt)}</TableCell> | |
| 72 | + <TableCell> | |
| 73 | + {formatAnswerStatusLabel(post.answerStatusCode)} | |
| 74 | + </TableCell> | |
| 75 | + <TableCell>{formatOptionalValue(post.answeredAt)}</TableCell> | |
| 76 | + <TableCell> | |
| 77 | + <InquiryRowActions post={post} /> | |
| 78 | + </TableCell> | |
| 79 | + </TableRow> | |
| 80 | + ))} | |
| 81 | + </TableBody> | |
| 82 | + </Table> | |
| 83 | + ); | |
| 84 | +} |
+++ app/(protected)/(basic)/boards/inquiries/page.tsx
... | ... | @@ -0,0 +1,90 @@ |
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { Alert } from '@/components/ui/alert'; | |
| 4 | +import { Pagination } from '@/components/ui/pagination'; | |
| 5 | +import { verifySession } from '@/lib/auth/dal'; | |
| 6 | +import { fetchBoardPosts } from '@/lib/data/repositories/board-post-repository'; | |
| 7 | +import { | |
| 8 | + buildBoardPostHref, | |
| 9 | + parseBoardPostQuery, | |
| 10 | +} from '@/lib/domain/board-post-query'; | |
| 11 | +import { BoardFilterBar } from '../_components/board-filter-bar'; | |
| 12 | +import { BoardListToolbar } from '../_components/board-list-toolbar'; | |
| 13 | +import { InquiryTable } from './_components/inquiry-table'; | |
| 14 | + | |
| 15 | +export const metadata: Metadata = { | |
| 16 | + title: '1:1문의', | |
| 17 | +}; | |
| 18 | + | |
| 19 | +interface PageProps { | |
| 20 | + searchParams: Promise<Record<string, string | string[] | undefined>>; | |
| 21 | +} | |
| 22 | + | |
| 23 | +/** | |
| 24 | + * 1:1문의 목록 — 시안 A_BOA_012. | |
| 25 | + * | |
| 26 | + * 관리자는 글을 등록하지 않고 **조회·답변·삭제만** 한다(사용자 확인 사항) — 그래서 툴바의 | |
| 27 | + * [신규 등록]을 끈다. 등록을 막는 것은 화면뿐 아니라 Server Action도 마찬가지다. | |
| 28 | + */ | |
| 29 | +export default async function Page({ searchParams }: PageProps) { | |
| 30 | + await verifySession(); | |
| 31 | + | |
| 32 | + const query = parseBoardPostQuery('inquiry', await searchParams); | |
| 33 | + const { items, totalCount } = await fetchBoardPosts('inquiry', query); | |
| 34 | + | |
| 35 | + const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize)); | |
| 36 | + const currentPage = Math.min(query.page, totalPages); | |
| 37 | + | |
| 38 | + return ( | |
| 39 | + <section className="flex flex-1 flex-col gap-6 p-8"> | |
| 40 | + <h1 className="text-headline-sm text-foreground">1:1문의 목록</h1> | |
| 41 | + | |
| 42 | + <BoardFilterBar boardType="inquiry" query={query} /> | |
| 43 | + | |
| 44 | + <BoardListToolbar | |
| 45 | + boardType="inquiry" | |
| 46 | + query={query} | |
| 47 | + canCreate={false} | |
| 48 | + /> | |
| 49 | + | |
| 50 | + <p className="text-body-md text-foreground-muted"> | |
| 51 | + 총 {totalCount}개 | 현재페이지 {currentPage}/{totalPages} | |
| 52 | + </p> | |
| 53 | + | |
| 54 | + {items.length === 0 ? ( | |
| 55 | + <Alert | |
| 56 | + title="검색 결과가 없습니다." | |
| 57 | + actions={ | |
| 58 | + <Link | |
| 59 | + href={buildBoardPostHref('inquiry', query, { | |
| 60 | + keyword: '', | |
| 61 | + page: 1, | |
| 62 | + })} | |
| 63 | + className="bg-foreground px-4 py-2 text-background" | |
| 64 | + > | |
| 65 | + 확인 | |
| 66 | + </Link> | |
| 67 | + } | |
| 68 | + > | |
| 69 | + 다른 검색어로 다시 시도해 주세요. | |
| 70 | + </Alert> | |
| 71 | + ) : ( | |
| 72 | + <> | |
| 73 | + <InquiryTable | |
| 74 | + items={items} | |
| 75 | + page={currentPage} | |
| 76 | + pageSize={query.pageSize} | |
| 77 | + totalCount={totalCount} | |
| 78 | + /> | |
| 79 | + <Pagination | |
| 80 | + currentPage={currentPage} | |
| 81 | + totalPages={totalPages} | |
| 82 | + buildHref={(page: number) => | |
| 83 | + buildBoardPostHref('inquiry', query, { page }) | |
| 84 | + } | |
| 85 | + /> | |
| 86 | + </> | |
| 87 | + )} | |
| 88 | + </section> | |
| 89 | + ); | |
| 90 | +} |
+++ app/(protected)/(basic)/boards/loading.tsx
... | ... | @@ -0,0 +1,11 @@ |
| 1 | +import { Spinner } from '@/components/ui/spinner'; | |
| 2 | + | |
| 3 | +// 게시판 세 화면(공지사항·1:1문의·FAQ)이 공유하는 스트리밍 폴백 — 이 세그먼트에 두면 하위 | |
| 4 | +// 라우트 전체에 적용되므로 화면마다 따로 두지 않는다. Server Component로 충분하다. | |
| 5 | +export default function Loading() { | |
| 6 | + return ( | |
| 7 | + <section className="flex flex-1 flex-col items-center justify-center gap-2 p-8"> | |
| 8 | + <Spinner size="lg" /> | |
| 9 | + </section> | |
| 10 | + ); | |
| 11 | +} |
+++ app/(protected)/(basic)/boards/notices/_components/notice-table.tsx
... | ... | @@ -0,0 +1,83 @@ |
| 1 | +import { | |
| 2 | + Table, | |
| 3 | + TableBody, | |
| 4 | + TableCell, | |
| 5 | + TableHead, | |
| 6 | + TableHeaderCell, | |
| 7 | + TableRow, | |
| 8 | +} from '@/components/ui/table'; | |
| 9 | +import { | |
| 10 | + EMPTY_FIELD_PLACEHOLDER, | |
| 11 | + formatBoardCategoryLabel, | |
| 12 | + formatOptionalValue, | |
| 13 | + formatVisibilityLabel, | |
| 14 | + type BoardPost, | |
| 15 | +} from '@/lib/domain/board-post'; | |
| 16 | +import { BoardRowActions } from '../../_components/board-row-actions'; | |
| 17 | + | |
| 18 | +interface NoticeTableProps { | |
| 19 | + items: BoardPost[]; | |
| 20 | + page: number; | |
| 21 | + pageSize: number; | |
| 22 | + totalCount: number; | |
| 23 | +} | |
| 24 | + | |
| 25 | +const COLUMN_HEADERS = [ | |
| 26 | + '번호', | |
| 27 | + '구분', | |
| 28 | + '제목', | |
| 29 | + '첨부', | |
| 30 | + '작성자', | |
| 31 | + '사용여부', | |
| 32 | + '작성일', | |
| 33 | + '관리', | |
| 34 | +]; | |
| 35 | + | |
| 36 | +/** | |
| 37 | + * 공지사항 목록 테이블 — 시안 A_BOA_006 ③. | |
| 38 | + * | |
| 39 | + * "번호"는 저장된 값이 아니라 전체 건수에서 거꾸로 세는 표시 순번이다(시안이 2·1처럼 내림차순으로 | |
| 40 | + * 표기하고 목록이 최신순이라 가장 최근 글이 가장 큰 번호가 된다). 정확한 전체 건수를 전제하는데, | |
| 41 | + * Repository가 전체를 받아 세기 때문에 그 전제가 성립한다. | |
| 42 | + * | |
| 43 | + * "첨부"는 파일 유무만 표시한다 — 백엔드에 atchFileId로 파일 정보를 조회하는 API가 없어 파일명· | |
| 44 | + * 용량을 알 수 없다(`board-attachment-field.tsx` 주석 참조). | |
| 45 | + */ | |
| 46 | +export function NoticeTable({ | |
| 47 | + items, | |
| 48 | + page, | |
| 49 | + pageSize, | |
| 50 | + totalCount, | |
| 51 | +}: NoticeTableProps) { | |
| 52 | + const offset = (page - 1) * pageSize; | |
| 53 | + | |
| 54 | + return ( | |
| 55 | + <Table> | |
| 56 | + <TableHead> | |
| 57 | + <TableRow> | |
| 58 | + {COLUMN_HEADERS.map((header) => ( | |
| 59 | + <TableHeaderCell key={header}>{header}</TableHeaderCell> | |
| 60 | + ))} | |
| 61 | + </TableRow> | |
| 62 | + </TableHead> | |
| 63 | + <TableBody> | |
| 64 | + {items.map((post, index) => ( | |
| 65 | + <TableRow key={post.id}> | |
| 66 | + <TableCell>{totalCount - offset - index}</TableCell> | |
| 67 | + <TableCell>{formatBoardCategoryLabel(post.categoryCode)}</TableCell> | |
| 68 | + <TableCell>{formatOptionalValue(post.title)}</TableCell> | |
| 69 | + <TableCell> | |
| 70 | + {post.attachmentId ? '있음' : EMPTY_FIELD_PLACEHOLDER} | |
| 71 | + </TableCell> | |
| 72 | + <TableCell>{formatOptionalValue(post.authorName)}</TableCell> | |
| 73 | + <TableCell>{formatVisibilityLabel(post.isVisible)}</TableCell> | |
| 74 | + <TableCell>{formatOptionalValue(post.createdAt)}</TableCell> | |
| 75 | + <TableCell> | |
| 76 | + <BoardRowActions boardType="notice" post={post} /> | |
| 77 | + </TableCell> | |
| 78 | + </TableRow> | |
| 79 | + ))} | |
| 80 | + </TableBody> | |
| 81 | + </Table> | |
| 82 | + ); | |
| 83 | +} |
+++ app/(protected)/(basic)/boards/notices/page.tsx
... | ... | @@ -0,0 +1,88 @@ |
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { Alert } from '@/components/ui/alert'; | |
| 4 | +import { Pagination } from '@/components/ui/pagination'; | |
| 5 | +import { verifySession } from '@/lib/auth/dal'; | |
| 6 | +import { fetchBoardPosts } from '@/lib/data/repositories/board-post-repository'; | |
| 7 | +import { | |
| 8 | + buildBoardPostHref, | |
| 9 | + parseBoardPostQuery, | |
| 10 | +} from '@/lib/domain/board-post-query'; | |
| 11 | +import { BoardFilterBar } from '../_components/board-filter-bar'; | |
| 12 | +import { BoardListToolbar } from '../_components/board-list-toolbar'; | |
| 13 | +import { NoticeTable } from './_components/notice-table'; | |
| 14 | + | |
| 15 | +export const metadata: Metadata = { | |
| 16 | + title: '공지사항', | |
| 17 | +}; | |
| 18 | + | |
| 19 | +interface PageProps { | |
| 20 | + searchParams: Promise<Record<string, string | string[] | undefined>>; | |
| 21 | +} | |
| 22 | + | |
| 23 | +/** | |
| 24 | + * 공지사항 목록 — 시안 A_BOA_006. | |
| 25 | + * | |
| 26 | + * 전체 건수가 **확정값**이라 페이지 수를 그대로 계산한다 — Repository가 백엔드 페이징을 쓰지 않고 | |
| 27 | + * 전체를 받아 직접 세기 때문이다(백엔드 `totalCount`는 현재 페이지 행 수라 신뢰할 수 없다). | |
| 28 | + */ | |
| 29 | +export default async function Page({ searchParams }: PageProps) { | |
| 30 | + await verifySession(); | |
| 31 | + | |
| 32 | + const query = parseBoardPostQuery('notice', await searchParams); | |
| 33 | + const { items, totalCount } = await fetchBoardPosts('notice', query); | |
| 34 | + | |
| 35 | + const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize)); | |
| 36 | + // 요청 페이지가 범위를 벗어나면(예: 삭제로 마지막 페이지가 사라짐) 마지막 페이지로 맞춘다 — | |
| 37 | + // 표의 순번 계산도 이 값을 기준으로 해야 헤더의 "현재페이지"와 어긋나지 않는다. | |
| 38 | + const currentPage = Math.min(query.page, totalPages); | |
| 39 | + | |
| 40 | + return ( | |
| 41 | + <section className="flex flex-1 flex-col gap-6 p-8"> | |
| 42 | + <h1 className="text-headline-sm text-foreground">공지사항 목록</h1> | |
| 43 | + | |
| 44 | + <BoardFilterBar boardType="notice" query={query} /> | |
| 45 | + | |
| 46 | + <BoardListToolbar boardType="notice" query={query} canCreate /> | |
| 47 | + | |
| 48 | + <p className="text-body-md text-foreground-muted"> | |
| 49 | + 총 {totalCount}개 | 현재페이지 {currentPage}/{totalPages} | |
| 50 | + </p> | |
| 51 | + | |
| 52 | + {items.length === 0 ? ( | |
| 53 | + <Alert | |
| 54 | + title="검색 결과가 없습니다." | |
| 55 | + actions={ | |
| 56 | + <Link | |
| 57 | + href={buildBoardPostHref('notice', query, { | |
| 58 | + keyword: '', | |
| 59 | + page: 1, | |
| 60 | + })} | |
| 61 | + className="bg-foreground px-4 py-2 text-background" | |
| 62 | + > | |
| 63 | + 확인 | |
| 64 | + </Link> | |
| 65 | + } | |
| 66 | + > | |
| 67 | + 다른 검색어로 다시 시도해 주세요. | |
| 68 | + </Alert> | |
| 69 | + ) : ( | |
| 70 | + <> | |
| 71 | + <NoticeTable | |
| 72 | + items={items} | |
| 73 | + page={currentPage} | |
| 74 | + pageSize={query.pageSize} | |
| 75 | + totalCount={totalCount} | |
| 76 | + /> | |
| 77 | + <Pagination | |
| 78 | + currentPage={currentPage} | |
| 79 | + totalPages={totalPages} | |
| 80 | + buildHref={(page: number) => | |
| 81 | + buildBoardPostHref('notice', query, { page }) | |
| 82 | + } | |
| 83 | + /> | |
| 84 | + </> | |
| 85 | + )} | |
| 86 | + </section> | |
| 87 | + ); | |
| 88 | +} |
+++ lib/data/repositories/board-post-repository.ts
... | ... | @@ -0,0 +1,377 @@ |
| 1 | +import 'server-only'; | |
| 2 | +import { getSessionAccessToken } from '@/lib/auth/dal'; | |
| 3 | +import { | |
| 4 | + BackendRequestError, | |
| 5 | + backendCommand, | |
| 6 | + backendFetch, | |
| 7 | +} from '@/lib/http/backend-fetch'; | |
| 8 | +import { | |
| 9 | + BOARD_SETTING_IDS, | |
| 10 | + BOARD_TYPE_CODE_PARAM, | |
| 11 | + type BoardPost, | |
| 12 | + type BoardType, | |
| 13 | +} from '@/lib/domain/board-post'; | |
| 14 | +import type { BoardPostQuery } from '@/lib/domain/board-post-query'; | |
| 15 | + | |
| 16 | +/** | |
| 17 | + * 게시판 Repository — 공지사항·1:1문의·FAQ가 **같은 백엔드 API**를 쓰므로 한 파일이 셋을 모두 | |
| 18 | + * 담당하고, 게시판 구분은 `BoardType` → `stngId` 변환으로만 처리한다. | |
| 19 | + * | |
| 20 | + * GET /api/v1/mngr/bbs/pagination (searchStngId 필수) | |
| 21 | + * GET /api/v1/mngr/bbs/{bbsId} | |
| 22 | + * POST /api/v1/mngr/bbs | |
| 23 | + * PUT /api/v1/mngr/bbs/{bbsId} | |
| 24 | + * DELETE /api/v1/mngr/bbs/{bbsId} (논리 삭제 — DEL_YN='Y') | |
| 25 | + * | |
| 26 | + * 아래는 백엔드 저장소(edupay-backend develop 924db37)의 실제 구현을 읽고 확인한 것이다 — | |
| 27 | + * MngrBbsApiController / MngrBbsServiceImpl / MngrBbsMapper.xml / PaginationUtil / CrudLogInterceptor. | |
| 28 | + * | |
| 29 | + * - **쓰기 요청은 JSON이 아니라 폼 인코딩이다.** 컨트롤러의 `MngrBbsRequestVo`에 `@RequestBody`가 | |
| 30 | + * 없어 Spring이 요청 파라미터로 바인딩한다. 그래서 `application/x-www-form-urlencoded`로 보낸다. | |
| 31 | + * - **작성자·수정자·삭제자는 보내지 않는다.** `CrudLogInterceptor`(MyBatis 플러그인)가 인증 | |
| 32 | + * 주체에서 꺼내 자동 기록한다. 프론트가 보내도 덮어써진다. | |
| 33 | + * - **`totalCount`를 신뢰할 수 없다.** count 쿼리가 없어 `PaginationUtil`이 현재 페이지 행 수를 | |
| 34 | + * 총건수로 반환한다(학생·관리자 목록과 동일한 결함). | |
| 35 | + * - **노출기간·앱푸쉬는 저장되지만 조회되지 않는다.** INSERT/UPDATE에는 있으나 조회 SQL의 select | |
| 36 | + * 목록에 START_DT·END_DT·PUSH_YN이 없다. 사용자 확인 후 "전송은 하되 조회는 빈 값" 방침이다. | |
| 37 | + * - **`reg_dt` 별칭이 두 번 쓰인다**(ANS_DT에 한 번, FRST_REG_DT에 한 번). 같은 이름의 컬럼이 둘이라 | |
| 38 | + * 작성일 자리에 답변일이 들어올 수 있다 — 보고한 백엔드 결함이며, 값이 이상하면 이 지점을 의심한다. | |
| 39 | + * | |
| 40 | + * 인증: `/api/v1/mngr/**`는 ROLE_ADMIN 전용. 캐시: `no-store`(조건이 매 요청 다르고 관리 데이터다). | |
| 41 | + */ | |
| 42 | + | |
| 43 | +const BOARD_LIST_PATH = '/api/v1/mngr/bbs/pagination'; | |
| 44 | +const BOARD_BASE_PATH = '/api/v1/mngr/bbs'; | |
| 45 | + | |
| 46 | +/** | |
| 47 | + * 한 번에 받아올 최대 행 수. **백엔드 페이징을 쓰지 않고 전체를 받아 여기서 자른다** — 사용자가 | |
| 48 | + * "전체 조회 후 프론트에서 필터링"을 선택했기 때문이다. | |
| 49 | + * | |
| 50 | + * 그렇게 정한 이유: ① 백엔드 목록 쿼리의 검색 조건이 제목 하나뿐이고 `searchUseYn`은 쿼리에서 | |
| 51 | + * 아예 쓰이지 않아 시안의 필터를 백엔드에 맡길 수 없다. ② `totalCount`가 전체 건수가 아니라 | |
| 52 | + * 페이지가 가득 차면 다음 페이지에 닿을 수 없다. 전체를 손에 쥐면 두 문제가 함께 풀린다. | |
| 53 | + * | |
| 54 | + * 게시물이 이 상한을 넘으면 그 위로는 조회·검색 대상에서 빠진다 — 그 규모가 되면 백엔드에 count와 | |
| 55 | + * 검색 조건이 필요하다(상한 인상은 임시방편일 뿐이다). | |
| 56 | + */ | |
| 57 | +const BOARD_FETCH_LIMIT = 10_000; | |
| 58 | + | |
| 59 | +function isRecord(value: unknown): value is Record<string, unknown> { | |
| 60 | + return value !== null && typeof value === 'object'; | |
| 61 | +} | |
| 62 | + | |
| 63 | +function readString(source: Record<string, unknown>, key: string): string | null { | |
| 64 | + const value = source[key]; | |
| 65 | + return typeof value === 'string' && value.length > 0 ? value : null; | |
| 66 | +} | |
| 67 | + | |
| 68 | +/** 백엔드의 Y/N 플래그 → boolean. 값이 없거나 Y/N이 아니면 "모름"(null)이다. */ | |
| 69 | +function parseYesNo(value: unknown): boolean | null { | |
| 70 | + if (value === 'Y') return true; | |
| 71 | + if (value === 'N') return false; | |
| 72 | + return null; | |
| 73 | +} | |
| 74 | + | |
| 75 | +/** `inqCnt`는 VO 타입이 String이라 숫자 문자열로 온다 — 숫자로 못 읽으면 null. */ | |
| 76 | +function parseCount(value: unknown): number | null { | |
| 77 | + if (typeof value === 'number') return value; | |
| 78 | + if (typeof value === 'string' && value.trim() !== '') { | |
| 79 | + const parsed = Number(value); | |
| 80 | + return Number.isFinite(parsed) ? parsed : null; | |
| 81 | + } | |
| 82 | + return null; | |
| 83 | +} | |
| 84 | + | |
| 85 | +/** | |
| 86 | + * 백엔드 응답 1건 → 도메인 타입. 백엔드가 주지 않는 항목은 `null`로 둔다. | |
| 87 | + * | |
| 88 | + * `bbsId`만 없으면 예외로 끊는다(fail-fast) — 행의 key이자 수정·삭제의 입력값이라 없으면 목록 | |
| 89 | + * 자체가 성립하지 않는다. 제목·내용은 비어 있어도 화면이 `-`로 표시하면 되므로 끊지 않는다. | |
| 90 | + */ | |
| 91 | +function toBoardPost(raw: unknown): BoardPost { | |
| 92 | + if (!isRecord(raw)) { | |
| 93 | + throw new Error('게시판 응답 항목의 형식이 올바르지 않습니다.'); | |
| 94 | + } | |
| 95 | + | |
| 96 | + const id = readString(raw, 'bbsId'); | |
| 97 | + if (id === null) { | |
| 98 | + throw new Error('게시판 응답에 bbsId가 없습니다.'); | |
| 99 | + } | |
| 100 | + | |
| 101 | + return { | |
| 102 | + id, | |
| 103 | + title: readString(raw, 'bbsNm') ?? '', | |
| 104 | + content: readString(raw, 'bbsCn') ?? '', | |
| 105 | + categoryCode: readString(raw, 'bbsCd') ?? '', | |
| 106 | + // 백엔드에 유형 저장 필드가 아직 없다(BOARD_TYPE_CODE_PARAM 주석) — 응답에도 없으므로 null이다. | |
| 107 | + typeCode: readString(raw, BOARD_TYPE_CODE_PARAM), | |
| 108 | + attachmentId: readString(raw, 'atchFileId'), | |
| 109 | + isPinned: parseYesNo(raw.hghrkYn), | |
| 110 | + isVisible: parseYesNo(raw.rlsYn), | |
| 111 | + startDate: readString(raw, 'startDt'), | |
| 112 | + endDate: readString(raw, 'endDt'), | |
| 113 | + isPushEnabled: parseYesNo(raw.pushYn), | |
| 114 | + authorName: readString(raw, 'regNm'), | |
| 115 | + createdAt: readString(raw, 'regDt'), | |
| 116 | + viewCount: parseCount(raw.inqCnt), | |
| 117 | + authorPhoneNumber: readString(raw, 'telNo'), | |
| 118 | + authorEmail: readString(raw, 'emlAddr'), | |
| 119 | + answerContent: readString(raw, 'ansCn'), | |
| 120 | + answeredAt: readString(raw, 'ansDt'), | |
| 121 | + answererName: readString(raw, 'answrNm'), | |
| 122 | + answerStatusCode: readString(raw, 'ansSttsCd'), | |
| 123 | + }; | |
| 124 | +} | |
| 125 | + | |
| 126 | +/** 게시판 전체를 한 번에 받아온다(검색·필터·페이징은 호출부가 처리한다). */ | |
| 127 | +async function fetchAllBoardPosts(boardType: BoardType): Promise<BoardPost[]> { | |
| 128 | + const accessToken = await getSessionAccessToken(); | |
| 129 | + | |
| 130 | + const result = await backendFetch<unknown>(BOARD_LIST_PATH, { | |
| 131 | + method: 'GET', | |
| 132 | + query: { | |
| 133 | + searchStngId: BOARD_SETTING_IDS[boardType], | |
| 134 | + pageIndex: 1, | |
| 135 | + recordCountPerPage: BOARD_FETCH_LIMIT, | |
| 136 | + }, | |
| 137 | + accessToken: accessToken ?? undefined, | |
| 138 | + cache: 'no-store', | |
| 139 | + }); | |
| 140 | + | |
| 141 | + if (!result.ok) { | |
| 142 | + throw new BackendRequestError(result); | |
| 143 | + } | |
| 144 | + | |
| 145 | + const data = result.data; | |
| 146 | + if (!isRecord(data) || !Array.isArray(data.list)) { | |
| 147 | + throw new Error('게시판 목록 응답의 형식이 올바르지 않습니다.'); | |
| 148 | + } | |
| 149 | + | |
| 150 | + return data.list.map(toBoardPost); | |
| 151 | +} | |
| 152 | + | |
| 153 | +/** 검색 대상 → 비교할 값. 목록·상세에 실제로 보이는 값으로만 거른다. */ | |
| 154 | +const SEARCH_VALUE_BY_FIELD: Record<string, (post: BoardPost) => string> = { | |
| 155 | + title: (post) => post.title, | |
| 156 | + content: (post) => post.content, | |
| 157 | + authorName: (post) => post.authorName ?? '', | |
| 158 | + authorPhoneNumber: (post) => post.authorPhoneNumber ?? '', | |
| 159 | + answerContent: (post) => post.answerContent ?? '', | |
| 160 | +}; | |
| 161 | + | |
| 162 | +function matchesQuery(post: BoardPost, query: BoardPostQuery): boolean { | |
| 163 | + if (query.category && post.categoryCode !== query.category) { | |
| 164 | + return false; | |
| 165 | + } | |
| 166 | + if (query.type && post.typeCode !== query.type) { | |
| 167 | + return false; | |
| 168 | + } | |
| 169 | + if (query.visibility === 'visible' && post.isVisible !== true) { | |
| 170 | + return false; | |
| 171 | + } | |
| 172 | + if (query.visibility === 'hidden' && post.isVisible === true) { | |
| 173 | + return false; | |
| 174 | + } | |
| 175 | + | |
| 176 | + // 답변여부는 별도 플래그가 없어 답변 내용의 유무로 판단한다(진행상태 코드는 잠정값이라 | |
| 177 | + // 그것으로 판정하면 코드가 확정될 때 함께 틀어진다). | |
| 178 | + const hasAnswer = Boolean(post.answerContent); | |
| 179 | + if (query.answered === 'answered' && !hasAnswer) { | |
| 180 | + return false; | |
| 181 | + } | |
| 182 | + if (query.answered === 'unanswered' && hasAnswer) { | |
| 183 | + return false; | |
| 184 | + } | |
| 185 | + | |
| 186 | + const keyword = query.keyword.trim().toLowerCase(); | |
| 187 | + if (!keyword) { | |
| 188 | + return true; | |
| 189 | + } | |
| 190 | + | |
| 191 | + const readValue = SEARCH_VALUE_BY_FIELD[query.searchField]; | |
| 192 | + return readValue ? readValue(post).toLowerCase().includes(keyword) : true; | |
| 193 | +} | |
| 194 | + | |
| 195 | +export type BoardPostPage = { | |
| 196 | + items: BoardPost[]; | |
| 197 | + /** 검색·필터를 적용한 전체 건수. 전체를 손에 쥐고 세므로 확정값이다. */ | |
| 198 | + totalCount: number; | |
| 199 | +}; | |
| 200 | + | |
| 201 | +/** | |
| 202 | + * 검색·필터·페이징이 적용된 목록을 조회한다. | |
| 203 | + * | |
| 204 | + * 정렬은 하지 않는다 — 백엔드 목록 SQL이 `ORDER BY rnum DESC`(= 최초등록일시 최신순)로 고정돼 | |
| 205 | + * 있고 그것이 곧 시안의 "최근등록순"이다. 시안의 정렬 select에도 다른 선택지가 없다. | |
| 206 | + */ | |
| 207 | +export async function fetchBoardPosts( | |
| 208 | + boardType: BoardType, | |
| 209 | + query: BoardPostQuery | |
| 210 | +): Promise<BoardPostPage> { | |
| 211 | + const all = await fetchAllBoardPosts(boardType); | |
| 212 | + const matched = all.filter((post) => matchesQuery(post, query)); | |
| 213 | + | |
| 214 | + const offset = (query.page - 1) * query.pageSize; | |
| 215 | + | |
| 216 | + return { | |
| 217 | + items: matched.slice(offset, offset + query.pageSize), | |
| 218 | + totalCount: matched.length, | |
| 219 | + }; | |
| 220 | +} | |
| 221 | + | |
| 222 | +/** 단건 조회 — 수정·답변 팝업의 진입점. */ | |
| 223 | +export async function findBoardPostById( | |
| 224 | + bbsId: string | |
| 225 | +): Promise<BoardPost | null> { | |
| 226 | + const accessToken = await getSessionAccessToken(); | |
| 227 | + | |
| 228 | + const result = await backendFetch<unknown>(`${BOARD_BASE_PATH}/${bbsId}`, { | |
| 229 | + method: 'GET', | |
| 230 | + accessToken: accessToken ?? undefined, | |
| 231 | + cache: 'no-store', | |
| 232 | + }); | |
| 233 | + | |
| 234 | + if (!result.ok) { | |
| 235 | + throw new BackendRequestError(result); | |
| 236 | + } | |
| 237 | + | |
| 238 | + return result.data === null ? null : toBoardPost(result.data); | |
| 239 | +} | |
| 240 | + | |
| 241 | +/** boolean → 백엔드 Y/N 플래그. */ | |
| 242 | +function toYesNo(value: boolean): string { | |
| 243 | + return value ? 'Y' : 'N'; | |
| 244 | +} | |
| 245 | + | |
| 246 | +export type BoardPostWriteInput = { | |
| 247 | + categoryCode: string; | |
| 248 | + typeCode: string; | |
| 249 | + title: string; | |
| 250 | + content: string; | |
| 251 | + attachmentId: string; | |
| 252 | + isPinned: boolean; | |
| 253 | + isVisible: boolean; | |
| 254 | + isPushEnabled: boolean; | |
| 255 | + startDate: string; | |
| 256 | + endDate: string; | |
| 257 | +}; | |
| 258 | + | |
| 259 | +/** | |
| 260 | + * 등록/수정 공통 파라미터. | |
| 261 | + * | |
| 262 | + * 빈 문자열은 보내지 않는다 — 수정 SQL이 `<if test="... != null">`로 동적 SET을 만들기 때문에, | |
| 263 | + * 빈 문자열을 보내면 기존 값을 빈 값으로 덮어쓴다(예: 첨부파일을 안 건드렸는데 지워지는 상황). | |
| 264 | + * "값을 안 보냄 = 기존 유지"가 백엔드의 규약이라 그에 맞춘다. | |
| 265 | + */ | |
| 266 | +function buildWriteParams( | |
| 267 | + boardType: BoardType, | |
| 268 | + input: BoardPostWriteInput | |
| 269 | +): Record<string, string> { | |
| 270 | + const params: Record<string, string> = { | |
| 271 | + stngId: BOARD_SETTING_IDS[boardType], | |
| 272 | + bbsNm: input.title, | |
| 273 | + bbsCn: input.content, | |
| 274 | + hghrkYn: toYesNo(input.isPinned), | |
| 275 | + rlsYn: toYesNo(input.isVisible), | |
| 276 | + pushYn: toYesNo(input.isPushEnabled), | |
| 277 | + }; | |
| 278 | + | |
| 279 | + // 구분(bbsCd)은 아직 요청 VO에 없어 백엔드가 무시한다. 필드가 추가되는 즉시 동작하도록 보낸다. | |
| 280 | + if (input.categoryCode) params.bbsCd = input.categoryCode; | |
| 281 | + if (input.typeCode) params[BOARD_TYPE_CODE_PARAM] = input.typeCode; | |
| 282 | + if (input.attachmentId) params.atchFileId = input.attachmentId; | |
| 283 | + if (input.startDate) params.startDt = input.startDate; | |
| 284 | + if (input.endDate) params.endDt = input.endDate; | |
| 285 | + | |
| 286 | + return params; | |
| 287 | +} | |
| 288 | + | |
| 289 | +export async function createBoardPost( | |
| 290 | + boardType: BoardType, | |
| 291 | + input: BoardPostWriteInput | |
| 292 | +): Promise<void> { | |
| 293 | + const accessToken = await getSessionAccessToken(); | |
| 294 | + | |
| 295 | + const result = await backendCommand(BOARD_BASE_PATH, { | |
| 296 | + method: 'POST', | |
| 297 | + form: buildWriteParams(boardType, input), | |
| 298 | + accessToken: accessToken ?? undefined, | |
| 299 | + cache: 'no-store', | |
| 300 | + }); | |
| 301 | + | |
| 302 | + if (!result.ok) { | |
| 303 | + throw new BackendRequestError(result); | |
| 304 | + } | |
| 305 | +} | |
| 306 | + | |
| 307 | +export async function updateBoardPost( | |
| 308 | + boardType: BoardType, | |
| 309 | + bbsId: string, | |
| 310 | + input: BoardPostWriteInput | |
| 311 | +): Promise<void> { | |
| 312 | + const accessToken = await getSessionAccessToken(); | |
| 313 | + | |
| 314 | + const result = await backendCommand(`${BOARD_BASE_PATH}/${bbsId}`, { | |
| 315 | + method: 'PUT', | |
| 316 | + // 수정은 컨트롤러가 `@ParameterObject`라 쿼리스트링으로 받는다(본문이 아니다). | |
| 317 | + query: buildWriteParams(boardType, input), | |
| 318 | + accessToken: accessToken ?? undefined, | |
| 319 | + cache: 'no-store', | |
| 320 | + }); | |
| 321 | + | |
| 322 | + if (!result.ok) { | |
| 323 | + throw new BackendRequestError(result); | |
| 324 | + } | |
| 325 | +} | |
| 326 | + | |
| 327 | +export type InquiryAnswerInput = { | |
| 328 | + answerStatusCode: string; | |
| 329 | + answerContent: string; | |
| 330 | + attachmentId: string; | |
| 331 | +}; | |
| 332 | + | |
| 333 | +/** | |
| 334 | + * 1:1문의 답변 저장 — 백엔드에 전용 엔드포인트가 없어 수정 API에 답변 필드를 실어 보낸다. | |
| 335 | + * | |
| 336 | + * 제목·내용은 보내지 않는다(질문자가 쓴 값이라 관리자가 건드릴 이유가 없고, 위에서 적었듯 | |
| 337 | + * 안 보내면 기존 값이 유지된다). `stngId`는 수정 SQL의 WHERE 조건이라 반드시 필요하다. | |
| 338 | + */ | |
| 339 | +export async function saveInquiryAnswer( | |
| 340 | + bbsId: string, | |
| 341 | + input: InquiryAnswerInput | |
| 342 | +): Promise<void> { | |
| 343 | + const accessToken = await getSessionAccessToken(); | |
| 344 | + | |
| 345 | + const query: Record<string, string> = { | |
| 346 | + stngId: BOARD_SETTING_IDS.inquiry, | |
| 347 | + ansSttsCd: input.answerStatusCode, | |
| 348 | + }; | |
| 349 | + if (input.answerContent) query.ansCn = input.answerContent; | |
| 350 | + if (input.attachmentId) query.atchFileId = input.attachmentId; | |
| 351 | + | |
| 352 | + const result = await backendCommand(`${BOARD_BASE_PATH}/${bbsId}`, { | |
| 353 | + method: 'PUT', | |
| 354 | + query, | |
| 355 | + accessToken: accessToken ?? undefined, | |
| 356 | + cache: 'no-store', | |
| 357 | + }); | |
| 358 | + | |
| 359 | + if (!result.ok) { | |
| 360 | + throw new BackendRequestError(result); | |
| 361 | + } | |
| 362 | +} | |
| 363 | + | |
| 364 | +/** 삭제 — 백엔드가 물리 삭제가 아니라 `DEL_YN='Y'`로 표시만 바꾼다. */ | |
| 365 | +export async function deleteBoardPost(bbsId: string): Promise<void> { | |
| 366 | + const accessToken = await getSessionAccessToken(); | |
| 367 | + | |
| 368 | + const result = await backendCommand(`${BOARD_BASE_PATH}/${bbsId}`, { | |
| 369 | + method: 'DELETE', | |
| 370 | + accessToken: accessToken ?? undefined, | |
| 371 | + cache: 'no-store', | |
| 372 | + }); | |
| 373 | + | |
| 374 | + if (!result.ok) { | |
| 375 | + throw new BackendRequestError(result); | |
| 376 | + } | |
| 377 | +} |
+++ lib/data/repositories/file-repository.ts
... | ... | @@ -0,0 +1,68 @@ |
| 1 | +import 'server-only'; | |
| 2 | +import { getSessionAccessToken } from '@/lib/auth/dal'; | |
| 3 | +import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch'; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * 첨부파일 Repository — 업로드해서 `atchFileId`를 받아오는 것만 담당한다. | |
| 7 | + * | |
| 8 | + * POST /api/v1/common/file/upload/{moduleId} multipart/form-data, 파트 이름 `file` | |
| 9 | + * → data: "생성된 atchFileId" (문자열) | |
| 10 | + * | |
| 11 | + * 게시판 등록/수정은 이 id를 `atchFileId`로 실어 보내 게시물과 파일을 연결한다. | |
| 12 | + * | |
| 13 | + * **파일 메타데이터(파일명·용량) 조회 API가 없다.** 백엔드 파일 컨트롤러에는 업로드·다운로드·삭제· | |
| 14 | + * 이미지·스트림만 있고 "이 atchFileId에 무슨 파일이 붙어 있는가"를 알려 주는 엔드포인트가 없다. | |
| 15 | + * 그래서 시안(A_BOA_008_p)의 `pic_0001.jpg 88KB` 같은 표기는 **이미 저장된 첨부에 대해서는 | |
| 16 | + * 불가능**하고, 화면은 "첨부 있음 + 다운로드 링크"까지만 보여준다. 방금 업로드한 파일은 | |
| 17 | + * 브라우저가 이름·크기를 알고 있어 그 세션 동안만 표시할 수 있다. | |
| 18 | + * | |
| 19 | + * 다운로드는 이 파일이 다루지 않는다 — 파일 응답은 라우트 핸들러가 스트림으로 중계한다 | |
| 20 | + * (`app/(protected)/(basic)/boards/attachments/route.ts`). | |
| 21 | + */ | |
| 22 | + | |
| 23 | +const FILE_UPLOAD_PATH = '/api/v1/common/file/upload'; | |
| 24 | + | |
| 25 | +/** 업로드 상한. 백엔드에 명시적 제한이 보이지 않아 프론트에서 방어적으로 건다. */ | |
| 26 | +export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024; | |
| 27 | + | |
| 28 | +/** 파일 생성은 일반 조회보다 오래 걸릴 수 있어 넉넉히 잡는다. */ | |
| 29 | +const UPLOAD_TIMEOUT_MS = 60_000; | |
| 30 | + | |
| 31 | +/** | |
| 32 | + * 파일 하나를 업로드하고 `atchFileId`를 돌려준다. | |
| 33 | + * | |
| 34 | + * `moduleId`는 백엔드에서 파일 저장 설정(`FileStrgStngVo.strgStngId`)을 가리키며, 설정에 없는 | |
| 35 | + * 값이면 `UNKNOWN` 경로로 저장된다(EgovFileMngUtil) — 즉 잘못된 값이어도 실패하지 않고 조용히 | |
| 36 | + * 다른 곳에 저장되므로, 실제 설정 ID가 확정되면 반드시 맞춰야 한다. | |
| 37 | + */ | |
| 38 | +export async function uploadAttachment( | |
| 39 | + file: File, | |
| 40 | + moduleId: string | |
| 41 | +): Promise<string> { | |
| 42 | + const accessToken = await getSessionAccessToken(); | |
| 43 | + | |
| 44 | + const multipart = new FormData(); | |
| 45 | + multipart.set('file', file); | |
| 46 | + | |
| 47 | + const result = await backendFetch<unknown>( | |
| 48 | + `${FILE_UPLOAD_PATH}/${moduleId}`, | |
| 49 | + { | |
| 50 | + method: 'POST', | |
| 51 | + multipart, | |
| 52 | + accessToken: accessToken ?? undefined, | |
| 53 | + cache: 'no-store', | |
| 54 | + timeoutMs: UPLOAD_TIMEOUT_MS, | |
| 55 | + } | |
| 56 | + ); | |
| 57 | + | |
| 58 | + if (!result.ok) { | |
| 59 | + throw new BackendRequestError(result); | |
| 60 | + } | |
| 61 | + | |
| 62 | + // 성공 응답의 data는 atchFileId 문자열 그대로다(ApiResponseVO.success(atchFileId)). | |
| 63 | + if (typeof result.data !== 'string' || result.data.length === 0) { | |
| 64 | + throw new Error('첨부파일 업로드 응답에 파일 식별자가 없습니다.'); | |
| 65 | + } | |
| 66 | + | |
| 67 | + return result.data; | |
| 68 | +} |
+++ lib/domain/board-post-form.ts
... | ... | @@ -0,0 +1,180 @@ |
| 1 | +/** | |
| 2 | + * 게시판 등록/수정/답변 입력 규칙 — 순수 검증 로직만 담는다(외부 의존 없음). | |
| 3 | + * | |
| 4 | + * **이 파일이 검증의 단일 진실원천이다.** Server Action이 저장 직전에 여기를 거치고, 화면의 | |
| 5 | + * 글자수 상한 표시도 여기 상수를 그대로 쓴다 — 규칙이 화면과 서버에서 갈라지는 것을 막는다. | |
| 6 | + * 화면의 required 속성은 편의일 뿐 신뢰 경계가 아니다(Server Action은 UI를 거치지 않고 직접 | |
| 7 | + * 호출될 수 있다). | |
| 8 | + * | |
| 9 | + * 규칙의 근거는 시안(A_BOA_007_p / 008_p / 013_p / 015_p / 016_p)이다 — 백엔드에 게시판 입력 | |
| 10 | + * 검증이 전혀 없어(요청 VO에 Bean Validation 애너테이션이 하나도 없다) 시안이 유일한 근거다. | |
| 11 | + */ | |
| 12 | + | |
| 13 | +import { | |
| 14 | + ANSWER_STATUS_OPTIONS, | |
| 15 | + BOARD_CATEGORY_OPTIONS, | |
| 16 | + type BoardType, | |
| 17 | +} from '@/lib/domain/board-post'; | |
| 18 | +import { BOARD_TYPE_OPTIONS } from '@/lib/domain/board-post-query'; | |
| 19 | + | |
| 20 | +/** 본문 글자수 상한 — 시안 하단의 "50/1000자" 표기 기준. */ | |
| 21 | +export const CONTENT_MAX_LENGTH = 1000; | |
| 22 | +/** 제목 상한. 시안에 수치가 없어 통상적인 게시판 제목 길이로 둔다(DB 컬럼 길이는 미확인). */ | |
| 23 | +export const TITLE_MAX_LENGTH = 200; | |
| 24 | +/** 답변 내용 상한 — 시안 A_BOA_013_p ④ "텍스트 입력, 글자수 세기 적용". */ | |
| 25 | +export const ANSWER_MAX_LENGTH = 1000; | |
| 26 | + | |
| 27 | +export type BoardPostFormValues = { | |
| 28 | + categoryCode: string; | |
| 29 | + typeCode: string; | |
| 30 | + title: string; | |
| 31 | + content: string; | |
| 32 | + attachmentId: string; | |
| 33 | + isPinned: boolean; | |
| 34 | + isVisible: boolean; | |
| 35 | + isPushEnabled: boolean; | |
| 36 | + startDate: string; | |
| 37 | + endDate: string; | |
| 38 | +}; | |
| 39 | + | |
| 40 | +export type BoardPostFormErrors = Partial< | |
| 41 | + Record<keyof BoardPostFormValues, string> | |
| 42 | +>; | |
| 43 | + | |
| 44 | +export type InquiryAnswerFormValues = { | |
| 45 | + answerStatusCode: string; | |
| 46 | + answerContent: string; | |
| 47 | + attachmentId: string; | |
| 48 | +}; | |
| 49 | + | |
| 50 | +export type InquiryAnswerFormErrors = Partial< | |
| 51 | + Record<keyof InquiryAnswerFormValues, string> | |
| 52 | +>; | |
| 53 | + | |
| 54 | +export type ValidationResult<V, E> = | |
| 55 | + | { ok: true; values: V } | |
| 56 | + | { ok: false; errors: E }; | |
| 57 | + | |
| 58 | +/** `YYYY-MM-DD` 형식인지. 달력 위젯 없이 입력될 수 있으므로 형식을 직접 본다. */ | |
| 59 | +function isIsoDate(value: string): boolean { | |
| 60 | + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { | |
| 61 | + return false; | |
| 62 | + } | |
| 63 | + // 2026-02-31처럼 형식은 맞지만 존재하지 않는 날짜를 걸러낸다. | |
| 64 | + const parsed = new Date(`${value}T00:00:00Z`); | |
| 65 | + return !Number.isNaN(parsed.getTime()) && parsed.toISOString().startsWith(value); | |
| 66 | +} | |
| 67 | + | |
| 68 | +/** | |
| 69 | + * 등록·수정 공통 검증. | |
| 70 | + * | |
| 71 | + * `isCategoryEditable`로 등록과 수정을 가른다 — 수정 시 구분은 읽기 전용이라(시안 008_p ① / | |
| 72 | + * 016_p ①) 값이 오지 않거나 기존 값 그대로 온다. 그 값에까지 "선택 필수"를 적용하면 코드 표가 | |
| 73 | + * 바뀐 뒤 기존 게시물을 저장할 수 없게 되므로, 수정에서는 구분을 검증하지 않는다. | |
| 74 | + */ | |
| 75 | +export function validateBoardPostForm( | |
| 76 | + values: BoardPostFormValues, | |
| 77 | + options: { boardType: BoardType; isCategoryEditable: boolean } | |
| 78 | +): ValidationResult<BoardPostFormValues, BoardPostFormErrors> { | |
| 79 | + const errors: BoardPostFormErrors = {}; | |
| 80 | + | |
| 81 | + const title = values.title.trim(); | |
| 82 | + const content = values.content.trim(); | |
| 83 | + | |
| 84 | + if (options.isCategoryEditable) { | |
| 85 | + const isKnownCategory = BOARD_CATEGORY_OPTIONS.some( | |
| 86 | + (option) => option.value === values.categoryCode | |
| 87 | + ); | |
| 88 | + if (!isKnownCategory) { | |
| 89 | + errors.categoryCode = '구분을 선택해 주세요.'; | |
| 90 | + } | |
| 91 | + } | |
| 92 | + | |
| 93 | + // 유형은 공지사항에 없는 항목이라 해당 게시판에서만 검증한다. | |
| 94 | + const typeOptions = BOARD_TYPE_OPTIONS[options.boardType]; | |
| 95 | + if (typeOptions.length > 0) { | |
| 96 | + const isKnownType = typeOptions.some( | |
| 97 | + (option) => option.value === values.typeCode | |
| 98 | + ); | |
| 99 | + if (!isKnownType) { | |
| 100 | + errors.typeCode = '유형을 선택해 주세요.'; | |
| 101 | + } | |
| 102 | + } | |
| 103 | + | |
| 104 | + if (!title) { | |
| 105 | + errors.title = '제목을 입력해 주세요.'; | |
| 106 | + } else if (title.length > TITLE_MAX_LENGTH) { | |
| 107 | + errors.title = `제목은 ${TITLE_MAX_LENGTH}자 이내로 입력해 주세요.`; | |
| 108 | + } | |
| 109 | + | |
| 110 | + if (!content) { | |
| 111 | + errors.content = '내용을 입력해 주세요.'; | |
| 112 | + } else if (content.length > CONTENT_MAX_LENGTH) { | |
| 113 | + errors.content = `내용은 ${CONTENT_MAX_LENGTH}자 이내로 입력해 주세요.`; | |
| 114 | + } | |
| 115 | + | |
| 116 | + // 노출기간은 선택 항목이지만, 넣는다면 형식과 선후 관계는 맞아야 한다. | |
| 117 | + const startDate = values.startDate.trim(); | |
| 118 | + const endDate = values.endDate.trim(); | |
| 119 | + | |
| 120 | + if (startDate && !isIsoDate(startDate)) { | |
| 121 | + errors.startDate = '게시시작일시 형식이 올바르지 않습니다.'; | |
| 122 | + } | |
| 123 | + if (endDate && !isIsoDate(endDate)) { | |
| 124 | + errors.endDate = '게시종료일시 형식이 올바르지 않습니다.'; | |
| 125 | + } | |
| 126 | + if ( | |
| 127 | + !errors.startDate && | |
| 128 | + !errors.endDate && | |
| 129 | + startDate && | |
| 130 | + endDate && | |
| 131 | + startDate > endDate | |
| 132 | + ) { | |
| 133 | + errors.endDate = '게시종료일시는 게시시작일시보다 뒤여야 합니다.'; | |
| 134 | + } | |
| 135 | + | |
| 136 | + if (Object.keys(errors).length > 0) { | |
| 137 | + return { ok: false, errors }; | |
| 138 | + } | |
| 139 | + | |
| 140 | + return { | |
| 141 | + ok: true, | |
| 142 | + values: { ...values, title, content, startDate, endDate }, | |
| 143 | + }; | |
| 144 | +} | |
| 145 | + | |
| 146 | +/** | |
| 147 | + * 1:1문의 답변 검증(시안 A_BOA_013_p). | |
| 148 | + * | |
| 149 | + * 답변 내용은 **답변완료로 바꿀 때만 필수**다 — 답변대기·처리중으로 상태만 바꿔 두는 것도 정상 | |
| 150 | + * 흐름이기 때문이다(시안 ③ "답변완료 선택 시에만 FO에 답변 내용 노출"). | |
| 151 | + */ | |
| 152 | +export function validateInquiryAnswerForm( | |
| 153 | + values: InquiryAnswerFormValues, | |
| 154 | + options: { doneStatusCode: string } | |
| 155 | +): ValidationResult<InquiryAnswerFormValues, InquiryAnswerFormErrors> { | |
| 156 | + const errors: InquiryAnswerFormErrors = {}; | |
| 157 | + | |
| 158 | + const answerContent = values.answerContent.trim(); | |
| 159 | + | |
| 160 | + const isKnownStatus = ANSWER_STATUS_OPTIONS.some( | |
| 161 | + (option) => option.value === values.answerStatusCode | |
| 162 | + ); | |
| 163 | + if (!isKnownStatus) { | |
| 164 | + errors.answerStatusCode = '진행상태를 선택해 주세요.'; | |
| 165 | + } | |
| 166 | + | |
| 167 | + if (values.answerStatusCode === options.doneStatusCode && !answerContent) { | |
| 168 | + errors.answerContent = '답변완료로 저장하려면 답변 내용을 입력해 주세요.'; | |
| 169 | + } | |
| 170 | + | |
| 171 | + if (answerContent.length > ANSWER_MAX_LENGTH) { | |
| 172 | + errors.answerContent = `답변 내용은 ${ANSWER_MAX_LENGTH}자 이내로 입력해 주세요.`; | |
| 173 | + } | |
| 174 | + | |
| 175 | + if (Object.keys(errors).length > 0) { | |
| 176 | + return { ok: false, errors }; | |
| 177 | + } | |
| 178 | + | |
| 179 | + return { ok: true, values: { ...values, answerContent } }; | |
| 180 | +} |
+++ lib/domain/board-post-query.ts
... | ... | @@ -0,0 +1,200 @@ |
| 1 | +/** | |
| 2 | + * 게시판 목록(공지사항·1:1문의·FAQ)의 검색·필터·페이징 조건 — 순수 규칙(허용 값·기본값·URL | |
| 3 | + * 직렬화)만 담는다. next/react 의존이 없다. | |
| 4 | + * | |
| 5 | + * 학생/관리자 회원 목록은 화면마다 query 파일을 따로 두었지만 **게시판 셋은 한 파일로 합쳤다.** | |
| 6 | + * 세 화면이 같은 API·같은 엔티티를 쓰고 조건도 같은 모양(구분·유형·검색어·페이징)이라, 나누면 | |
| 7 | + * 같은 규칙이 세 벌로 복제되어 드리프트한다. 화면마다 다른 것은 "어떤 필터를 노출하는가"뿐이므로 | |
| 8 | + * 그 목록만 `BOARD_QUERY_FIELDS`로 선언해 두고 파싱·직렬화 로직은 공유한다. | |
| 9 | + * | |
| 10 | + * 쓰지 않는 필터는 화면에서 렌더하지 않으면 그만이다(값이 빈 문자열로 남아 필터가 통과된다). | |
| 11 | + */ | |
| 12 | + | |
| 13 | +import { | |
| 14 | + BOARD_CATEGORY_OPTIONS, | |
| 15 | + FAQ_TYPE_OPTIONS, | |
| 16 | + INQUIRY_TYPE_OPTIONS, | |
| 17 | + type BoardType, | |
| 18 | +} from '@/lib/domain/board-post'; | |
| 19 | + | |
| 20 | +/** 라우트 경로 — 이 파일 안에서만 하드코딩하고 나머지는 이 표를 참조한다. */ | |
| 21 | +export const BOARD_PATHS: Record<BoardType, string> = { | |
| 22 | + notice: '/boards/notices', | |
| 23 | + inquiry: '/boards/inquiries', | |
| 24 | + faq: '/boards/faqs', | |
| 25 | +}; | |
| 26 | + | |
| 27 | +/** 필터 select의 "전체" 선택지 값. 빈 문자열이면 URL에서 생략되어 링크가 짧아진다. */ | |
| 28 | +export const FILTER_ALL = ''; | |
| 29 | + | |
| 30 | +export type BoardSearchFieldOption = { value: string; label: string }; | |
| 31 | + | |
| 32 | +/** | |
| 33 | + * 화면별 검색 대상. | |
| 34 | + * | |
| 35 | + * 백엔드 목록 쿼리는 제목(`searchCondition=1`) 하나만 지원하지만, 사용자 확인 결과 **전체를 받아 | |
| 36 | + * 프론트에서 필터링**하기로 해서 시안의 검색 대상을 모두 제공할 수 있다(Repository 주석 참조). | |
| 37 | + */ | |
| 38 | +export const BOARD_SEARCH_FIELDS: Record<BoardType, readonly BoardSearchFieldOption[]> = { | |
| 39 | + notice: [{ value: 'title', label: '제목' }], | |
| 40 | + inquiry: [ | |
| 41 | + { value: 'title', label: '제목' }, | |
| 42 | + { value: 'authorName', label: '질문자명' }, | |
| 43 | + { value: 'authorPhoneNumber', label: '전화번호' }, | |
| 44 | + { value: 'content', label: '내용' }, | |
| 45 | + { value: 'answerContent', label: '답변내용' }, | |
| 46 | + ], | |
| 47 | + faq: [ | |
| 48 | + { value: 'title', label: '제목' }, | |
| 49 | + { value: 'content', label: '내용' }, | |
| 50 | + ], | |
| 51 | +}; | |
| 52 | + | |
| 53 | +/** 화면별로 노출하는 필터 — 화면이 무엇을 그릴지 판단하는 근거이자, 이 파일의 검증 기준이다. */ | |
| 54 | +export const BOARD_QUERY_FIELDS: Record< | |
| 55 | + BoardType, | |
| 56 | + { category: boolean; type: boolean; visibility: boolean; answered: boolean } | |
| 57 | +> = { | |
| 58 | + notice: { category: true, type: false, visibility: true, answered: false }, | |
| 59 | + inquiry: { category: true, type: true, visibility: false, answered: true }, | |
| 60 | + faq: { category: true, type: true, visibility: false, answered: false }, | |
| 61 | +}; | |
| 62 | + | |
| 63 | +/** 화면별 유형 선택지. 공지사항은 유형 항목이 없다. */ | |
| 64 | +export const BOARD_TYPE_OPTIONS: Record< | |
| 65 | + BoardType, | |
| 66 | + readonly BoardSearchFieldOption[] | |
| 67 | +> = { | |
| 68 | + notice: [], | |
| 69 | + inquiry: INQUIRY_TYPE_OPTIONS, | |
| 70 | + faq: FAQ_TYPE_OPTIONS, | |
| 71 | +}; | |
| 72 | + | |
| 73 | +/** 사용여부 필터(공지사항) — 시안의 「전체/노출/미노출」. */ | |
| 74 | +export const VISIBILITY_FILTER_OPTIONS: readonly BoardSearchFieldOption[] = [ | |
| 75 | + { value: 'visible', label: '노출' }, | |
| 76 | + { value: 'hidden', label: '미노출' }, | |
| 77 | +]; | |
| 78 | + | |
| 79 | +/** 답변여부 필터(1:1문의) — 시안의 「전체/답변완료/미답변」. */ | |
| 80 | +export const ANSWERED_FILTER_OPTIONS: readonly BoardSearchFieldOption[] = [ | |
| 81 | + { value: 'answered', label: '답변완료' }, | |
| 82 | + { value: 'unanswered', label: '미답변' }, | |
| 83 | +]; | |
| 84 | + | |
| 85 | +export const BOARD_PAGE_SIZE_OPTIONS = [10, 30, 50] as const; | |
| 86 | +export type BoardPageSize = (typeof BOARD_PAGE_SIZE_OPTIONS)[number]; | |
| 87 | + | |
| 88 | +export const DEFAULT_BOARD_PAGE_SIZE: BoardPageSize = 10; | |
| 89 | +const DEFAULT_PAGE = 1; | |
| 90 | +const MAX_KEYWORD_LENGTH = 100; | |
| 91 | + | |
| 92 | +export type BoardPostQuery = { | |
| 93 | + /** 구분 코드. 빈 문자열이면 전체. */ | |
| 94 | + category: string; | |
| 95 | + /** 유형 코드. 빈 문자열이면 전체. */ | |
| 96 | + type: string; | |
| 97 | + /** 'visible' | 'hidden' | '' */ | |
| 98 | + visibility: string; | |
| 99 | + /** 'answered' | 'unanswered' | '' */ | |
| 100 | + answered: string; | |
| 101 | + searchField: string; | |
| 102 | + keyword: string; | |
| 103 | + page: number; | |
| 104 | + pageSize: BoardPageSize; | |
| 105 | +}; | |
| 106 | + | |
| 107 | +type RawSearchParams = Record<string, string | string[] | undefined>; | |
| 108 | + | |
| 109 | +function readParam(params: RawSearchParams, key: string): string | undefined { | |
| 110 | + const value = params[key]; | |
| 111 | + return Array.isArray(value) ? value[0] : value; | |
| 112 | +} | |
| 113 | + | |
| 114 | +/** 허용 목록에 있는 값만 통과시키고, 아니면 빈 문자열(전체)로 떨어뜨린다. */ | |
| 115 | +function readOneOf( | |
| 116 | + params: RawSearchParams, | |
| 117 | + key: string, | |
| 118 | + allowed: readonly { value: string }[] | |
| 119 | +): string { | |
| 120 | + const raw = readParam(params, key) ?? ''; | |
| 121 | + return allowed.some((option) => option.value === raw) ? raw : FILTER_ALL; | |
| 122 | +} | |
| 123 | + | |
| 124 | +function isBoardPageSize(value: number): value is BoardPageSize { | |
| 125 | + return (BOARD_PAGE_SIZE_OPTIONS as readonly number[]).includes(value); | |
| 126 | +} | |
| 127 | + | |
| 128 | +/** | |
| 129 | + * URL의 searchParams를 검증된 `BoardPostQuery`로 정규화한다. 값이 없거나 허용 목록을 벗어나면 | |
| 130 | + * 기본값으로 fallback한다 — searchParams는 사용자가 임의로 조작 가능한 값이라 신뢰하지 않는다. | |
| 131 | + * | |
| 132 | + * 화면이 쓰지 않는 필터(`BOARD_QUERY_FIELDS`)는 URL에 실려 와도 무시한다 — 예를 들어 FAQ에 | |
| 133 | + * `?visibility=hidden`을 붙여도 FAQ에는 사용여부 필터가 없으므로 결과가 달라지지 않아야 한다. | |
| 134 | + */ | |
| 135 | +export function parseBoardPostQuery( | |
| 136 | + boardType: BoardType, | |
| 137 | + searchParams: RawSearchParams | |
| 138 | +): BoardPostQuery { | |
| 139 | + const fields = BOARD_QUERY_FIELDS[boardType]; | |
| 140 | + const searchFields = BOARD_SEARCH_FIELDS[boardType]; | |
| 141 | + | |
| 142 | + const pageRaw = Number(readParam(searchParams, 'page')); | |
| 143 | + const pageSizeRaw = Number(readParam(searchParams, 'pageSize')); | |
| 144 | + const searchFieldRaw = readParam(searchParams, 'searchField') ?? ''; | |
| 145 | + | |
| 146 | + return { | |
| 147 | + category: fields.category | |
| 148 | + ? readOneOf(searchParams, 'category', BOARD_CATEGORY_OPTIONS) | |
| 149 | + : FILTER_ALL, | |
| 150 | + type: fields.type | |
| 151 | + ? readOneOf(searchParams, 'type', BOARD_TYPE_OPTIONS[boardType]) | |
| 152 | + : FILTER_ALL, | |
| 153 | + visibility: fields.visibility | |
| 154 | + ? readOneOf(searchParams, 'visibility', VISIBILITY_FILTER_OPTIONS) | |
| 155 | + : FILTER_ALL, | |
| 156 | + answered: fields.answered | |
| 157 | + ? readOneOf(searchParams, 'answered', ANSWERED_FILTER_OPTIONS) | |
| 158 | + : FILTER_ALL, | |
| 159 | + searchField: searchFields.some((field) => field.value === searchFieldRaw) | |
| 160 | + ? searchFieldRaw | |
| 161 | + : searchFields[0].value, | |
| 162 | + keyword: (readParam(searchParams, 'keyword') ?? '') | |
| 163 | + .trim() | |
| 164 | + .slice(0, MAX_KEYWORD_LENGTH), | |
| 165 | + page: Number.isInteger(pageRaw) && pageRaw > 0 ? pageRaw : DEFAULT_PAGE, | |
| 166 | + pageSize: isBoardPageSize(pageSizeRaw) | |
| 167 | + ? pageSizeRaw | |
| 168 | + : DEFAULT_BOARD_PAGE_SIZE, | |
| 169 | + }; | |
| 170 | +} | |
| 171 | + | |
| 172 | +/** | |
| 173 | + * `BoardPostQuery`(+ 부분 override)를 해당 게시판 링크로 직렬화한다. `parseBoardPostQuery`의 | |
| 174 | + * 역연산이며, 기본값과 같은 필드는 URL에서 생략해 링크를 짧게 유지한다. | |
| 175 | + */ | |
| 176 | +export function buildBoardPostHref( | |
| 177 | + boardType: BoardType, | |
| 178 | + query: BoardPostQuery, | |
| 179 | + overrides: Partial<BoardPostQuery> = {} | |
| 180 | +): string { | |
| 181 | + const merged = { ...query, ...overrides }; | |
| 182 | + const params = new URLSearchParams(); | |
| 183 | + | |
| 184 | + if (merged.category) params.set('category', merged.category); | |
| 185 | + if (merged.type) params.set('type', merged.type); | |
| 186 | + if (merged.visibility) params.set('visibility', merged.visibility); | |
| 187 | + if (merged.answered) params.set('answered', merged.answered); | |
| 188 | + if (merged.searchField !== BOARD_SEARCH_FIELDS[boardType][0].value) { | |
| 189 | + params.set('searchField', merged.searchField); | |
| 190 | + } | |
| 191 | + if (merged.keyword) params.set('keyword', merged.keyword); | |
| 192 | + if (merged.pageSize !== DEFAULT_BOARD_PAGE_SIZE) { | |
| 193 | + params.set('pageSize', String(merged.pageSize)); | |
| 194 | + } | |
| 195 | + if (merged.page !== DEFAULT_PAGE) params.set('page', String(merged.page)); | |
| 196 | + | |
| 197 | + const queryString = params.toString(); | |
| 198 | + const path = BOARD_PATHS[boardType]; | |
| 199 | + return queryString ? `${path}?${queryString}` : path; | |
| 200 | +} |
+++ lib/domain/board-post.ts
... | ... | @@ -0,0 +1,210 @@ |
| 1 | +/** | |
| 2 | + * 게시판(공지사항·1:1문의·FAQ) 도메인 타입 — 순수 데이터 표현, 외부 의존 없음. | |
| 3 | + * | |
| 4 | + * 세 화면은 **백엔드에서 같은 테이블(TB_COM_BBS)·같은 API**를 쓰고 `stngId`(게시판 설정 ID)로만 | |
| 5 | + * 갈린다. 그래서 타입과 코드 표는 여기 한 곳에 모으고, 화면별로 다른 것(검색 조건·정렬·컬럼)만 | |
| 6 | + * 각자의 query 파일에서 다룬다. | |
| 7 | + * | |
| 8 | + * 데이터 출처: `GET /api/v1/mngr/bbs/pagination` (edupay-backend develop 924db37) | |
| 9 | + * | |
| 10 | + * **`null`의 의미는 "백엔드가 아직 주지 않는 항목"이다.** 노출기간·앱푸쉬는 등록/수정으로 저장은 | |
| 11 | + * 되지만 조회 SQL의 select 목록에 빠져 있어 다시 읽으면 항상 비어 있다(사용자 확인 후 "입력은 | |
| 12 | + * 만들고 전송하되 조회는 빈 값" 방침). 백엔드가 select에 컬럼을 추가하면 Repository 매핑만 | |
| 13 | + * 늘리면 값이 그대로 채워진다. | |
| 14 | + */ | |
| 15 | + | |
| 16 | +/** 세 화면을 구분하는 값 — 라우트·API 파라미터·코드 표의 키를 겸한다. */ | |
| 17 | +export type BoardType = 'notice' | 'inquiry' | 'faq'; | |
| 18 | + | |
| 19 | +/** | |
| 20 | + * 백엔드 `stngId`(게시판 설정 ID) — **임시값이다.** | |
| 21 | + * | |
| 22 | + * 백엔드에 게시판 설정 목록 API도 테이블도 없고 소스에는 예시값 하나 | |
| 23 | + * (`5e9ae040df1b41c1b03ce11d1bafcc60`)만 있어 세 게시판 중 무엇인지 알 수 없다. 사용자 확인 결과 | |
| 24 | + * **"추후 백엔드에 추가 예정, 지금은 임의값으로 개발"** 이라 아래 값을 쓴다. | |
| 25 | + * | |
| 26 | + * 실제 값이 나오면 이 표만 고치면 된다 — 나머지 코드는 `BoardType`만 다루고 이 값을 직접 알지 | |
| 27 | + * 못한다. 그때까지 목록 조회는 빈 결과가 정상이다(존재하지 않는 stngId라 매칭되는 행이 없다). | |
| 28 | + */ | |
| 29 | +export const BOARD_SETTING_IDS: Record<BoardType, string> = { | |
| 30 | + notice: 'TEMP_STNG_ID_NOTICE', | |
| 31 | + inquiry: 'TEMP_STNG_ID_INQUIRY', | |
| 32 | + faq: 'TEMP_STNG_ID_FAQ', | |
| 33 | +}; | |
| 34 | + | |
| 35 | +/** | |
| 36 | + * 파일 업로드 API(`POST /api/v1/common/file/upload/{moduleId}`)의 moduleId — **임시값이다.** | |
| 37 | + * 백엔드에서 이 값은 파일 저장 설정(`FileStrgStngVo.strgStngId`)을 가리키며, 설정에 없는 값이면 | |
| 38 | + * `UNKNOWN` 경로로 떨어진다(EgovFileMngUtil). 실제 설정 ID가 정해지면 여기만 고친다. | |
| 39 | + */ | |
| 40 | +export const BOARD_FILE_MODULE_ID = 'MODULE_BBS'; | |
| 41 | + | |
| 42 | +export type BoardPost = { | |
| 43 | + /** 백엔드 `bbsId`. 행의 key이자 단건 조회·수정·삭제의 입력값이다. */ | |
| 44 | + id: string; | |
| 45 | + /** 제목 — 백엔드 `bbsNm`. */ | |
| 46 | + title: string; | |
| 47 | + /** 본문 — 백엔드 `bbsCn`. */ | |
| 48 | + content: string; | |
| 49 | + /** 구분 코드 — 백엔드 `bbsCd`. */ | |
| 50 | + categoryCode: string; | |
| 51 | + /** | |
| 52 | + * 유형 코드(FAQ의 유형 / 1:1문의의 질문유형). | |
| 53 | + * **백엔드에 아직 저장 필드가 없다** — `BOARD_TYPE_CODE_PARAM` 주석 참조. 조회 시 항상 null이다. | |
| 54 | + */ | |
| 55 | + typeCode: string | null; | |
| 56 | + /** 첨부파일 식별자 — 백엔드 `atchFileId`. 파일명·용량 조회 API가 없어 존재 여부만 알 수 있다. */ | |
| 57 | + attachmentId: string | null; | |
| 58 | + /** 상단고정 — 백엔드 `hghrkYn`. */ | |
| 59 | + isPinned: boolean | null; | |
| 60 | + /** 사용여부(노출여부) — 백엔드 `rlsYn`. */ | |
| 61 | + isVisible: boolean | null; | |
| 62 | + /** 노출 시작일. 백엔드 조회 응답에 없어 현재는 항상 null이다(파일 상단 주석). */ | |
| 63 | + startDate: string | null; | |
| 64 | + /** 노출 종료일. 위와 같다. */ | |
| 65 | + endDate: string | null; | |
| 66 | + /** 앱푸쉬 발송 여부. 위와 같다. */ | |
| 67 | + isPushEnabled: boolean | null; | |
| 68 | + /** 작성자 표기 — 백엔드 `regNm`(이름(로그인ID) 형태로 이미 조합돼 온다). */ | |
| 69 | + authorName: string | null; | |
| 70 | + /** 작성일(YYYY-MM-DD) — 백엔드 `regDt`. */ | |
| 71 | + createdAt: string | null; | |
| 72 | + /** 조회수 — 백엔드 `inqCnt`. */ | |
| 73 | + viewCount: number | null; | |
| 74 | + | |
| 75 | + /* ── 1:1문의 전용 ── */ | |
| 76 | + /** 질문자 연락처 — 백엔드 `telNo`. */ | |
| 77 | + authorPhoneNumber: string | null; | |
| 78 | + /** 질문자 이메일 — 백엔드 `emlAddr`. */ | |
| 79 | + authorEmail: string | null; | |
| 80 | + /** 답변 내용 — 백엔드 `ansCn`. */ | |
| 81 | + answerContent: string | null; | |
| 82 | + /** 답변일 — 백엔드 `ansDt`. */ | |
| 83 | + answeredAt: string | null; | |
| 84 | + /** 답변자 표기 — 백엔드 `answrNm`. */ | |
| 85 | + answererName: string | null; | |
| 86 | + /** 진행상태 코드 — 백엔드 `ansSttsCd`. */ | |
| 87 | + answerStatusCode: string | null; | |
| 88 | +}; | |
| 89 | + | |
| 90 | +/** 값이 없는 항목의 화면 표기. 표·팝업이 같은 문자를 쓰도록 여기 한 곳에 둔다. */ | |
| 91 | +export const EMPTY_FIELD_PLACEHOLDER = '-'; | |
| 92 | + | |
| 93 | +export function formatOptionalValue(value: string | number | null): string { | |
| 94 | + return value === null || value === '' ? EMPTY_FIELD_PLACEHOLDER : String(value); | |
| 95 | +} | |
| 96 | + | |
| 97 | +/** | |
| 98 | + * 「유형」을 실어 보낼 요청 파라미터 이름 — **임시 이름이다.** | |
| 99 | + * | |
| 100 | + * 시안은 구분과 유형을 별도 항목으로 요구하지만 백엔드에는 `BBS_CD` 하나뿐이고, 등록/수정 요청 | |
| 101 | + * VO(`MngrBbsRequestVo`)에는 그마저도 없다. 사용자 확인 결과 **"백엔드가 필드를 추가할 예정이니 | |
| 102 | + * 프론트는 미리 구현"** 이라, 구분은 `bbsCd`로 유형은 이 이름으로 보낸다. | |
| 103 | + * | |
| 104 | + * 백엔드가 실제 이름을 확정하면 이 상수 한 줄만 고치면 된다. 그전까지 백엔드는 이 파라미터를 | |
| 105 | + * 무시하므로 **유형은 저장되지 않는다**(화면 입력과 전송은 정상 동작한다). | |
| 106 | + */ | |
| 107 | +export const BOARD_TYPE_CODE_PARAM = 'bbsTypeCd'; | |
| 108 | + | |
| 109 | +type CodeOption = { value: string; label: string }; | |
| 110 | + | |
| 111 | +/** | |
| 112 | + * 「구분」 코드 표 — **임시값이다.** 백엔드에 코드 상수도 코드테이블도 없어 시안의 라벨을 기준으로 | |
| 113 | + * 잠정 코드를 붙였다. 실제 코드가 확정되면 이 표만 교체한다. | |
| 114 | + * | |
| 115 | + * 시안의 공지사항 구분은 "공통 / 메뉴명1, 메뉴명2…"이고 FAQ는 "공통 / FOX PAY"다. 확정되지 않은 | |
| 116 | + * "메뉴명N"은 임의로 지어내지 않고 두 화면이 공통으로 쓰는 값만 둔다. | |
| 117 | + */ | |
| 118 | +export const BOARD_CATEGORY_OPTIONS: readonly CodeOption[] = [ | |
| 119 | + { value: 'COMMON', label: '공통' }, | |
| 120 | + { value: 'FOXPAY', label: 'FOX PAY' }, | |
| 121 | +]; | |
| 122 | + | |
| 123 | +/** FAQ의 「유형」 — 시안 A_BOA_015_p ②. 시안에도 "추후 재정의 필요"로 적혀 있어 잠정값이다. */ | |
| 124 | +export const FAQ_TYPE_OPTIONS: readonly CodeOption[] = [ | |
| 125 | + { value: 'SIGNUP', label: '회원가입&로그인' }, | |
| 126 | + { value: 'PAYMENT', label: '결제관련' }, | |
| 127 | + { value: 'STUDENT_CARD', label: '학생증' }, | |
| 128 | + { value: 'DECO_ITEM', label: '꾸미기아이템' }, | |
| 129 | + { value: 'MERCHANT', label: '가맹점관련' }, | |
| 130 | + { value: 'ETC', label: '기타' }, | |
| 131 | +]; | |
| 132 | + | |
| 133 | +/** 1:1문의의 「질문유형」 — 시안 A_BOA_012. 잠정값이다. */ | |
| 134 | +export const INQUIRY_TYPE_OPTIONS: readonly CodeOption[] = [ | |
| 135 | + { value: 'SIGNUP', label: '회원가입/로그인' }, | |
| 136 | + { value: 'ERROR', label: '오류신고' }, | |
| 137 | + { value: 'ETC', label: '기타' }, | |
| 138 | +]; | |
| 139 | + | |
| 140 | +/** | |
| 141 | + * 1:1문의 「진행상태」(`ansSttsCd`) — **임시값이다.** 백엔드에 코드 정의가 없어 사용자 확인 후 | |
| 142 | + * 잠정 코드를 한 곳에 모아 두는 방침으로 정했다. 실제 코드가 나오면 이 표만 교체한다. | |
| 143 | + */ | |
| 144 | +export const ANSWER_STATUS_OPTIONS: readonly CodeOption[] = [ | |
| 145 | + { value: 'WAIT', label: '답변대기' }, | |
| 146 | + { value: 'ING', label: '처리중' }, | |
| 147 | + { value: 'DONE', label: '답변완료' }, | |
| 148 | +]; | |
| 149 | + | |
| 150 | +/** 답변완료 상태 코드 — "답변일 표시"·"답변 완료 시 푸쉬" 판단이 이 값을 참조한다. */ | |
| 151 | +export const ANSWER_STATUS_DONE = 'DONE'; | |
| 152 | + | |
| 153 | +/** 코드 → 라벨. 표에 없는 코드는 지어내지 않고 코드 그대로 노출한다(잘못된 라벨보다 낫다). */ | |
| 154 | +function formatCode(options: readonly CodeOption[], code: string | null): string { | |
| 155 | + if (code === null || code === '') { | |
| 156 | + return EMPTY_FIELD_PLACEHOLDER; | |
| 157 | + } | |
| 158 | + return options.find((option) => option.value === code)?.label ?? code; | |
| 159 | +} | |
| 160 | + | |
| 161 | +export function formatBoardCategoryLabel(code: string | null): string { | |
| 162 | + return formatCode(BOARD_CATEGORY_OPTIONS, code); | |
| 163 | +} | |
| 164 | + | |
| 165 | +export function formatFaqTypeLabel(code: string | null): string { | |
| 166 | + return formatCode(FAQ_TYPE_OPTIONS, code); | |
| 167 | +} | |
| 168 | + | |
| 169 | +export function formatInquiryTypeLabel(code: string | null): string { | |
| 170 | + return formatCode(INQUIRY_TYPE_OPTIONS, code); | |
| 171 | +} | |
| 172 | + | |
| 173 | +export function formatAnswerStatusLabel(code: string | null): string { | |
| 174 | + return formatCode(ANSWER_STATUS_OPTIONS, code); | |
| 175 | +} | |
| 176 | + | |
| 177 | +/** 사용여부 표기 — 시안의 「노출 / 미노출」. */ | |
| 178 | +export function formatVisibilityLabel(isVisible: boolean | null): string { | |
| 179 | + if (isVisible === null) { | |
| 180 | + return EMPTY_FIELD_PLACEHOLDER; | |
| 181 | + } | |
| 182 | + return isVisible ? '노출' : '미노출'; | |
| 183 | +} | |
| 184 | + | |
| 185 | +/** | |
| 186 | + * 1:1문의 목록의 전화번호 마스킹(시안 A_BOA_012 — `010-12**-**78`). | |
| 187 | + * | |
| 188 | + * 사용자 확인 후 **적용**하기로 한 항목이다(학생 회원 목록은 반대로 "마스킹 없음"이 지시였다 — | |
| 189 | + * 화면마다 방침이 다르므로 이 함수를 1:1문의에서만 쓴다). | |
| 190 | + * | |
| 191 | + * 가운데 자리는 앞 2자리만, 끝자리는 뒤 2자리만 남긴다. 형식이 `nnn-nnnn-nnnn`이 아니면 마스킹 | |
| 192 | + * 규칙을 적용할 자리를 알 수 없으므로 **전부 가린다** — 규칙 밖의 값을 원본 그대로 흘리는 것이 | |
| 193 | + * 마스킹의 목적에 어긋나기 때문이다. | |
| 194 | + */ | |
| 195 | +export function maskPhoneNumber(phoneNumber: string | null): string { | |
| 196 | + if (phoneNumber === null || phoneNumber === '') { | |
| 197 | + return EMPTY_FIELD_PLACEHOLDER; | |
| 198 | + } | |
| 199 | + | |
| 200 | + const parts = phoneNumber.split('-'); | |
| 201 | + if (parts.length !== 3) { | |
| 202 | + return '***'; | |
| 203 | + } | |
| 204 | + | |
| 205 | + const [prefix, middle, last] = parts; | |
| 206 | + const maskedMiddle = middle.slice(0, 2).padEnd(middle.length, '*'); | |
| 207 | + const maskedLast = '*'.repeat(Math.max(0, last.length - 2)) + last.slice(-2); | |
| 208 | + | |
| 209 | + return `${prefix}-${maskedMiddle}-${maskedLast}`; | |
| 210 | +} |
--- lib/http/backend-fetch.ts
+++ lib/http/backend-fetch.ts
... | ... | @@ -50,8 +50,21 @@ |
| 50 | 50 |
} |
| 51 | 51 |
|
| 52 | 52 |
type BackendRequestInit = {
|
| 53 |
- method: 'GET' | 'POST'; |
|
| 53 |
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE'; |
|
| 54 | 54 |
body?: unknown; |
| 55 |
+ /** |
|
| 56 |
+ * 폼 인코딩(`application/x-www-form-urlencoded`) 본문. |
|
| 57 |
+ * |
|
| 58 |
+ * 백엔드 컨트롤러 중에는 요청 VO에 `@RequestBody`가 없는 것들이 있다(예: 게시판 등록 |
|
| 59 |
+ * `MngrBbsApiController.insert`). 그런 엔드포인트는 JSON 본문을 읽지 못하고 요청 파라미터로만 |
|
| 60 |
+ * 바인딩하므로 폼으로 보내야 한다. `body`와 함께 쓰지 않는다. |
|
| 61 |
+ */ |
|
| 62 |
+ form?: Record<string, string>; |
|
| 63 |
+ /** |
|
| 64 |
+ * 멀티파트 본문(파일 업로드). `Content-Type`을 **직접 지정하지 않는다** — 헤더에는 파트 경계 |
|
| 65 |
+ * (boundary) 문자열이 함께 들어가야 하는데 그 값은 런타임이 FormData에서 생성하기 때문이다. |
|
| 66 |
+ */ |
|
| 67 |
+ multipart?: FormData; |
|
| 55 | 68 |
/** 쿼리 스트링 파라미터. 값은 문자열로 직렬화해 붙인다. */ |
| 56 | 69 |
query?: Record<string, string | number>; |
| 57 | 70 |
/** |
... | ... | @@ -63,6 +76,8 @@ |
| 63 | 76 |
/** Next.js `fetch` 확장 옵션 — 호출부가 캐시 전략을 명시하는 용도. 둘 중 하나만 지정한다. */ |
| 64 | 77 |
cache?: RequestCache; |
| 65 | 78 |
next?: { revalidate?: number | false; tags?: string[] };
|
| 79 |
+ /** 기본 타임아웃보다 오래 걸리는 호출(파일 업로드 등)이 값을 올려 잡는다. */ |
|
| 80 |
+ timeoutMs?: number; |
|
| 66 | 81 |
}; |
| 67 | 82 |
|
| 68 | 83 |
function communicationError(reason: string, detail: unknown): BackendResult<never> {
|
... | ... | @@ -161,23 +176,51 @@ |
| 161 | 176 |
return { ok: true, data: response };
|
| 162 | 177 |
} |
| 163 | 178 |
|
| 164 |
-/** 백엔드 REST 호출 단일 진입점. */ |
|
| 165 |
-export async function backendFetch<T>( |
|
| 179 |
+/** 본문 인코딩 선택 — 멀티파트 > 폼 > JSON 순으로 우선한다. */ |
|
| 180 |
+function buildRequestBody(init: BackendRequestInit): {
|
|
| 181 |
+ headers: Record<string, string>; |
|
| 182 |
+ body: string | FormData | undefined; |
|
| 183 |
+} {
|
|
| 184 |
+ if (init.multipart !== undefined) {
|
|
| 185 |
+ // Content-Type을 비워 둬야 런타임이 boundary를 포함해 채워 넣는다(위 주석 참조). |
|
| 186 |
+ return { headers: {}, body: init.multipart };
|
|
| 187 |
+ } |
|
| 188 |
+ |
|
| 189 |
+ if (init.form !== undefined) {
|
|
| 190 |
+ return {
|
|
| 191 |
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
|
|
| 192 |
+ body: new URLSearchParams(init.form).toString(), |
|
| 193 |
+ }; |
|
| 194 |
+ } |
|
| 195 |
+ |
|
| 196 |
+ return {
|
|
| 197 |
+ headers: { 'Content-Type': 'application/json' },
|
|
| 198 |
+ body: init.body !== undefined ? JSON.stringify(init.body) : undefined, |
|
| 199 |
+ }; |
|
| 200 |
+} |
|
| 201 |
+ |
|
| 202 |
+/** |
|
| 203 |
+ * 요청 전송 + 실패 정규화까지의 공통 경로. 성공 시 파싱된 봉투를 그대로 돌려주고, `data`를 |
|
| 204 |
+ * 어떻게 다룰지(필수인지 없어도 되는지)는 호출부인 `backendFetch`/`backendCommand`가 정한다. |
|
| 205 |
+ */ |
|
| 206 |
+async function sendBackendRequest( |
|
| 166 | 207 |
path: string, |
| 167 | 208 |
init: BackendRequestInit |
| 168 |
-): Promise<BackendResult<T>> {
|
|
| 209 |
+): Promise<BackendResult<BackendEnvelope<unknown>>> {
|
|
| 210 |
+ const { headers, body } = buildRequestBody(init);
|
|
| 211 |
+ |
|
| 169 | 212 |
let response: Response; |
| 170 | 213 |
try {
|
| 171 | 214 |
response = await fetch(resolveUrl(path, init.query), {
|
| 172 | 215 |
method: init.method, |
| 173 | 216 |
headers: {
|
| 174 |
- 'Content-Type': 'application/json', |
|
| 217 |
+ ...headers, |
|
| 175 | 218 |
...(init.accessToken |
| 176 | 219 |
? { Authorization: `Bearer ${init.accessToken}` }
|
| 177 | 220 |
: {}),
|
| 178 | 221 |
}, |
| 179 |
- body: init.body !== undefined ? JSON.stringify(init.body) : undefined, |
|
| 180 |
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), |
|
| 222 |
+ body, |
|
| 223 |
+ signal: AbortSignal.timeout(init.timeoutMs ?? REQUEST_TIMEOUT_MS), |
|
| 181 | 224 |
cache: init.cache, |
| 182 | 225 |
next: init.next, |
| 183 | 226 |
}); |
... | ... | @@ -204,16 +247,53 @@ |
| 204 | 247 |
return communicationError(`예상치 못한 HTTP 상태: ${response.status}`, undefined);
|
| 205 | 248 |
} |
| 206 | 249 |
|
| 207 |
- let envelope: BackendEnvelope<T>; |
|
| 250 |
+ let envelope: BackendEnvelope<unknown>; |
|
| 208 | 251 |
try {
|
| 209 |
- envelope = (await response.json()) as BackendEnvelope<T>; |
|
| 252 |
+ envelope = (await response.json()) as BackendEnvelope<unknown>; |
|
| 210 | 253 |
} catch (error) {
|
| 211 | 254 |
return communicationError('응답 파싱 실패', error);
|
| 212 | 255 |
} |
| 213 | 256 |
|
| 214 |
- if (!envelope.success || envelope.data === null) {
|
|
| 257 |
+ if (!envelope.success) {
|
|
| 215 | 258 |
return { ok: false, code: envelope.code, message: envelope.message };
|
| 216 | 259 |
} |
| 217 | 260 |
|
| 218 |
- return { ok: true, data: envelope.data };
|
|
| 261 |
+ return { ok: true, data: envelope };
|
|
| 262 |
+} |
|
| 263 |
+ |
|
| 264 |
+/** |
|
| 265 |
+ * 백엔드 REST 호출 단일 진입점 — **응답 데이터를 기대하는 호출용**이다. |
|
| 266 |
+ * 봉투의 `data`가 없으면 실패로 본다(조회인데 실을 것이 없다면 계약 위반이다). |
|
| 267 |
+ */ |
|
| 268 |
+export async function backendFetch<T>( |
|
| 269 |
+ path: string, |
|
| 270 |
+ init: BackendRequestInit |
|
| 271 |
+): Promise<BackendResult<T>> {
|
|
| 272 |
+ const result = await sendBackendRequest(path, init); |
|
| 273 |
+ if (!result.ok) {
|
|
| 274 |
+ return result; |
|
| 275 |
+ } |
|
| 276 |
+ |
|
| 277 |
+ const envelope = result.data; |
|
| 278 |
+ if (envelope.data === null || envelope.data === undefined) {
|
|
| 279 |
+ return { ok: false, code: envelope.code, message: envelope.message };
|
|
| 280 |
+ } |
|
| 281 |
+ |
|
| 282 |
+ return { ok: true, data: envelope.data as T };
|
|
| 283 |
+} |
|
| 284 |
+ |
|
| 285 |
+/** |
|
| 286 |
+ * **응답 데이터가 없는 쓰기 호출용**(등록·수정·삭제). 성공 판정은 봉투의 `success`만 본다. |
|
| 287 |
+ * |
|
| 288 |
+ * `backendFetch`와 나눈 이유: 백엔드의 쓰기 API 상당수가 `ApiResponseVO.success(null)`을 반환한다 |
|
| 289 |
+ * (예: 게시판 등록·수정·삭제). `backendFetch`는 `data`가 없으면 실패로 보므로 그대로 쓰면 **성공한 |
|
| 290 |
+ * 요청이 전부 실패로 보고된다.** 두 규약을 한 함수에 섞으면 조회 쪽의 계약 위반 감지가 무뎌지므로 |
|
| 291 |
+ * 함수를 나눴다. |
|
| 292 |
+ */ |
|
| 293 |
+export async function backendCommand( |
|
| 294 |
+ path: string, |
|
| 295 |
+ init: BackendRequestInit |
|
| 296 |
+): Promise<BackendResult<null>> {
|
|
| 297 |
+ const result = await sendBackendRequest(path, init); |
|
| 298 |
+ return result.ok ? { ok: true, data: null } : result;
|
|
| 219 | 299 |
} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?