File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
'use client';
import { useActionState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Modal } from '@/components/ui/modal';
import { RadioGroup } from '@/components/ui/radio-group';
import { Select } from '@/components/ui/select';
import { useFeedback } from '@/app/_hooks/use-feedback';
import {
BOARD_CATEGORY_OPTIONS,
formatBoardCategoryLabel,
formatOptionalValue,
type BoardPost,
type BoardType,
} from '@/lib/domain/board-post';
import {
CONTENT_MAX_LENGTH,
INITIAL_BOARD_POST_FORM_STATE,
TITLE_MAX_LENGTH,
} from '@/lib/domain/board-post-form';
import { BOARD_TYPE_OPTIONS } from '@/lib/domain/board-post-query';
import { createBoardPostAction, updateBoardPostAction } from '../_actions';
import { BoardAttachmentField } from './board-attachment-field';
import { BoardContentField } from './board-content-field';
interface BoardPostFormModalProps {
boardType: BoardType;
mode: 'create' | 'edit';
/** 수정 모드의 기존 값. 등록 모드는 넘기지 않는다. */
post?: BoardPost;
onClose: () => void;
}
const FORM_ID = 'board-post-form';
const PINNED_OPTIONS = [
{ value: 'N', label: '사용안함' },
{ value: 'Y', label: '사용' },
];
const VISIBILITY_OPTIONS = [
{ value: 'Y', label: '노출' },
{ value: 'N', label: '미노출' },
];
const PUSH_OPTIONS = [
{ value: 'Y', label: '발송' },
{ value: 'N', label: '미발송' },
];
const BOARD_TITLES: Record<BoardType, string> = {
notice: '공지사항',
inquiry: '1:1문의',
faq: 'FAQ',
};
/**
* 공지사항·FAQ의 등록/수정 팝업 — 시안 A_BOA_007_p·008_p(공지사항), 015_p·016_p(FAQ).
*
* 네 시안이 같은 폼의 변형이라 한 컴포넌트가 게시판 종류(`boardType`)와 모드(`mode`)로 분기한다.
* 게시판별 차이는 두 가지뿐이다 — 공지사항에만 상단고정·노출기간·앱푸쉬가, FAQ에만 유형이 있다.
*
* **수정 시 구분은 읽기 전용이다**(시안 ①). 값 자체를 제출하지 않고, Server Action도 수정
* 경로에서는 구분을 검증하지 않는다 — 직접 POST로 다른 값을 보내도 반영되지 않는다.
*
* 첨부파일이 있어 폼은 multipart로 제출된다(Server Action은 이를 자동으로 처리한다).
* 저장 버튼은 footer에서 `form={FORM_ID}`로 연결한다 — 네이티브 HTML의 `form` 속성은 버튼이
* DOM상 form의 자손이 아니어도 같은 문서 안에서 id만 일치하면 그 form을 제출한다.
*/
export function BoardPostFormModal({
boardType,
mode,
post,
onClose,
}: BoardPostFormModalProps) {
const { showToast } = useFeedback();
const isCreate = mode === 'create';
const [state, formAction, isPending] = useActionState(
isCreate ? createBoardPostAction : updateBoardPostAction,
INITIAL_BOARD_POST_FORM_STATE
);
useEffect(() => {
if (state.status === 'success') {
showToast({
variant: 'success',
message: isCreate ? '게시물을 등록했습니다.' : '게시물을 수정했습니다.',
});
onClose();
}
}, [state, showToast, onClose, isCreate]);
const errors = state.status === 'error' ? (state.errors ?? {}) : {};
const typeOptions = BOARD_TYPE_OPTIONS[boardType];
const hasNoticeOnlyFields = boardType === 'notice';
return (
<Modal
title={`${BOARD_TITLES[boardType]} ${isCreate ? '신규 등록' : '수정'}`}
onClose={onClose}
footer={
<>
<Button type="button" variant="ghost" onClick={onClose}>
취소
</Button>
<Button
type="submit"
form={FORM_ID}
variant="primary"
disabled={isPending}
>
{isPending ? '저장 중...' : isCreate ? '등록' : '수정'}
</Button>
</>
}
>
<form id={FORM_ID} action={formAction} className="flex flex-col gap-4">
<input type="hidden" name="boardType" value={boardType} />
{!isCreate && post && <input type="hidden" name="id" value={post.id} />}
<p className="text-right text-body-sm text-danger">
* 는 필수 항목입니다.
</p>
<Field label={isCreate ? '구분 *' : '구분'}>
{isCreate ? (
<Select name="categoryCode" defaultValue={BOARD_CATEGORY_OPTIONS[0].value}>
{BOARD_CATEGORY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
) : (
<Input
value={formatBoardCategoryLabel(post?.categoryCode ?? null)}
readOnly
/>
)}
</Field>
<FieldError message={errors.categoryCode} />
{typeOptions.length > 0 && (
<>
<Field label="유형 *">
<Select name="typeCode" defaultValue={post?.typeCode ?? ''}>
<option value="">선택</option>
{typeOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</Field>
<FieldError message={errors.typeCode} />
</>
)}
<Field label="제목 *">
<Input
type="text"
name="title"
defaultValue={post?.title ?? ''}
maxLength={TITLE_MAX_LENGTH}
placeholder="제목을 입력하세요."
/>
</Field>
<FieldError message={errors.title} />
<BoardContentField
name="content"
label="내용 *"
defaultValue={post?.content ?? ''}
maxLength={CONTENT_MAX_LENGTH}
placeholder="내용을 입력하세요."
/>
<FieldError message={errors.content} />
<BoardAttachmentField attachmentId={post?.attachmentId} />
{hasNoticeOnlyFields && (
<>
<Field label="상단고정">
<RadioGroup
name="isPinned"
options={PINNED_OPTIONS}
defaultValue={post?.isPinned ? 'Y' : 'N'}
/>
</Field>
<Field label="노출기간">
<div className="flex items-center gap-2">
<Input
type="date"
name="startDate"
defaultValue={post?.startDate ?? ''}
aria-label="게시시작일시"
/>
<span className="text-foreground-muted">~</span>
<Input
type="date"
name="endDate"
defaultValue={post?.endDate ?? ''}
aria-label="게시종료일시"
/>
</div>
</Field>
<FieldError message={errors.startDate} />
<FieldError message={errors.endDate} />
</>
)}
<Field label="사용여부 *">
<RadioGroup
name="isVisible"
options={VISIBILITY_OPTIONS}
defaultValue={post?.isVisible === false ? 'N' : 'Y'}
/>
</Field>
{hasNoticeOnlyFields && (
<Field label="앱푸쉬 설정 *">
<RadioGroup
name="isPushEnabled"
options={PUSH_OPTIONS}
defaultValue={post?.isPushEnabled === false ? 'N' : 'Y'}
/>
</Field>
)}
{!isCreate && post && (
<Field label="작성자 / 작성일">
<div className="flex items-center gap-2">
<Input value={formatOptionalValue(post.authorName)} readOnly />
<Input value={formatOptionalValue(post.createdAt)} readOnly />
</div>
</Field>
)}
{state.status === 'error' && state.message && (
<p className="text-body-sm text-danger">{state.message}</p>
)}
</form>
</Modal>
);
}
/** 필드 하단 오류 문구. 값이 없으면 아무것도 그리지 않아 레이아웃을 차지하지 않는다. */
export function FieldError({ message }: { message?: string }) {
if (!message) {
return null;
}
return <p className="text-body-sm text-danger">{message}</p>;
}