feat: 1:1문의 진행상태를 공통코드 BBS_ANS_CD로 표기
상태를 프론트 상수 표(`WAIT`/`ING`/`DONE`)로 치환하고 있었다. 그 표를 지우고 공통코드 `BBS_ANS_CD`를 조회해 코드명으로 옮긴다 — 꾸미기 아이템 카테고리(`ITEM_CATE_CD`)와 같은 방식이다. 목록의 "상태" 열과 답변 팝업의 진행상태 선택지, Server Action의 허용 코드 검증이 모두 같은 응답을 본다. 페이지가 한 번 조회해 목록으로 내리고, Server Action은 저장 직전에 다시 조회한다 (화면이 보낸 코드를 그대로 믿지 않기 위해서다 — 아이템 카테고리와 같은 근거). `ANSWER_STATUS_DONE`만 상수로 남는다. "답변완료일 때만 FO 노출·답변일 표시"라는 판단이 특정 코드값을 알아야 하는데 BBS_ANS_CD의 실제 값을 아직 확인하지 못해 TODO로 표시해 두었다. Co-Authored-By: Claude Opus 5
@c115e10bbfaf325bba0f9a77441680832c36c5dc
--- app/(protected)/(basic)/boards/_actions.ts
+++ app/(protected)/(basic)/boards/_actions.ts
... | ... | @@ -2,6 +2,8 @@ |
| 2 | 2 |
|
| 3 | 3 |
import { revalidatePath } from 'next/cache';
|
| 4 | 4 |
import { verifySession } from '@/lib/auth/dal';
|
| 5 |
+import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository';
|
|
| 6 |
+import { CODE_GROUP } from '@/lib/domain/common-code';
|
|
| 5 | 7 |
import {
|
| 6 | 8 |
createBoardPost, |
| 7 | 9 |
deleteBoardPost, |
... | ... | @@ -236,7 +238,12 @@ |
| 236 | 238 |
answerContent: readString(formData, 'answerContent'), |
| 237 | 239 |
attachmentId, |
| 238 | 240 |
}, |
| 239 |
- { doneStatusCode: ANSWER_STATUS_DONE }
|
|
| 241 |
+ {
|
|
| 242 |
+ doneStatusCode: ANSWER_STATUS_DONE, |
|
| 243 |
+ allowedStatusCodes: ( |
|
| 244 |
+ await fetchCommonCodes(CODE_GROUP.inquiryAnswerStatus) |
|
| 245 |
+ ).map((code) => code.code), |
|
| 246 |
+ } |
|
| 240 | 247 |
); |
| 241 | 248 |
if (!validation.ok) {
|
| 242 | 249 |
return { status: 'error', errors: validation.errors };
|
--- app/(protected)/(basic)/boards/_components/board-post-columns.tsx
+++ app/(protected)/(basic)/boards/_components/board-post-columns.tsx
... | ... | @@ -4,6 +4,7 @@ |
| 4 | 4 |
import { FoxIconButton } from '@fox/core/components/fox-icon-button';
|
| 5 | 5 |
import { FoxStatusIndicator } from '@fox/core/components/fox-status-indicator';
|
| 6 | 6 |
import type { FoxListColumn } from '@fox/core/components/fox-list-container';
|
| 7 |
+import type { CommonCode } from '@/lib/domain/common-code';
|
|
| 7 | 8 |
import { FoxPaperclipIcon } from '@fox/core/icons';
|
| 8 | 9 |
import { formatPhoneNumber } from '@/lib/domain/phone-number';
|
| 9 | 10 |
import {
|
... | ... | @@ -137,7 +138,16 @@ |
| 137 | 138 |
]; |
| 138 | 139 |
} |
| 139 | 140 |
|
| 140 |
-function inquiryColumns(page: number, pageSize: number): FoxListColumn<BoardPost>[] {
|
|
| 141 |
+function inquiryColumns( |
|
| 142 |
+ page: number, |
|
| 143 |
+ pageSize: number, |
|
| 144 |
+ answerStatuses: readonly CommonCode[] |
|
| 145 |
+): FoxListColumn<BoardPost>[] {
|
|
| 146 |
+ const statusOptions = answerStatuses.map((code) => ({
|
|
| 147 |
+ value: code.code, |
|
| 148 |
+ label: code.label, |
|
| 149 |
+ })); |
|
| 150 |
+ |
|
| 141 | 151 |
return [ |
| 142 | 152 |
numberColumn(page, pageSize), |
| 143 | 153 |
{
|
... | ... | @@ -172,7 +182,7 @@ |
| 172 | 182 |
render: (row) => ( |
| 173 | 183 |
<FoxStatusIndicator |
| 174 | 184 |
type={row.answeredAt ? 'success' : 'warning'}
|
| 175 |
- label={formatAnswerStatusLabel(row.answerStatusCode)}
|
|
| 185 |
+ label={formatAnswerStatusLabel(row.answerStatusCode, statusOptions)}
|
|
| 176 | 186 |
/> |
| 177 | 187 |
), |
| 178 | 188 |
}, |
... | ... | @@ -186,7 +196,9 @@ |
| 186 | 196 |
key: 'actions', |
| 187 | 197 |
header: '관리', |
| 188 | 198 |
width: 160, |
| 189 |
- render: (row) => <InquiryRowActions post={row} />,
|
|
| 199 |
+ render: (row) => ( |
|
| 200 |
+ <InquiryRowActions post={row} answerStatuses={answerStatuses} />
|
|
| 201 |
+ ), |
|
| 190 | 202 |
}, |
| 191 | 203 |
]; |
| 192 | 204 |
} |
... | ... | @@ -194,9 +206,10 @@ |
| 194 | 206 |
export function boardPostColumns( |
| 195 | 207 |
boardType: BoardType, |
| 196 | 208 |
page: number, |
| 197 |
- pageSize: number |
|
| 209 |
+ pageSize: number, |
|
| 210 |
+ answerStatuses: readonly CommonCode[] = [] |
|
| 198 | 211 |
): FoxListColumn<BoardPost>[] {
|
| 199 | 212 |
if (boardType === 'notice') return noticeColumns(page, pageSize); |
| 200 | 213 |
if (boardType === 'faq') return faqColumns(page, pageSize); |
| 201 |
- return inquiryColumns(page, pageSize); |
|
| 214 |
+ return inquiryColumns(page, pageSize, answerStatuses); |
|
| 202 | 215 |
} |
--- app/(protected)/(basic)/boards/_components/board-post-list.tsx
+++ app/(protected)/(basic)/boards/_components/board-post-list.tsx
... | ... | @@ -10,6 +10,7 @@ |
| 10 | 10 |
import { FoxChipArea } from '@fox/core/components/fox-chip-area';
|
| 11 | 11 |
import { FoxArrowsClockwiseIcon, FoxPlusIcon } from '@fox/core/icons';
|
| 12 | 12 |
import type { BoardPost, BoardType } from '@/lib/domain/board-post';
|
| 13 |
+import type { CommonCode } from '@/lib/domain/common-code';
|
|
| 13 | 14 |
import {
|
| 14 | 15 |
BOARD_PAGE_SIZE_OPTIONS, |
| 15 | 16 |
BOARD_SEARCH_FIELDS, |
... | ... | @@ -36,6 +37,8 @@ |
| 36 | 37 |
canCreate?: boolean; |
| 37 | 38 |
/** 행의 "관리" 열을 화면마다 다르게 그려야 할 때. */ |
| 38 | 39 |
extraToolbar?: ReactNode; |
| 40 |
+ /** 공통코드 `BBS_ANS_CD` — 1:1문의 진행상태 표기·선택지. */ |
|
| 41 |
+ answerStatuses?: readonly CommonCode[]; |
|
| 39 | 42 |
} |
| 40 | 43 |
|
| 41 | 44 |
const SORT_OPTIONS = [{ value: 'recent', label: '최근등록순' }];
|
... | ... | @@ -51,6 +54,7 @@ |
| 51 | 54 |
totalCount, |
| 52 | 55 |
canCreate = true, |
| 53 | 56 |
extraToolbar, |
| 57 |
+ answerStatuses, |
|
| 54 | 58 |
}: BoardPostListProps) {
|
| 55 | 59 |
const router = useRouter(); |
| 56 | 60 |
const [isCreateOpen, setIsCreateOpen] = useState(false); |
... | ... | @@ -66,7 +70,12 @@ |
| 66 | 70 |
title={title}
|
| 67 | 71 |
breadcrumb={breadcrumb}
|
| 68 | 72 |
caption={title}
|
| 69 |
- columns={boardPostColumns(boardType, currentPage, query.pageSize)}
|
|
| 73 |
+ columns={boardPostColumns(
|
|
| 74 |
+ boardType, |
|
| 75 |
+ currentPage, |
|
| 76 |
+ query.pageSize, |
|
| 77 |
+ answerStatuses |
|
| 78 |
+ )} |
|
| 70 | 79 |
rows={items}
|
| 71 | 80 |
rowKey={(row) => row.id}
|
| 72 | 81 |
totalCount={totalCount}
|
--- app/(protected)/(basic)/boards/inquiries/_components/inquiry-answer-modal.tsx
+++ app/(protected)/(basic)/boards/inquiries/_components/inquiry-answer-modal.tsx
... | ... | @@ -10,7 +10,6 @@ |
| 10 | 10 |
import { submitFormAction } from '@/app/_hooks/submit-form-action';
|
| 11 | 11 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 12 | 12 |
import {
|
| 13 |
- ANSWER_STATUS_OPTIONS, |
|
| 14 | 13 |
formatInquiryTypeLabel, |
| 15 | 14 |
formatOptionalValue, |
| 16 | 15 |
type BoardPost, |
... | ... | @@ -19,12 +18,15 @@ |
| 19 | 18 |
ANSWER_MAX_LENGTH, |
| 20 | 19 |
INITIAL_INQUIRY_ANSWER_FORM_STATE, |
| 21 | 20 |
} from '@/lib/domain/board-post-form'; |
| 21 |
+import type { CommonCode } from '@/lib/domain/common-code';
|
|
| 22 | 22 |
import { saveInquiryAnswerAction } from '../../_actions';
|
| 23 | 23 |
import { BoardAttachmentField } from '../../_components/board-attachment-field';
|
| 24 | 24 |
import styles from './inquiry-answer-modal.module.scss'; |
| 25 | 25 |
|
| 26 | 26 |
interface InquiryAnswerModalProps {
|
| 27 | 27 |
post: BoardPost; |
| 28 |
+ /** 공통코드 `BBS_ANS_CD` — 진행상태 선택지. */ |
|
| 29 |
+ answerStatuses: readonly CommonCode[]; |
|
| 28 | 30 |
onClose: () => void; |
| 29 | 31 |
} |
| 30 | 32 |
|
... | ... | @@ -35,11 +37,15 @@ |
| 35 | 37 |
* 이슈: 기획의 "답변완료 시 앱푸쉬 전송"은 백엔드 푸시 분기가 빈 블록이라 동작하지 않는다. |
| 36 | 38 |
* 이슈: 백엔드 첨부 필드가 `atchFileId` 하나뿐이라 질문 첨부와 답변 첨부가 같은 자리를 쓴다. |
| 37 | 39 |
*/ |
| 38 |
-export function InquiryAnswerModal({ post, onClose }: InquiryAnswerModalProps) {
|
|
| 40 |
+export function InquiryAnswerModal({
|
|
| 41 |
+ post, |
|
| 42 |
+ answerStatuses, |
|
| 43 |
+ onClose, |
|
| 44 |
+}: InquiryAnswerModalProps) {
|
|
| 39 | 45 |
const { showToast } = useFeedback();
|
| 40 | 46 |
const formRef = useRef<HTMLFormElement>(null); |
| 41 | 47 |
const [statusCode, setStatusCode] = useState( |
| 42 |
- post.answerStatusCode ?? ANSWER_STATUS_OPTIONS[0].value |
|
| 48 |
+ post.answerStatusCode ?? answerStatuses[0]?.code ?? '' |
|
| 43 | 49 |
); |
| 44 | 50 |
const [state, formAction, isPending] = useActionState( |
| 45 | 51 |
saveInquiryAnswerAction, |
... | ... | @@ -149,9 +155,9 @@ |
| 149 | 155 |
size="md" |
| 150 | 156 |
label="진행상태" |
| 151 | 157 |
requirement="required" |
| 152 |
- options={ANSWER_STATUS_OPTIONS.map((option) => ({
|
|
| 153 |
- value: option.value, |
|
| 154 |
- label: option.label, |
|
| 158 |
+ options={answerStatuses.map((status) => ({
|
|
| 159 |
+ value: status.code, |
|
| 160 |
+ label: status.label, |
|
| 155 | 161 |
}))} |
| 156 | 162 |
value={statusCode}
|
| 157 | 163 |
onValueChange={setStatusCode}
|
--- app/(protected)/(basic)/boards/inquiries/_components/inquiry-row-actions.tsx
+++ app/(protected)/(basic)/boards/inquiries/_components/inquiry-row-actions.tsx
... | ... | @@ -7,11 +7,13 @@ |
| 7 | 7 |
import { FoxPencilSimpleIcon, FoxTrashIcon } from '@fox/core/icons';
|
| 8 | 8 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 9 | 9 |
import type { BoardPost } from '@/lib/domain/board-post';
|
| 10 |
+import type { CommonCode } from '@/lib/domain/common-code';
|
|
| 10 | 11 |
import { deleteBoardPostAction } from '../../_actions';
|
| 11 | 12 |
import { InquiryAnswerModal } from './inquiry-answer-modal';
|
| 12 | 13 |
|
| 13 | 14 |
interface InquiryRowActionsProps {
|
| 14 | 15 |
post: BoardPost; |
| 16 |
+ answerStatuses: readonly CommonCode[]; |
|
| 15 | 17 |
} |
| 16 | 18 |
|
| 17 | 19 |
/** |
... | ... | @@ -20,7 +22,10 @@ |
| 20 | 22 |
* 공지사항·FAQ의 `BoardRowActions`와 나눈 이유: 여기서 여는 팝업이 수정 폼이 아니라 **상세 및 답변** |
| 21 | 23 |
* 팝업이고(관리자는 질문 글을 수정하지 않는다), 등록 경로도 없다. 삭제만 동작이 같다. |
| 22 | 24 |
*/ |
| 23 |
-export function InquiryRowActions({ post }: InquiryRowActionsProps) {
|
|
| 25 |
+export function InquiryRowActions({
|
|
| 26 |
+ post, |
|
| 27 |
+ answerStatuses, |
|
| 28 |
+}: InquiryRowActionsProps) {
|
|
| 24 | 29 |
const { showAlert, hideAlert, showToast } = useFeedback();
|
| 25 | 30 |
const [isAnswerOpen, setIsAnswerOpen] = useState(false); |
| 26 | 31 |
const [isDeleting, startDeleting] = useTransition(); |
... | ... | @@ -73,7 +78,11 @@ |
| 73 | 78 |
/> |
| 74 | 79 |
|
| 75 | 80 |
{isAnswerOpen && (
|
| 76 |
- <InquiryAnswerModal post={post} onClose={() => setIsAnswerOpen(false)} />
|
|
| 81 |
+ <InquiryAnswerModal |
|
| 82 |
+ post={post}
|
|
| 83 |
+ answerStatuses={answerStatuses}
|
|
| 84 |
+ onClose={() => setIsAnswerOpen(false)}
|
|
| 85 |
+ /> |
|
| 77 | 86 |
)} |
| 78 | 87 |
</FoxButtonGroup> |
| 79 | 88 |
); |
--- app/(protected)/(basic)/boards/inquiries/page.tsx
+++ app/(protected)/(basic)/boards/inquiries/page.tsx
... | ... | @@ -1,6 +1,8 @@ |
| 1 | 1 |
import type { Metadata } from 'next';
|
| 2 | 2 |
import { verifySession } from '@/lib/auth/dal';
|
| 3 | 3 |
import { fetchBoardPosts } from '@/lib/data/repositories/board-post-repository';
|
| 4 |
+import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository';
|
|
| 5 |
+import { CODE_GROUP } from '@/lib/domain/common-code';
|
|
| 4 | 6 |
import { parseBoardPostQuery } from '@/lib/domain/board-post-query';
|
| 5 | 7 |
import { BoardPostList } from '../_components/board-post-list';
|
| 6 | 8 |
|
... | ... | @@ -16,6 +18,7 @@ |
| 16 | 18 |
|
| 17 | 19 |
const query = parseBoardPostQuery('inquiry', await searchParams);
|
| 18 | 20 |
const { items, totalCount } = await fetchBoardPosts('inquiry', query);
|
| 21 |
+ const answerStatuses = await fetchCommonCodes(CODE_GROUP.inquiryAnswerStatus); |
|
| 19 | 22 |
|
| 20 | 23 |
const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize)); |
| 21 | 24 |
const currentPage = Math.min(query.page, totalPages); |
... | ... | @@ -35,6 +38,7 @@ |
| 35 | 38 |
totalPages={totalPages}
|
| 36 | 39 |
totalCount={totalCount}
|
| 37 | 40 |
canCreate={false}
|
| 41 |
+ answerStatuses={answerStatuses}
|
|
| 38 | 42 |
/> |
| 39 | 43 |
); |
| 40 | 44 |
} |
--- lib/domain/board-post-form.ts
+++ lib/domain/board-post-form.ts
... | ... | @@ -11,7 +11,6 @@ |
| 11 | 11 |
*/ |
| 12 | 12 |
|
| 13 | 13 |
import {
|
| 14 |
- ANSWER_STATUS_OPTIONS, |
|
| 15 | 14 |
BOARD_CATEGORY_OPTIONS, |
| 16 | 15 |
type BoardType, |
| 17 | 16 |
} from '@/lib/domain/board-post'; |
... | ... | @@ -178,16 +177,13 @@ |
| 178 | 177 |
*/ |
| 179 | 178 |
export function validateInquiryAnswerForm( |
| 180 | 179 |
values: InquiryAnswerFormValues, |
| 181 |
- options: { doneStatusCode: string }
|
|
| 180 |
+ options: { doneStatusCode: string; allowedStatusCodes: readonly string[] }
|
|
| 182 | 181 |
): ValidationResult<InquiryAnswerFormValues, InquiryAnswerFormErrors> {
|
| 183 | 182 |
const errors: InquiryAnswerFormErrors = {};
|
| 184 | 183 |
|
| 185 | 184 |
const answerContent = values.answerContent.trim(); |
| 186 | 185 |
|
| 187 |
- const isKnownStatus = ANSWER_STATUS_OPTIONS.some( |
|
| 188 |
- (option) => option.value === values.answerStatusCode |
|
| 189 |
- ); |
|
| 190 |
- if (!isKnownStatus) {
|
|
| 186 |
+ if (!options.allowedStatusCodes.includes(values.answerStatusCode)) {
|
|
| 191 | 187 |
errors.answerStatusCode = '진행상태를 선택해 주세요.'; |
| 192 | 188 |
} |
| 193 | 189 |
|
--- lib/domain/board-post.ts
+++ lib/domain/board-post.ts
... | ... | @@ -128,13 +128,7 @@ |
| 128 | 128 |
* 1:1문의 「진행상태」(`ansSttsCd`) — **임시값이다.** 백엔드에 코드 정의가 없어 사용자 확인 후 |
| 129 | 129 |
* 잠정 코드를 한 곳에 모아 두는 방침으로 정했다. 실제 코드가 나오면 이 표만 교체한다. |
| 130 | 130 |
*/ |
| 131 |
-export const ANSWER_STATUS_OPTIONS: readonly CodeOption[] = [ |
|
| 132 |
- { value: 'WAIT', label: '답변대기' },
|
|
| 133 |
- { value: 'ING', label: '처리중' },
|
|
| 134 |
- { value: 'DONE', label: '답변완료' },
|
|
| 135 |
-]; |
|
| 136 |
- |
|
| 137 |
-/** 답변완료 상태 코드 — "답변일 표시"·"답변 완료 시 푸쉬" 판단이 이 값을 참조한다. */ |
|
| 131 |
+// TODO: 답변완료를 가리키는 코드값. BBS_ANS_CD의 실제 값이 확인되면 교체한다. |
|
| 138 | 132 |
export const ANSWER_STATUS_DONE = 'DONE'; |
| 139 | 133 |
|
| 140 | 134 |
/** 코드 → 라벨. 표에 없는 코드는 지어내지 않고 코드 그대로 노출한다(잘못된 라벨보다 낫다). */ |
... | ... | @@ -157,8 +151,11 @@ |
| 157 | 151 |
return formatCode(INQUIRY_TYPE_OPTIONS, code); |
| 158 | 152 |
} |
| 159 | 153 |
|
| 160 |
-export function formatAnswerStatusLabel(code: string | null): string {
|
|
| 161 |
- return formatCode(ANSWER_STATUS_OPTIONS, code); |
|
| 154 |
+export function formatAnswerStatusLabel( |
|
| 155 |
+ code: string | null, |
|
| 156 |
+ options: readonly CodeOption[] |
|
| 157 |
+): string {
|
|
| 158 |
+ return formatCode(options, code); |
|
| 162 | 159 |
} |
| 163 | 160 |
|
| 164 | 161 |
/** 사용여부 표기 — 시안의 「노출 / 미노출」. */ |
--- lib/domain/common-code.ts
+++ lib/domain/common-code.ts
... | ... | @@ -21,6 +21,8 @@ |
| 21 | 21 |
export const CODE_GROUP = {
|
| 22 | 22 |
/** 꾸미기 아이템 카테고리. */ |
| 23 | 23 |
decorationItemCategory: 'ITEM_CATE_CD', |
| 24 |
+ /** 1:1문의 진행상태. */ |
|
| 25 |
+ inquiryAnswerStatus: 'BBS_ANS_CD', |
|
| 24 | 26 |
} as const; |
| 25 | 27 |
|
| 26 | 28 |
/** 값이 없는 항목의 화면 표기. */ |
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?