임동욱 임동욱 08-11
feat: 꾸미기 아이템 등록·수정 기능 추가
Next.js 16의 'use server' 파일은 async 함수 외의 값을 export할 수 없어(런타임 전용 제약),
폼 상태 초깃값(INITIAL_DECORATION_ITEM_FORM_STATE)을 domain 계층으로 옮겼다.

Co-Authored-By: Claude Opus 5 
@6dd882c947b82ccb121774687814a0e66ac28dd7
 
app/(protected)/(basic)/decoration-items/_actions.ts (added)
+++ app/(protected)/(basic)/decoration-items/_actions.ts
@@ -0,0 +1,152 @@
+'use server';
+
+import { revalidatePath } from 'next/cache';
+import { verifySession } from '@/lib/auth/dal';
+import {
+  createDecorationItem,
+  deleteDecorationItem,
+  isDecorationItemIdTaken,
+  updateDecorationItem,
+} from '@/lib/data/repositories/decoration-item-repository';
+import type {
+  DecorationItemCategory,
+  DecorationItemType,
+} from '@/lib/domain/decoration-item';
+import {
+  validateDecorationItemCreate,
+  validateDecorationItemUpdate,
+  type DecorationItemEditableValues,
+  type DecorationItemFormState,
+} from '@/lib/domain/decoration-item-form';
+import { DECORATION_ITEMS_PATH } from '@/lib/domain/decoration-item-query';
+
+/**
+ * 꾸미기 아이템 등록/수정/삭제 Server Action.
+ *
+ * **모든 Action이 `verifySession()`으로 시작한다** — Server Action은 UI를 거치지 않고 직접
+ * POST될 수 있어 이 확인이 유일한 최종 방어선이다(설계서 §3 SRP 체크).
+ *
+ * 검증은 화면이 아니라 여기서 확정한다(`lib/domain/decoration-item-form.ts`의 규칙을 호출) —
+ * 화면의 required 속성·기본값 채움은 편의일 뿐 신뢰 경계가 아니다.
+ *
+ * 실제 저장은 Repository에 맡긴다. 백엔드에 이 도메인의 API가 전혀 없어(조회조차 없음, 사용자
+ * 확정 사항) Repository가 mock 저장소로 위임하고 있지만, **이 파일은 그 사실을 알지 못한다** —
+ * 백엔드 API가 생겨도 이 파일은 바뀌지 않는다.
+ *
+ * `DecorationItemFormState` 타입과 그 초깃값(`INITIAL_DECORATION_ITEM_FORM_STATE`)은 이
+ * 파일이 아니라 `lib/domain/decoration-item-form.ts`에 있다 — Next.js가 `'use server'`
+ * 파일에서 함수가 아닌 값(일반 객체 상수)을 export하는 것을 런타임에 거부하기 때문이다
+ * (`decoration-item-form.ts`의 해당 타입 주석 참조). 타입만 이 파일에서 다시 쓰는 것은
+ * 문제 없다 — 타입은 컴파일 시 지워져 런타임 export로 남지 않는다.
+ */
+
+const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.';
+const DUPLICATE_ITEM_ID_MESSAGE = '이미 사용 중인 아이템 ID입니다.';
+
+function readString(formData: FormData, key: string): string {
+  const value = formData.get(key);
+  return typeof value === 'string' ? value : '';
+}
+
+/**
+ * 폼 숫자 입력을 파싱한다. `Number('')`이 조용히 `0`이 되는 JS 함정을 피하려고 빈 문자열은
+ * 명시적으로 `NaN`으로 취급한다 — 그래야 `Number.isInteger` 검증이 "값이 비었음"을 정상적으로
+ * 잡아낸다(포인트 0은 유효한 값이라 "비어서 0"과 "실제로 0"을 구분해야 한다).
+ */
+function parseFormNumber(formData: FormData, key: string): number {
+  const trimmed = readString(formData, key).trim();
+  return trimmed === '' ? NaN : Number(trimmed);
+}
+
+/**
+ * 등록·수정이 공유하는 입력 항목을 읽는다. 유형·카테고리는 select/radio 값을 그대로 읽고,
+ * 허용 목록을 벗어난 값(위조된 요청 포함)은 뒤이은 `validateEditableValues`가 걸러낸다 — 여기서
+ * 하는 캐스팅은 타입을 맞추는 것일 뿐 신뢰를 부여하지 않는다.
+ */
+function readEditableValues(formData: FormData): DecorationItemEditableValues {
+  return {
+    itemType: readString(formData, 'itemType') as DecorationItemType,
+    name: readString(formData, 'name'),
+    category: readString(formData, 'category') as DecorationItemCategory,
+    points: parseFormNumber(formData, 'points'),
+    description: readString(formData, 'description'),
+    isActive: readString(formData, 'isActive') === 'true',
+    sortOrder: parseFormNumber(formData, 'sortOrder'),
+  };
+}
+
+/** 시안 ADM_ITM_102_p — 꾸미기 아이템 등록. */
+export async function createDecorationItemAction(
+  _prevState: DecorationItemFormState,
+  formData: FormData
+): Promise<DecorationItemFormState> {
+  await verifySession();
+
+  const validation = validateDecorationItemCreate({
+    ...readEditableValues(formData),
+    itemId: readString(formData, 'itemId'),
+  });
+
+  if (!validation.ok) {
+    return { status: 'error', errors: validation.errors };
+  }
+
+  const { itemId, ...editable } = validation.values;
+
+  // 시안에는 admins의 [중복확인] 같은 버튼이 없지만, 아이템ID가 유일 식별자이자 수정/삭제
+  // 대상 키라 저장 직전에 반드시 다시 확인한다(decoration-item-repository.ts 주석 참조).
+  if (await isDecorationItemIdTaken(itemId)) {
+    return { status: 'error', errors: { itemId: DUPLICATE_ITEM_ID_MESSAGE } };
+  }
+
+  await createDecorationItem({ itemId, ...editable });
+
+  revalidatePath(DECORATION_ITEMS_PATH);
+  return { status: 'success' };
+}
+
+/** 시안 ADM_ITM_103_p — 꾸미기 아이템 수정. 아이템ID는 읽기 전용이라 변경 대상이 아니다. */
+export async function updateDecorationItemAction(
+  _prevState: DecorationItemFormState,
+  formData: FormData
+): Promise<DecorationItemFormState> {
+  await verifySession();
+
+  const itemId = readString(formData, 'itemId');
+  if (!itemId) {
+    return { status: 'error', message: INVALID_REQUEST_MESSAGE };
+  }
+
+  const validation = validateDecorationItemUpdate(readEditableValues(formData));
+  if (!validation.ok) {
+    return { status: 'error', errors: validation.errors };
+  }
+
+  await updateDecorationItem(itemId, validation.values);
+
+  revalidatePath(DECORATION_ITEMS_PATH);
+  return { status: 'success' };
+}
+
+/**
+ * 시안 ADM_ITM_101 — 삭제. 확인 얼럿은 화면(`decoration-item-row-actions.tsx`)이 띄우고,
+ * 여기서는 인증과 입력만 확인한다.
+ *
+ * 폼 제출이 아니라 얼럿의 [삭제] 클릭에 반응하는 단발 호출이라 `useActionState`의
+ * (prevState, formData) 규약 대신 id를 직접 받는다(admins의 `deleteAdminMemberAction`과 동일한
+ * 이유).
+ */
+export async function deleteDecorationItemAction(
+  itemId: string
+): Promise<DecorationItemFormState> {
+  await verifySession();
+
+  if (!itemId) {
+    return { status: 'error', message: INVALID_REQUEST_MESSAGE };
+  }
+
+  await deleteDecorationItem(itemId);
+
+  revalidatePath(DECORATION_ITEMS_PATH);
+  return { status: 'success' };
+}
 
app/(protected)/(basic)/decoration-items/_components/decoration-item-create-modal.tsx (added)
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-create-modal.tsx
@@ -0,0 +1,110 @@
+'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 { useFeedback } from '@/app/_hooks/use-feedback';
+import type { DecorationItemType } from '@/lib/domain/decoration-item';
+import {
+  DECORATION_ITEM_ID_HELP_TEXT,
+  INITIAL_DECORATION_ITEM_FORM_STATE,
+} from '@/lib/domain/decoration-item-form';
+import { createDecorationItemAction } from '../_actions';
+import {
+  DecorationItemFormFields,
+  FieldError,
+} from './decoration-item-form-fields';
+
+interface DecorationItemCreateModalProps {
+  /** 등록 버튼을 누른 시점의 활성 탭(유형) — 폼의 유형 기본값으로 쓴다. */
+  defaultItemType: DecorationItemType;
+  typeTotalCount: number;
+  onClose: () => void;
+}
+
+const FORM_ID = 'decoration-item-create-form';
+
+/**
+ * 꾸미기 아이템 등록 팝업(시안 ADM_ITM_102_p).
+ *
+ * 아이템ID는 시안에 [중복확인] 버튼이 없지만 유일 식별자이자 수정/삭제 대상 키라 Server
+ * Action이 저장 직전에 중복을 확인한다(`_actions.ts` 주석 참조) — 화면에는 그 확인 과정이
+ * 보이지 않고 실패 시 필드 오류로만 나타난다.
+ *
+ * 저장 버튼은 footer 슬롯에서 `form={FORM_ID}` 속성으로 폼과 연결한다(admins의 등록 팝업과
+ * 동일한 패턴) — 버튼이 실제 DOM상 form의 자손이 아니어도 같은 문서 안에서 id만 일치하면 그
+ * form을 제출한다.
+ */
+export function DecorationItemCreateModal({
+  defaultItemType,
+  typeTotalCount,
+  onClose,
+}: DecorationItemCreateModalProps) {
+  const { showToast } = useFeedback();
+  const [state, formAction, isPending] = useActionState(
+    createDecorationItemAction,
+    INITIAL_DECORATION_ITEM_FORM_STATE
+  );
+
+  useEffect(() => {
+    if (state.status === 'success') {
+      showToast({ variant: 'success', message: '아이템을 등록했습니다.' });
+      onClose();
+    }
+  }, [state, showToast, onClose]);
+
+  const errors = state.status === 'error' ? (state.errors ?? {}) : {};
+
+  return (
+    <Modal
+      title="꾸미기 아이템 등록"
+      onClose={onClose}
+      footer={
+        <>
+          <Button type="button" variant="ghost" onClick={onClose}>
+            취소
+          </Button>
+          <Button
+            type="submit"
+            form={FORM_ID}
+            variant="primary"
+            disabled={isPending}
+          >
+            {isPending ? '등록 중...' : '등록'}
+          </Button>
+        </>
+      }
+    >
+      <form id={FORM_ID} action={formAction} className="flex flex-col gap-4">
+        <p className="text-right text-body-sm text-danger">
+          * 는 필수 항목입니다.
+        </p>
+
+        <Field label="아이템 ID *">
+          <Input
+            type="text"
+            name="itemId"
+            placeholder="예: ITEM-HAT-002"
+            autoComplete="off"
+          />
+        </Field>
+        <p className="text-body-sm text-foreground-muted">
+          {DECORATION_ITEM_ID_HELP_TEXT}
+        </p>
+        <FieldError message={errors.itemId} />
+
+        <DecorationItemFormFields
+          defaultItemType={defaultItemType}
+          typeTotalCount={typeTotalCount}
+          errors={errors}
+        />
+
+        {state.status === 'error' && state.message && (
+          <p className="text-body-sm text-danger">{state.message}</p>
+        )}
+      </form>
+    </Modal>
+  );
+}
 
app/(protected)/(basic)/decoration-items/_components/decoration-item-edit-modal.tsx (added)
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-edit-modal.tsx
@@ -0,0 +1,95 @@
+'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 { useFeedback } from '@/app/_hooks/use-feedback';
+import type { DecorationItem } from '@/lib/domain/decoration-item';
+import { INITIAL_DECORATION_ITEM_FORM_STATE } from '@/lib/domain/decoration-item-form';
+import { updateDecorationItemAction } from '../_actions';
+import { DecorationItemFormFields } from './decoration-item-form-fields';
+
+interface DecorationItemEditModalProps {
+  item: DecorationItem;
+  typeTotalCount: number;
+  onClose: () => void;
+}
+
+const FORM_ID = 'decoration-item-edit-form';
+
+/**
+ * 꾸미기 아이템 수정 팝업(시안 ADM_ITM_103_p) — 등록과 동일 항목이되 **아이템ID는 읽기
+ * 전용**이다.
+ *
+ * readOnly로 보여주는 아이템ID `Input`에는 `name`을 주지 않아 제출 대상에서 아예 빠지게 하고,
+ * 실제 수정 대상은 별도 hidden input(`itemId`)으로 넘긴다 — Server Action도 hidden 값만
+ * 읽으므로 readOnly 필드를 위조해서 보내도 수정 대상이 바뀌지 않는다(admins의 이름/ID readOnly
+ * 처리와 동일한 방어 방식).
+ */
+export function DecorationItemEditModal({
+  item,
+  typeTotalCount,
+  onClose,
+}: DecorationItemEditModalProps) {
+  const { showToast } = useFeedback();
+  const [state, formAction, isPending] = useActionState(
+    updateDecorationItemAction,
+    INITIAL_DECORATION_ITEM_FORM_STATE
+  );
+
+  useEffect(() => {
+    if (state.status === 'success') {
+      showToast({ variant: 'success', message: '아이템 정보를 수정했습니다.' });
+      onClose();
+    }
+  }, [state, showToast, onClose]);
+
+  const errors = state.status === 'error' ? (state.errors ?? {}) : {};
+
+  return (
+    <Modal
+      title="꾸미기 아이템 수정"
+      onClose={onClose}
+      footer={
+        <>
+          <Button type="button" variant="ghost" onClick={onClose}>
+            취소
+          </Button>
+          <Button
+            type="submit"
+            form={FORM_ID}
+            variant="primary"
+            disabled={isPending}
+          >
+            {isPending ? '수정 중...' : '수정'}
+          </Button>
+        </>
+      }
+    >
+      <form id={FORM_ID} action={formAction} className="flex flex-col gap-4">
+        <input type="hidden" name="itemId" value={item.itemId} />
+
+        <p className="text-right text-body-sm text-danger">
+          * 는 필수 항목입니다.
+        </p>
+
+        <Field label="아이템 ID">
+          <Input type="text" value={item.itemId} readOnly />
+        </Field>
+
+        <DecorationItemFormFields
+          item={item}
+          defaultItemType={item.itemType}
+          typeTotalCount={typeTotalCount}
+          errors={errors}
+        />
+
+        {state.status === 'error' && state.message && (
+          <p className="text-body-sm text-danger">{state.message}</p>
+        )}
+      </form>
+    </Modal>
+  );
+}
 
app/(protected)/(basic)/decoration-items/_components/decoration-item-form-fields.tsx (added)
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-form-fields.tsx
@@ -0,0 +1,144 @@
+'use client';
+
+import { Field } from '@/components/ui/field';
+import { Input } from '@/components/ui/input';
+import { RadioGroup } from '@/components/ui/radio-group';
+import { Select } from '@/components/ui/select';
+import {
+  DECORATION_ITEM_CATEGORIES,
+  DECORATION_ITEM_TYPE_OPTIONS,
+  type DecorationItem,
+  type DecorationItemType,
+} from '@/lib/domain/decoration-item';
+import type { DecorationItemFormErrors } from '@/lib/domain/decoration-item-form';
+
+const ACTIVE_STATUS_OPTIONS = [
+  { value: 'true', label: '사용' },
+  { value: 'false', label: '미사용' },
+];
+
+interface DecorationItemFormFieldsProps {
+  /** 수정 팝업의 기존 값. 등록 팝업은 넘기지 않는다(빈 폼 + 기본값). */
+  item?: DecorationItem;
+  /** 등록 팝업을 연 시점의 활성 탭(유형) — 수정 팝업에서는 `item.itemType`이 대신 쓰인다. */
+  defaultItemType: DecorationItemType;
+  /** "정렬순서" 라벨의 "(총 등록 N개)" 힌트와 등록 시 기본값 계산에 쓰는, 현재 유형의 전체
+   *  등록 건수(검색어 무관). */
+  typeTotalCount: number;
+  errors: DecorationItemFormErrors;
+}
+
+/**
+ * 등록·수정 팝업이 공유하는 입력 항목 — 유형/아이템명/카테고리/포인트/설명/사용여부/정렬순서
+ * (시안 ADM_ITM_102_p / 103_p). 아이템ID와 그 읽기전용 여부만 각 팝업이 따로 그린다.
+ *
+ * **유형(개별/셋트) 전환 시 폼 구성이 바뀔 수 있다는 시안 설명이 있지만, 두 유형의 입력 항목이
+ * 완전히 같아 조건부 렌더링을 두지 않았다** — 유형 값 자체만 폼에 실어 저장한다(사용자 확정
+ * 사항). 목록의 유형 탭과 시각적으로 구분하기 위해(탭은 "지금 보고 있는 목록", 이 라디오는
+ * "저장할 값") 별도 UI가 필요했는데, 전용 탭 컴포넌트가 없어(§10.3 — 신설은 design 레인 소관)
+ * 이미 있는 `RadioGroup`을 재사용했다(students의 사용여부 라디오와 동일한 재사용 패턴).
+ *
+ * **썸네일 이미지 업로드 필드는 의도적으로 없다** — 시안은 업로드를 요구하지만, 사용자가
+ * "썸네일을 따로 등록하지 않고 추후 백엔드가 자동생성해주는 형식"이라고 확정했다(시안과
+ * 다르게 가는 지점). 목록의 썸네일 컬럼 자체는 유지한다 — `decoration-item.ts`의
+ * `getDecorationItemThumbnailLabel` 참조.
+ *
+ * 설명은 여러 줄 입력이 자연스럽지만 공용 textarea 컴포넌트가 없어(신설은 design 레인 소관,
+ * §10.4) 로직 우선 단계에서는 단일행 `Input`으로 대체했다 — design 레인에 필요 컴포넌트로
+ * 보고한다.
+ */
+export function DecorationItemFormFields({
+  item,
+  defaultItemType,
+  typeTotalCount,
+  errors,
+}: DecorationItemFormFieldsProps) {
+  return (
+    <>
+      <Field label="유형 *">
+        <RadioGroup
+          name="itemType"
+          // RadioGroup의 options는 mutable 배열을 요구해 도메인의 ReadonlyArray를 그대로 넘길
+          // 수 없다(components/ui는 design 레인 소관이라 시그니처를 바꾸지 않는다) — 얕은
+          // 복사로 새 mutable 배열을 만들어 넘긴다.
+          options={[...DECORATION_ITEM_TYPE_OPTIONS]}
+          defaultValue={item?.itemType ?? defaultItemType}
+        />
+      </Field>
+      <FieldError message={errors.itemType} />
+
+      <Field label="아이템명 *">
+        <Input
+          type="text"
+          name="name"
+          defaultValue={item?.name ?? ''}
+          placeholder="아이템명을 입력하세요."
+        />
+      </Field>
+      <FieldError message={errors.name} />
+
+      <Field label="카테고리 *">
+        <Select
+          name="category"
+          defaultValue={item?.category ?? DECORATION_ITEM_CATEGORIES[0]}
+        >
+          {DECORATION_ITEM_CATEGORIES.map((category) => (
+            <option key={category} value={category}>
+              {category}
+            </option>
+          ))}
+        </Select>
+      </Field>
+      <FieldError message={errors.category} />
+
+      <Field label="포인트 *">
+        <Input
+          type="number"
+          name="points"
+          min={0}
+          step={1}
+          defaultValue={item?.points}
+          placeholder="오픈 가능한 포인트"
+        />
+      </Field>
+      <FieldError message={errors.points} />
+
+      <Field label="설명">
+        <Input
+          type="text"
+          name="description"
+          defaultValue={item?.description ?? ''}
+          placeholder="설명을 입력하세요."
+        />
+      </Field>
+      <FieldError message={errors.description} />
+
+      <Field label="사용여부">
+        <RadioGroup
+          name="isActive"
+          options={ACTIVE_STATUS_OPTIONS}
+          defaultValue={String(item?.isActive ?? true)}
+        />
+      </Field>
+
+      <Field label={`정렬순서 (총 등록 ${typeTotalCount}개)`}>
+        <Input
+          type="number"
+          name="sortOrder"
+          min={1}
+          step={1}
+          defaultValue={item?.sortOrder ?? typeTotalCount + 1}
+        />
+      </Field>
+      <FieldError message={errors.sortOrder} />
+    </>
+  );
+}
+
+/** 필드 하단 오류 문구. 값이 없으면 아무것도 그리지 않아 레이아웃을 차지하지 않는다. */
+export function FieldError({ message }: { message?: string }) {
+  if (!message) {
+    return null;
+  }
+  return <p className="text-body-sm text-danger">{message}</p>;
+}
lib/domain/decoration-item-form.ts
--- lib/domain/decoration-item-form.ts
+++ lib/domain/decoration-item-form.ts
@@ -59,6 +59,24 @@
   | { ok: true; values: T }
   | { ok: false; errors: DecorationItemFormErrors };
 
+/**
+ * 등록/수정 Server Action의 `useActionState` 결과 상태. 원래는 admins처럼 `_actions.ts`
+ * (`'use server'` 파일)에 두려 했지만, Next.js는 **`'use server'` 파일이 async 함수 외의
+ * 값을 export하는 것을 런타임에 거부한다**("A 'use server' file can only export async
+ * functions, found object" — `INITIAL_DECORATION_ITEM_FORM_STATE`처럼 일반 객체 상수를
+ * 함께 export하면 그 파일의 Server Action을 호출하는 즉시 500으로 깨진다. 빌드/타입체크는
+ * 통과하고 실제로 폼을 제출해야만 드러나는 런타임 전용 제약이라 여기로 옮겼다 — domain
+ * 계층은 `'use server'`가 없어 값 export에 제약이 없다).
+ */
+export type DecorationItemFormState =
+  | { status: 'idle' }
+  | { status: 'error'; message?: string; errors?: DecorationItemFormErrors }
+  | { status: 'success' };
+
+export const INITIAL_DECORATION_ITEM_FORM_STATE: DecorationItemFormState = {
+  status: 'idle',
+};
+
 function isDecorationItemType(value: string): value is DecorationItemType {
   return DECORATION_ITEM_TYPE_OPTIONS.some((option) => option.value === value);
 }
Add a comment
List