임동욱 임동욱 08-20
merge: 1:1문의 답변 팝업
@f7909995296555a248713c1277717d7c5310c60b
app/(protected)/(basic)/boards/_actions.ts
--- app/(protected)/(basic)/boards/_actions.ts
+++ app/(protected)/(basic)/boards/_actions.ts
@@ -8,10 +8,7 @@
   saveInquiryAnswer,
   updateBoardPost,
 } from '@/lib/data/repositories/board-post-repository';
-import {
-  MAX_ATTACHMENT_BYTES,
-  uploadAttachment,
-} from '@/lib/data/repositories/file-repository';
+import { uploadAttachment } from '@/lib/data/repositories/file-repository';
 import {
   ANSWER_STATUS_DONE,
   type BoardType,
@@ -23,7 +20,10 @@
   type InquiryAnswerFormState,
 } from '@/lib/domain/board-post-form';
 import { BOARD_PATHS } from '@/lib/domain/board-post-query';
-import { FILE_MODULE_ID } from '@/lib/domain/file-module';
+import {
+  FILE_MODULE_ID,
+  MAX_ATTACHMENT_BYTES,
+} from '@/lib/domain/file-module';
 
 /**
  * 게시판(공지사항·1:1문의·FAQ) 등록/수정/삭제/답변 Server Action.
 
app/(protected)/(basic)/boards/_components/board-attachment-field.module.scss (added)
+++ app/(protected)/(basic)/boards/_components/board-attachment-field.module.scss
@@ -0,0 +1,28 @@
+@use "@fox/styles/abstracts" as fox;
+
+.input {
+  inline-size: 100%;
+  color: fox.color(font-neutral-subtle);
+  font-family: fox.font-family(body);
+  font-size: fox.font-size(body-sm);
+
+  &::file-selector-button {
+    margin-inline-end: fox.gap(3);
+    padding-block: fox.padding(3);
+    padding-inline: fox.padding(5);
+    border: fox.border(1) solid fox.color(button-default-border);
+    border-radius: fox.form(radius-sm);
+    background: fox.color(button-default-surface);
+    color: fox.color(font-neutral-strong);
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(label-sm);
+    font-weight: fox.font-weight(bold);
+    cursor: pointer;
+  }
+}
+
+.existing {
+  color: fox.color(font-neutral-subtle);
+  font-family: fox.font-family(body);
+  font-size: fox.font-size(body-sm);
+}
app/(protected)/(basic)/boards/_components/board-attachment-field.tsx
--- app/(protected)/(basic)/boards/_components/board-attachment-field.tsx
+++ app/(protected)/(basic)/boards/_components/board-attachment-field.tsx
@@ -3,7 +3,8 @@
 import { useState } from 'react';
 import { FoxHelperText } from '@fox/core/components/fox-helper-text';
 import { Field } from '@/components/ui/field';
-import { MAX_ATTACHMENT_BYTES } from '@/lib/data/repositories/file-repository';
+import styles from './board-attachment-field.module.scss';
+import { MAX_ATTACHMENT_BYTES } from '@/lib/domain/file-module';
 
 interface BoardAttachmentFieldProps {
   label?: string;
@@ -42,7 +43,7 @@
       <input type="hidden" name="attachmentId" value={attachmentId ?? ''} />
 
       {attachmentId ? (
-        <p className="text-body-sm text-foreground-muted">
+        <p className={styles.existing}>
           기존 첨부파일{' '}
           <a
             href={`${ATTACHMENT_DOWNLOAD_PATH}?atchFileId=${encodeURIComponent(attachmentId)}`}
@@ -59,7 +60,7 @@
       <input
         type="file"
         name="attachment"
-        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"
+        className={styles.input}
         onChange={(event) => {
           const file = event.target.files?.[0];
           if (file && file.size > MAX_ATTACHMENT_BYTES) {
app/(protected)/(basic)/boards/_components/board-post-columns.tsx
--- app/(protected)/(basic)/boards/_components/board-post-columns.tsx
+++ app/(protected)/(basic)/boards/_components/board-post-columns.tsx
@@ -17,6 +17,7 @@
   type BoardType,
 } from '@/lib/domain/board-post';
 import { BoardRowActions } from './board-row-actions';
+import { InquiryRowActions } from '../inquiries/_components/inquiry-row-actions';
 
 function numberColumn(page: number, pageSize: number): FoxListColumn<BoardPost> {
   return {
@@ -181,7 +182,12 @@
       width: 140,
       render: (row) => formatOptionalValue(row.answeredAt),
     },
-    actionsColumn('inquiry'),
+    {
+      key: 'actions',
+      header: '관리',
+      width: 160,
+      render: (row) => <InquiryRowActions post={row} />,
+    },
   ];
 }
 
 
app/(protected)/(basic)/boards/inquiries/_components/inquiry-answer-modal.module.scss (added)
+++ app/(protected)/(basic)/boards/inquiries/_components/inquiry-answer-modal.module.scss
@@ -0,0 +1,37 @@
+@use "@fox/styles/abstracts" as fox;
+
+.fields {
+  display: flex;
+  inline-size: 100%;
+  min-inline-size: 0;
+  flex-direction: column;
+}
+
+.field {
+  display: flex;
+  inline-size: 100%;
+  flex-direction: column;
+  gap: fox.gap(3);
+  padding-block: fox.padding(6);
+  border-block-end: fox.border(1) solid fox.color(border-neutral-subtler);
+}
+
+.pair {
+  display: flex;
+  align-items: flex-start;
+  gap: fox.gap(3);
+
+  > * {
+    flex: 1 1 0;
+    min-inline-size: 0;
+  }
+}
+
+.sectionTitle {
+  margin: 0;
+  padding-block-start: fox.padding(6);
+  color: fox.color(font-theme-primary);
+  font-family: fox.font-family(body);
+  font-size: fox.font-size(label-md);
+  font-weight: fox.font-weight(bold);
+}
app/(protected)/(basic)/boards/inquiries/_components/inquiry-answer-modal.tsx
--- app/(protected)/(basic)/boards/inquiries/_components/inquiry-answer-modal.tsx
+++ app/(protected)/(basic)/boards/inquiries/_components/inquiry-answer-modal.tsx
@@ -1,11 +1,13 @@
 '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 { Select } from '@/components/ui/select';
+import { useActionState, useEffect, useRef, useState } from 'react';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxHelperText } from '@fox/core/components/fox-helper-text';
+import { FoxInput } from '@fox/core/components/fox-input';
+import { FoxModal } from '@fox/core/components/fox-modal';
+import { FoxSelect } from '@fox/core/components/fox-select';
+import { FoxTextArea } from '@fox/core/components/fox-text-area';
+import { submitFormAction } from '@/app/_hooks/submit-form-action';
 import { useFeedback } from '@/app/_hooks/use-feedback';
 import {
   ANSWER_STATUS_OPTIONS,
@@ -19,35 +21,26 @@
 } from '@/lib/domain/board-post-form';
 import { saveInquiryAnswerAction } from '../../_actions';
 import { BoardAttachmentField } from '../../_components/board-attachment-field';
-import { BoardContentField } from '../../_components/board-content-field';
-import { FoxHelperText } from '@fox/core/components/fox-helper-text';
-import { submitFormAction } from '@/app/_hooks/submit-form-action';
+import styles from './inquiry-answer-modal.module.scss';
 
 interface InquiryAnswerModalProps {
   post: BoardPost;
   onClose: () => void;
 }
 
-const FORM_ID = 'inquiry-answer-form';
+const STATUS_HELP_TEXT =
+  '답변완료 선택 시에만 FO(사용자 화면)에 답변 내용 노출, 목록에 답변일도 표시.';
 
 /**
- * 1:1문의 상세 및 답변 팝업 — 시안 A_BOA_013_p.
- *
- * 질문자가 작성한 항목(질문유형·제목·질문자명·작성일·이메일·전화번호·질문내용)은 **전부 읽기
- * 전용**이고, 관리자가 바꾸는 것은 진행상태·답변내용·답변첨부 셋뿐이다. 그래서 읽기 전용 값은
- * 폼에 싣지 않는다 — Server Action도 그 값들을 읽지 않으므로 위조해 보내도 반영되지 않는다.
- *
- * 전화번호는 **마스킹하지 않고 원본을 보여준다** — 목록에서는 마스킹하지만(시안 A_BOA_012),
- * 상세는 답변을 위해 연락처를 확인하는 화면이라 시안에도 원본(`010-1234-5678`)으로 표기돼 있다.
- *
- * 첨부는 **답변 첨부만** 다룬다(사용자 확인 사항). 백엔드 필드가 `atchFileId` 하나뿐이라 질문
- * 첨부와 공유되며, 시안에도 "질문 첨부파일 → 일단 삭제" 메모가 있다.
- *
- * 시안 메모의 "답변완료 시 사용자에게 앱푸쉬 전송"은 **백엔드 미구현**이다 — 게시판 서비스의 푸시
- * 분기가 빈 블록이라 프론트가 할 수 있는 일이 없다. 별도로 보고한 사항이다.
+ * 이슈: 기획의 "답변완료 시 앱푸쉬 전송"은 백엔드 푸시 분기가 빈 블록이라 동작하지 않는다.
+ * 이슈: 백엔드 첨부 필드가 `atchFileId` 하나뿐이라 질문 첨부와 답변 첨부가 같은 자리를 쓴다.
  */
 export function InquiryAnswerModal({ post, onClose }: InquiryAnswerModalProps) {
   const { showToast } = useFeedback();
+  const formRef = useRef<HTMLFormElement>(null);
+  const [statusCode, setStatusCode] = useState(
+    post.answerStatusCode ?? ANSWER_STATUS_OPTIONS[0].value
+  );
   const [state, formAction, isPending] = useActionState(
     saveInquiryAnswerAction,
     INITIAL_INQUIRY_ANSWER_FORM_STATE
@@ -63,106 +56,136 @@
   const errors = state.status === 'error' ? (state.errors ?? {}) : {};
 
   return (
-    <Modal
+    <FoxModal
+      open
+      size="md"
       title="1:1문의 상세 및 답변"
       onClose={onClose}
-      footer={
+      actions={
         <>
-          <Button type="button" variant="ghost" onClick={onClose}>
-            취소
-          </Button>
-          <Button
-            type="submit"
-            form={FORM_ID}
-            variant="primary"
-            disabled={isPending}
-          >
-            {isPending ? '저장 중...' : '저장'}
-          </Button>
+          <FoxButton type="default" size="md" label="취소" onAction={onClose} />
+          <FoxButton
+            type="primary"
+            size="md"
+            label="저장"
+            loading={isPending}
+            onAction={() => formRef.current?.requestSubmit()}
+          />
         </>
       }
     >
-      <form id={FORM_ID} onSubmit={(event) => submitFormAction(event, formAction)} className="flex flex-col gap-4">
+      <form
+        ref={formRef}
+        onSubmit={(event) => submitFormAction(event, formAction)}
+        className={styles.fields}
+      >
         <input type="hidden" name="id" value={post.id} />
 
-        <Field label="질문유형">
-          <Input value={formatInquiryTypeLabel(post.typeCode)} readOnly />
-        </Field>
-
-        <p className="text-body-sm text-foreground-muted">
-          아래 질문 항목은 조회 전용입니다.
-        </p>
-
-        <Field label="질문제목">
-          <Input value={formatOptionalValue(post.title)} readOnly />
-        </Field>
-
-        <div className="flex gap-2">
-          <Field label="질문자명" className="flex-1">
-            <Input value={formatOptionalValue(post.authorName)} readOnly />
-          </Field>
-          <Field label="작성일" className="flex-1">
-            <Input value={formatOptionalValue(post.createdAt)} readOnly />
-          </Field>
+        <div className={styles.field}>
+          <FoxInput
+            size="md"
+            label="질문유형"
+            value={formatInquiryTypeLabel(post.typeCode)}
+            readOnly
+          />
         </div>
 
-        <div className="flex gap-2">
-          <Field label="이메일" className="flex-1">
-            <Input value={formatOptionalValue(post.authorEmail)} readOnly />
-          </Field>
-          <Field label="전화번호" className="flex-1">
-            <Input value={formatOptionalValue(post.authorPhoneNumber)} readOnly />
-          </Field>
+        <p className={styles.sectionTitle}>게시물의 항목 (조회 전용)</p>
+
+        <div className={styles.field}>
+          <FoxInput
+            size="md"
+            label="질문제목"
+            value={formatOptionalValue(post.title)}
+            readOnly
+          />
         </div>
 
-        <Field label="질문내용">
-          <textarea
+        <div className={styles.field}>
+          <div className={styles.pair}>
+            <FoxInput
+              size="md"
+              label="질문자명"
+              value={formatOptionalValue(post.authorName)}
+              readOnly
+            />
+            <FoxInput
+              size="md"
+              label="작성일"
+              value={formatOptionalValue(post.createdAt)}
+              readOnly
+            />
+          </div>
+        </div>
+
+        <div className={styles.field}>
+          <div className={styles.pair}>
+            <FoxInput
+              size="md"
+              label="이메일"
+              value={formatOptionalValue(post.authorEmail)}
+              readOnly
+            />
+            <FoxInput
+              size="md"
+              label="전화번호"
+              value={formatOptionalValue(post.authorPhoneNumber)}
+              readOnly
+            />
+          </div>
+        </div>
+
+        <div className={styles.field}>
+          <FoxTextArea
+            label="질문내용"
             value={post.content}
             readOnly
-            rows={5}
-            className="w-full cursor-default rounded-md border border-border bg-surface-muted px-3 py-2 text-body-md text-foreground"
+            rows={4}
           />
-        </Field>
+        </div>
 
-        <Field label="진행상태 *">
-          <Select
-            name="answerStatusCode"
-            defaultValue={post.answerStatusCode ?? ANSWER_STATUS_OPTIONS[0].value}
-          >
-            {ANSWER_STATUS_OPTIONS.map((option) => (
-              <option key={option.value} value={option.value}>
-                {option.label}
-              </option>
-            ))}
-          </Select>
-        </Field>
-        <p className="text-body-sm text-foreground-muted">
-          답변완료를 선택한 경우에만 사용자 화면에 답변 내용이 노출됩니다.
-        </p>
-        {errors.answerStatusCode && (
-          <FoxHelperText type="danger" message={errors.answerStatusCode} />
-        )}
+        <div className={styles.field}>
+          <FoxSelect
+            size="md"
+            label="진행상태"
+            requirement="required"
+            options={ANSWER_STATUS_OPTIONS.map((option) => ({
+              value: option.value,
+              label: option.label,
+            }))}
+            value={statusCode}
+            onValueChange={setStatusCode}
+            error={Boolean(errors.answerStatusCode)}
+            hint={errors.answerStatusCode ?? STATUS_HELP_TEXT}
+          />
+          <input type="hidden" name="answerStatusCode" value={statusCode} />
+        </div>
 
-        <BoardContentField
-          name="answerContent"
-          label="답변내용"
-          defaultValue={post.answerContent ?? ''}
-          maxLength={ANSWER_MAX_LENGTH}
-          placeholder="답변 내용을 입력하세요."
-        />
-        {errors.answerContent && (
-          <FoxHelperText type="danger" message={errors.answerContent} />
-        )}
+        <div className={styles.field}>
+          <FoxTextArea
+            name="answerContent"
+            label="답변내용"
+            defaultValue={post.answerContent ?? ''}
+            maxLength={ANSWER_MAX_LENGTH}
+            counter
+            rows={4}
+            placeholder="답변 내용을 입력하세요."
+            invalid={Boolean(errors.answerContent)}
+            message={errors.answerContent}
+          />
+        </div>
 
-        <BoardAttachmentField
-          label="답변 첨부파일"
-          attachmentId={post.attachmentId}
-        />
+        <div className={styles.field}>
+          <BoardAttachmentField
+            label="답변 첨부파일"
+            attachmentId={post.attachmentId}
+          />
+        </div>
 
         {state.status === 'error' && state.message && (
-          <p className="text-body-sm text-danger">{state.message}</p>
+          <FoxHelperText type="danger" message={state.message} />
         )}
       </form>
-    </Modal>
+    </FoxModal>
   );
 }
app/(protected)/(basic)/boards/inquiries/_components/inquiry-row-actions.tsx
--- app/(protected)/(basic)/boards/inquiries/_components/inquiry-row-actions.tsx
+++ app/(protected)/(basic)/boards/inquiries/_components/inquiry-row-actions.tsx
@@ -3,7 +3,8 @@
 import { useState, useTransition } from 'react';
 import { FoxButton } from '@fox/core/components/fox-button';
 import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
-import { Button } from '@/components/ui/button';
+import { FoxIconButton } from '@fox/core/components/fox-icon-button';
+import { FoxPencilSimpleIcon, FoxTrashIcon } from '@fox/core/icons';
 import { useFeedback } from '@/app/_hooks/use-feedback';
 import type { BoardPost } from '@/lib/domain/board-post';
 import { deleteBoardPostAction } from '../../_actions';
@@ -47,35 +48,33 @@
       actions: (
         <FoxButtonGroup size="md">
           <FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
-          <FoxButton type="primary" size="md" label="삭제" onAction={runDelete} />
+          <FoxButton type="error" size="md" label="삭제" onAction={runDelete} />
         </FoxButtonGroup>
       ),
     });
   }
 
   return (
-    <div className="flex items-center gap-1">
-      <Button
-        type="button"
-        variant="ghost"
+    <FoxButtonGroup size="sm" align="center">
+      <FoxIconButton
+        type="default"
         size="sm"
-        onClick={() => setIsAnswerOpen(true)}
-      >
-        답변
-      </Button>
-      <Button
-        type="button"
-        variant="ghost"
+        icon={<FoxPencilSimpleIcon />}
+        label="상세 및 답변"
+        onAction={() => setIsAnswerOpen(true)}
+      />
+      <FoxIconButton
+        type="default"
         size="sm"
-        onClick={confirmDelete}
+        icon={<FoxTrashIcon />}
+        label={isDeleting ? '삭제 중' : '삭제'}
         disabled={isDeleting}
-      >
-        {isDeleting ? '삭제 중...' : '삭제'}
-      </Button>
+        onAction={confirmDelete}
+      />
 
       {isAnswerOpen && (
         <InquiryAnswerModal post={post} onClose={() => setIsAnswerOpen(false)} />
       )}
-    </div>
+    </FoxButtonGroup>
   );
 }
lib/data/repositories/file-repository.ts
--- lib/data/repositories/file-repository.ts
+++ lib/data/repositories/file-repository.ts
@@ -22,8 +22,6 @@
 
 const FILE_UPLOAD_PATH = '/api/v1/common/file/upload';
 
-/** 업로드 상한. 백엔드에 명시적 제한이 보이지 않아 프론트에서 방어적으로 건다. */
-export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
 
 /** 파일 생성은 일반 조회보다 오래 걸릴 수 있어 넉넉히 잡는다. */
 const UPLOAD_TIMEOUT_MS = 60_000;
lib/domain/file-module.ts
--- lib/domain/file-module.ts
+++ lib/domain/file-module.ts
@@ -7,3 +7,6 @@
 } as const;
 
 export type FileModuleId = (typeof FILE_MODULE_ID)[keyof typeof FILE_MODULE_ID];
+
+/** 첨부파일 상한. 화면(사전 차단)과 Server Action(재검증)이 같은 값을 본다. */
+export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
Add a comment
List