fix: 게시판 수정·답변을 단건 조회로 연다
findBoardPostById가 있는데 아무도 부르지 않고 목록 행으로 폼을 채우고 있었다. 행은 화면에 떠 있는 동안 낡을 수 있고, 그대로 저장하면 남의 수정을 덮는다. 없어진 게시물이면 얼럿으로 알리고 팝업을 열지 않는다. Co-Authored-By: Claude Opus 5
@f5e8b09ff0b06ca1096354b10908de07aa986c7b
--- app/(protected)/(basic)/boards/_actions.ts
+++ app/(protected)/(basic)/boards/_actions.ts
... | ... | @@ -7,12 +7,14 @@ |
| 7 | 7 |
import {
|
| 8 | 8 |
createBoardPost, |
| 9 | 9 |
deleteBoardPost, |
| 10 |
+ findBoardPostById, |
|
| 10 | 11 |
saveInquiryAnswer, |
| 11 | 12 |
updateBoardPost, |
| 12 | 13 |
} from '@/lib/data/repositories/board-post-repository'; |
| 13 | 14 |
import { uploadAttachment } from '@/lib/data/repositories/file-repository';
|
| 14 | 15 |
import {
|
| 15 | 16 |
ANSWER_STATUS_DONE, |
| 17 |
+ type BoardPost, |
|
| 16 | 18 |
type BoardType, |
| 17 | 19 |
} from '@/lib/domain/board-post'; |
| 18 | 20 |
import {
|
... | ... | @@ -56,6 +58,8 @@ |
| 56 | 58 |
)}MB 이하만 올릴 수 있습니다.`; |
| 57 | 59 |
const SAVE_FAILED_MESSAGE = '저장하지 못했습니다. 잠시 후 다시 시도해 주세요.'; |
| 58 | 60 |
const DELETE_FAILED_MESSAGE = '삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.'; |
| 61 |
+const LOAD_FAILED_MESSAGE = '게시물을 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.'; |
|
| 62 |
+const GONE_MESSAGE = '이미 삭제된 게시물입니다. 목록을 새로고침해 주세요.'; |
|
| 59 | 63 |
|
| 60 | 64 |
/** FAQ 유형 허용 목록. 화면이 보낸 코드를 그대로 믿지 않기 위해 저장 직전에 다시 조회한다. */ |
| 61 | 65 |
async function fetchFaqTypeCodes(boardType: BoardType): Promise<string[]> {
|
... | ... | @@ -284,6 +288,28 @@ |
| 284 | 288 |
* 폼 제출이 아니라 얼럿의 [삭제] 클릭에 반응하는 단발 호출이라 `useActionState`의 |
| 285 | 289 |
* (prevState, formData) 규약 대신 값을 직접 받는다. |
| 286 | 290 |
*/ |
| 291 |
+/** |
|
| 292 |
+ * 수정·답변 팝업이 열릴 때 그 게시물을 다시 읽는다 — 목록 행은 화면에 떠 있는 동안 남이 |
|
| 293 |
+ * 고쳤을 수 있어, 그것으로 폼을 채우면 오래된 값을 그대로 덮어쓴다. |
|
| 294 |
+ */ |
|
| 295 |
+export async function loadBoardPostAction( |
|
| 296 |
+ bbsId: string |
|
| 297 |
+): Promise< |
|
| 298 |
+ { status: 'success'; post: BoardPost } | { status: 'error'; message: string }
|
|
| 299 |
+> {
|
|
| 300 |
+ await verifySession(); |
|
| 301 |
+ |
|
| 302 |
+ try {
|
|
| 303 |
+ const post = await findBoardPostById(bbsId); |
|
| 304 |
+ if (!post) {
|
|
| 305 |
+ return { status: 'error', message: GONE_MESSAGE };
|
|
| 306 |
+ } |
|
| 307 |
+ return { status: 'success', post };
|
|
| 308 |
+ } catch {
|
|
| 309 |
+ return { status: 'error', message: LOAD_FAILED_MESSAGE };
|
|
| 310 |
+ } |
|
| 311 |
+} |
|
| 312 |
+ |
|
| 287 | 313 |
export async function deleteBoardPostAction( |
| 288 | 314 |
boardType: BoardType, |
| 289 | 315 |
bbsId: string |
--- app/(protected)/(basic)/boards/_components/board-row-actions.tsx
+++ app/(protected)/(basic)/boards/_components/board-row-actions.tsx
... | ... | @@ -7,7 +7,7 @@ |
| 7 | 7 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 8 | 8 |
import type { BoardPost, BoardType } from '@/lib/domain/board-post';
|
| 9 | 9 |
import type { CommonCode } from '@/lib/domain/common-code';
|
| 10 |
-import { deleteBoardPostAction } from '../_actions';
|
|
| 10 |
+import { deleteBoardPostAction, loadBoardPostAction } from '../_actions';
|
|
| 11 | 11 |
import { BoardPostFormModal } from './board-post-form-modal';
|
| 12 | 12 |
|
| 13 | 13 |
interface BoardRowActionsProps {
|
... | ... | @@ -35,8 +35,21 @@ |
| 35 | 35 |
faqTypes, |
| 36 | 36 |
}: BoardRowActionsProps) {
|
| 37 | 37 |
const { showAlert, hideAlert, showToast } = useFeedback();
|
| 38 |
- const [isEditOpen, setIsEditOpen] = useState(false); |
|
| 38 |
+ // 목록 행이 아니라 단건 조회 결과로 폼을 채운다 — 행은 화면에 떠 있는 동안 낡을 수 있다. |
|
| 39 |
+ const [editPost, setEditPost] = useState<BoardPost | null>(null); |
|
| 40 |
+ const [isLoading, startLoading] = useTransition(); |
|
| 39 | 41 |
const [isDeleting, startDeleting] = useTransition(); |
| 42 |
+ |
|
| 43 |
+ function openEdit() {
|
|
| 44 |
+ startLoading(async () => {
|
|
| 45 |
+ const result = await loadBoardPostAction(post.id); |
|
| 46 |
+ if (result.status === 'error') {
|
|
| 47 |
+ showToast({ variant: 'danger', message: result.message });
|
|
| 48 |
+ return; |
|
| 49 |
+ } |
|
| 50 |
+ setEditPost(result.post); |
|
| 51 |
+ }); |
|
| 52 |
+ } |
|
| 40 | 53 |
|
| 41 | 54 |
function runDelete() {
|
| 42 | 55 |
hideAlert(); |
... | ... | @@ -73,8 +86,9 @@ |
| 73 | 86 |
type="default" |
| 74 | 87 |
size="sm" |
| 75 | 88 |
icon={<i className="fox-ico fox-ico-PencilSimple" aria-hidden="true" />}
|
| 76 |
- label="수정" |
|
| 77 |
- onAction={() => setIsEditOpen(true)}
|
|
| 89 |
+ label={isLoading ? '불러오는 중' : '수정'}
|
|
| 90 |
+ disabled={isLoading}
|
|
| 91 |
+ onAction={openEdit}
|
|
| 78 | 92 |
/> |
| 79 | 93 |
<FoxIconButton |
| 80 | 94 |
type="default" |
... | ... | @@ -85,13 +99,13 @@ |
| 85 | 99 |
onAction={confirmDelete}
|
| 86 | 100 |
/> |
| 87 | 101 |
|
| 88 |
- {isEditOpen && (
|
|
| 102 |
+ {editPost && (
|
|
| 89 | 103 |
<BoardPostFormModal |
| 90 | 104 |
boardType={boardType}
|
| 91 | 105 |
mode="edit" |
| 92 | 106 |
faqTypes={faqTypes}
|
| 93 |
- post={post}
|
|
| 94 |
- onClose={() => setIsEditOpen(false)}
|
|
| 107 |
+ post={editPost}
|
|
| 108 |
+ onClose={() => setEditPost(null)}
|
|
| 95 | 109 |
/> |
| 96 | 110 |
)} |
| 97 | 111 |
</div> |
--- app/(protected)/(basic)/boards/inquiries/_components/inquiry-row-actions.tsx
+++ app/(protected)/(basic)/boards/inquiries/_components/inquiry-row-actions.tsx
... | ... | @@ -7,7 +7,7 @@ |
| 7 | 7 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 8 | 8 |
import type { BoardPost } from '@/lib/domain/board-post';
|
| 9 | 9 |
import type { CommonCode } from '@/lib/domain/common-code';
|
| 10 |
-import { deleteBoardPostAction } from '../../_actions';
|
|
| 10 |
+import { deleteBoardPostAction, loadBoardPostAction } from '../../_actions';
|
|
| 11 | 11 |
import { InquiryAnswerModal } from './inquiry-answer-modal';
|
| 12 | 12 |
|
| 13 | 13 |
interface InquiryRowActionsProps {
|
... | ... | @@ -26,8 +26,21 @@ |
| 26 | 26 |
answerStatuses, |
| 27 | 27 |
}: InquiryRowActionsProps) {
|
| 28 | 28 |
const { showAlert, hideAlert, showToast } = useFeedback();
|
| 29 |
- const [isAnswerOpen, setIsAnswerOpen] = useState(false); |
|
| 29 |
+ // 목록 행이 아니라 단건 조회 결과로 채운다 — 답변 중에 남이 고쳤을 수 있다. |
|
| 30 |
+ const [answerPost, setAnswerPost] = useState<BoardPost | null>(null); |
|
| 31 |
+ const [isLoading, startLoading] = useTransition(); |
|
| 30 | 32 |
const [isDeleting, startDeleting] = useTransition(); |
| 33 |
+ |
|
| 34 |
+ function openAnswer() {
|
|
| 35 |
+ startLoading(async () => {
|
|
| 36 |
+ const result = await loadBoardPostAction(post.id); |
|
| 37 |
+ if (result.status === 'error') {
|
|
| 38 |
+ showToast({ variant: 'danger', message: result.message });
|
|
| 39 |
+ return; |
|
| 40 |
+ } |
|
| 41 |
+ setAnswerPost(result.post); |
|
| 42 |
+ }); |
|
| 43 |
+ } |
|
| 31 | 44 |
|
| 32 | 45 |
function runDelete() {
|
| 33 | 46 |
hideAlert(); |
... | ... | @@ -64,8 +77,9 @@ |
| 64 | 77 |
type="default" |
| 65 | 78 |
size="sm" |
| 66 | 79 |
icon={<i className="fox-ico fox-ico-PencilSimple" aria-hidden="true" />}
|
| 67 |
- label="상세 및 답변" |
|
| 68 |
- onAction={() => setIsAnswerOpen(true)}
|
|
| 80 |
+ label={isLoading ? '불러오는 중' : '상세 및 답변'}
|
|
| 81 |
+ disabled={isLoading}
|
|
| 82 |
+ onAction={openAnswer}
|
|
| 69 | 83 |
/> |
| 70 | 84 |
<FoxIconButton |
| 71 | 85 |
type="default" |
... | ... | @@ -76,11 +90,11 @@ |
| 76 | 90 |
onAction={confirmDelete}
|
| 77 | 91 |
/> |
| 78 | 92 |
|
| 79 |
- {isAnswerOpen && (
|
|
| 93 |
+ {answerPost && (
|
|
| 80 | 94 |
<InquiryAnswerModal |
| 81 |
- post={post}
|
|
| 95 |
+ post={answerPost}
|
|
| 82 | 96 |
answerStatuses={answerStatuses}
|
| 83 |
- onClose={() => setIsAnswerOpen(false)}
|
|
| 97 |
+ onClose={() => setAnswerPost(null)}
|
|
| 84 | 98 |
/> |
| 85 | 99 |
)} |
| 86 | 100 |
</FoxButtonGroup> |
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?