임동욱 임동욱 08-11
feat: 꾸미기 아이템 도메인 타입과 mock Repository 추가
Co-Authored-By: Claude Opus 5 
@92e0c0a678848917d70f14559d6f07450ceac04b
 
lib/data/mock/decoration-item-store.ts (added)
+++ lib/data/mock/decoration-item-store.ts
@@ -0,0 +1,212 @@
+import 'server-only';
+import {
+  DECORATION_ITEM_CATEGORIES,
+  type DecorationItem,
+  type DecorationItemCategory,
+  type DecorationItemType,
+} from '@/lib/domain/decoration-item';
+
+/**
+ * 꾸미기 아이템의 **mock 저장소** — 백엔드(edupay-backend)에 이 도메인의 API가 전혀 없어
+ * (조회조차 없음, 사용자 확정 사항) admin-member-store.ts처럼 "백엔드 응답 위에 변경분을
+ * 얹는 오버레이"가 아니라, **이 파일이 유일한 데이터 원본**이다. 조회·등록·수정·삭제 전부
+ * 여기서 끝난다.
+ *
+ * **한계를 분명히 해 둔다 — 이건 데모용이지 저장소가 아니다.**
+ * - 서버 프로세스 메모리에만 있다. 재시작하면 시드로 되돌아가고, 인스턴스가 여럿이면
+ *   공유되지 않는다.
+ * - 개발 중 HMR로 모듈이 다시 평가돼도 상태가 초기화되지 않도록 globalThis에 붙인다
+ *   (admin-member-store.ts와 동일한 편법 — mock 전용이며 실제 데이터 계층에는 쓰지 않는다).
+ *
+ * 백엔드 API가 생기면 이 파일을 삭제하고 `decoration-item-repository.ts`의 함수 본문만 실제
+ * 호출로 교체한다 — 화면·Server Action은 그대로다.
+ */
+
+/** 아이템ID 접두사 — 시안 예시(`ITEM-HAT-002`, `ITEM-TOP-014`, `ITEM-ACC-007`)의 접두사를
+ *  순환시킨다. 카테고리(계절/축하/시즌)와는 별개 축이다 — 이쪽은 "무엇을 꾸미는 아이템인지",
+ *  카테고리는 "언제/어떤 테마인지"를 나타낸다. */
+const ID_PREFIXES = ['HAT', 'TOP', 'ACC', 'BG', 'FACE'] as const;
+type IdPrefix = (typeof ID_PREFIXES)[number];
+const ID_PREFIX_LABELS: Record<IdPrefix, string> = {
+  HAT: '모자',
+  TOP: '상의',
+  ACC: '액세서리',
+  BG: '배경',
+  FACE: '표정',
+};
+
+/** 시안의 "총 39개"에 맞춘 시드 건수(사용자 확정 사항). */
+const SEED_ITEM_COUNT = 39;
+/** 결정적 생성을 위한 고정 기준일(UTC) — `Date.now()`/`Math.random()`을 쓰지 않는다. */
+const SEED_BASE_DATE_UTC = Date.UTC(2026, 0, 1);
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+/**
+ * 39건의 시드 아이템을 결정적으로 생성한다. 인덱스 기반이라 매번 같은 결과를 낸다
+ * (`Math.random()` 금지 — CLAUDE.md 작업 지시).
+ *
+ * - 유형은 인덱스가 3의 배수일 때만 `set`으로 둬 개별 26 / 셋트 13으로 나눈다 — 두 탭 모두
+ *   10개씩 페이지네이션했을 때 여러 페이지가 생겨(3페이지/2페이지) 탭 전환·페이지 이동을 함께
+ *   확인할 수 있다.
+ * - 정렬순서는 유형별 카운터로 별도 채번한다 — `DecorationItem.sortOrder` 주석대로 두 탭이
+ *   서로 다른 순번 공간을 쓰기 때문이다.
+ * - 사용여부는 7건마다 미사용(X)을 섞어 표에서 O/X가 둘 다 보이게 한다.
+ */
+function generateSeedItems(): DecorationItem[] {
+  const prefixCounters = new Map<IdPrefix, number>();
+  const typeCounters: Record<DecorationItemType, number> = {
+    individual: 0,
+    set: 0,
+  };
+  const items: DecorationItem[] = [];
+
+  for (let index = 0; index < SEED_ITEM_COUNT; index += 1) {
+    const prefix = ID_PREFIXES[index % ID_PREFIXES.length];
+    const prefixSeq = (prefixCounters.get(prefix) ?? 0) + 1;
+    prefixCounters.set(prefix, prefixSeq);
+    const paddedSeq = String(prefixSeq).padStart(3, '0');
+
+    const itemType: DecorationItemType = index % 3 === 0 ? 'set' : 'individual';
+    typeCounters[itemType] += 1;
+
+    const category: DecorationItemCategory =
+      DECORATION_ITEM_CATEGORIES[index % DECORATION_ITEM_CATEGORIES.length];
+    const label = ID_PREFIX_LABELS[prefix];
+
+    items.push({
+      itemId: `ITEM-${prefix}-${paddedSeq}`,
+      itemType,
+      name:
+        itemType === 'set'
+          ? `${label} 세트 ${paddedSeq}`
+          : `${label} 아이템 ${paddedSeq}`,
+      category,
+      points: ((index % 5) + 1) * 50,
+      description: index % 2 === 0 ? `${label} ${paddedSeq} 설명입니다.` : null,
+      isActive: index % 7 !== 0,
+      sortOrder: typeCounters[itemType],
+      updatedAt: new Date(SEED_BASE_DATE_UTC + index * DAY_MS).toISOString(),
+    });
+  }
+
+  return items;
+}
+
+/** 생성/수정 시 저장하지 않는 필드(itemId)를 제외한 패치 대상. */
+type DecorationItemPatch = Partial<Omit<DecorationItem, 'itemId'>>;
+
+type MockStoreState = {
+  seed: DecorationItem[];
+  created: DecorationItem[];
+  patches: Map<string, DecorationItemPatch>;
+  deletedIds: Set<string>;
+};
+
+const globalStore = globalThis as typeof globalThis & {
+  __decorationItemMockStore?: MockStoreState;
+};
+
+function getState(): MockStoreState {
+  globalStore.__decorationItemMockStore ??= {
+    seed: generateSeedItems(),
+    created: [],
+    patches: new Map(),
+    deletedIds: new Set(),
+  };
+  return globalStore.__decorationItemMockStore;
+}
+
+export type CreateDecorationItemInput = {
+  itemId: string;
+  itemType: DecorationItemType;
+  name: string;
+  category: DecorationItemCategory;
+  points: number;
+  description: string;
+  isActive: boolean;
+  sortOrder: number;
+};
+
+export type UpdateDecorationItemInput = Omit<
+  CreateDecorationItemInput,
+  'itemId'
+>;
+
+/**
+ * 전체 아이템을 반환한다(시드 + 등록분, 삭제분 제외, 패치 적용). 검색·정렬·유형 필터·페이징은
+ * 이 함수가 하지 않는다 — Repository가 전체를 대상으로 처리한다(admin-member-store.ts의
+ * `applyMockOverlay`와 같은 책임 분리).
+ */
+export function listAllDecorationItems(): DecorationItem[] {
+  const { seed, created, patches, deletedIds } = getState();
+
+  const merged = [...created, ...seed];
+
+  return merged
+    .filter((item) => !deletedIds.has(item.itemId))
+    .map((item) => {
+      const patch = patches.get(item.itemId);
+      return patch ? { ...item, ...patch } : item;
+    });
+}
+
+export function createMockDecorationItem(
+  input: CreateDecorationItemInput
+): DecorationItem {
+  const state = getState();
+
+  const item: DecorationItem = {
+    itemId: input.itemId,
+    itemType: input.itemType,
+    name: input.name,
+    category: input.category,
+    points: input.points,
+    description: input.description || null,
+    isActive: input.isActive,
+    sortOrder: input.sortOrder,
+    updatedAt: new Date().toISOString(),
+  };
+
+  state.created.unshift(item);
+  return item;
+}
+
+/**
+ * 수정 — 신규 등록 행은 원본을 직접 고치고, 시드에서 온 행은 패치로 기록해 둔다(원본 배열을
+ * 공유 상수처럼 다루지 않기 위해 매 조회마다 덧입힌다).
+ */
+export function updateMockDecorationItem(
+  itemId: string,
+  input: UpdateDecorationItemInput
+): void {
+  const state = getState();
+
+  const patch: DecorationItemPatch = {
+    itemType: input.itemType,
+    name: input.name,
+    category: input.category,
+    points: input.points,
+    description: input.description || null,
+    isActive: input.isActive,
+    sortOrder: input.sortOrder,
+    updatedAt: new Date().toISOString(),
+  };
+
+  const createdIndex = state.created.findIndex((item) => item.itemId === itemId);
+  if (createdIndex >= 0) {
+    state.created[createdIndex] = { ...state.created[createdIndex], ...patch };
+    return;
+  }
+
+  state.patches.set(itemId, { ...state.patches.get(itemId), ...patch });
+}
+
+export function deleteMockDecorationItem(itemId: string): void {
+  const state = getState();
+
+  state.created = state.created.filter((item) => item.itemId !== itemId);
+  state.patches.delete(itemId);
+  // 시드 배열(state.seed) 자체는 건드리지 않고 삭제된 id만 기억한다 — listAllDecorationItems가
+  // 조회 때마다 이 집합으로 걸러낸다(admin-member-store.ts의 deletedIds와 동일한 방식).
+  state.deletedIds.add(itemId);
+}
 
lib/data/repositories/decoration-item-repository.ts (added)
+++ lib/data/repositories/decoration-item-repository.ts
@@ -0,0 +1,128 @@
+import 'server-only';
+import {
+  createMockDecorationItem,
+  deleteMockDecorationItem,
+  listAllDecorationItems,
+  updateMockDecorationItem,
+  type CreateDecorationItemInput,
+  type UpdateDecorationItemInput,
+} from '@/lib/data/mock/decoration-item-store';
+import type { DecorationItem } from '@/lib/domain/decoration-item';
+import type {
+  DecorationItemQuery,
+  DecorationItemSearchField,
+} from '@/lib/domain/decoration-item-query';
+
+/**
+ * 꾸미기 아이템 Repository — **백엔드에 이 도메인의 API가 전혀 없다**(조회조차 없음, 사용자
+ * 확정 사항 — edupay-backend 저장소에 대응 컨트롤러/서비스/매퍼가 없음을 확인했다). 그래서
+ * admin-member-repository.ts처럼 "백엔드 응답 + mock 오버레이" 구조가 아니라, 이 계층 전체가
+ * `lib/data/mock/decoration-item-store.ts` 하나로만 동작한다 — 조회·등록·수정·삭제 전부.
+ *
+ * **캐시 전략**: 실제 `fetch` 호출이 없어 `cache: 'no-store'`나 `next: { revalidate, tags }`
+ * 같은 HTTP 캐시 옵션을 붙일 대상이 없다(CLAUDE.md §2.6이 요구하는 "명시"는 여기서는 "해당
+ * 없음"을 명시하는 것으로 갈음한다). 대신 신선도는 두 가지로 보장한다:
+ *   1. 호출부인 `page.tsx`가 `verifySession()`으로 쿠키를 읽어 이미 이 라우트를 동적 렌더링으로
+ *      강제한다(요청마다 새로 실행).
+ *   2. 각 Server Action이 저장 직후 `revalidatePath(DECORATION_ITEMS_PATH)`로 라우터 캐시를
+ *      무효화한다.
+ * admin-member-repository.ts와 동일한 신선도 보장 방식이다.
+ *
+ * 백엔드에 API가 생기면 이 파일의 "쓰기 경로" 세 함수와 조회 함수의 본문만 실제 `backendFetch`
+ * 호출로 바꾸면 되고, 화면·Server Action은 그대로다.
+ */
+
+const SEARCH_VALUE_BY_FIELD: Record<
+  DecorationItemSearchField,
+  (item: DecorationItem) => string
+> = {
+  name: (item) => item.name,
+  itemId: (item) => item.itemId,
+};
+
+function filterByKeyword(
+  items: DecorationItem[],
+  query: DecorationItemQuery
+): DecorationItem[] {
+  const keyword = query.keyword.trim().toLowerCase();
+  if (!keyword) {
+    return items;
+  }
+
+  const readValue = SEARCH_VALUE_BY_FIELD[query.searchField];
+  return items.filter((item) => readValue(item).toLowerCase().includes(keyword));
+}
+
+/** 정렬순서 오름차순(시안 기본 정렬). 값이 같으면 아이템ID로 안정 정렬해 페이지를 넘나들 때
+ *  순서가 흔들리지 않게 한다. */
+function sortBySortOrder(items: DecorationItem[]): DecorationItem[] {
+  return [...items].sort((a, b) =>
+    a.sortOrder !== b.sortOrder
+      ? a.sortOrder - b.sortOrder
+      : a.itemId.localeCompare(b.itemId)
+  );
+}
+
+export type DecorationItemPage = {
+  items: DecorationItem[];
+  /** 검색어까지 적용한 결과 건수 — 요약 문구·페이지네이션 기준. */
+  totalCount: number;
+  /**
+   * 검색어와 무관하게 현재 유형(개별/셋트) 전체 등록 건수. 등록/수정 팝업의
+   * "정렬순서 (총 등록 N개)" 힌트와 신규 등록 기본 정렬순서(맨 끝에 추가) 계산에 쓴다 — 검색
+   * 결과가 아니라 그 유형에 실제로 존재하는 전체 개수여야 하므로 `totalCount`와 분리했다.
+   */
+  typeTotalCount: number;
+};
+
+/** 검색·유형·페이징이 적용된 꾸미기 아이템 목록을 조회한다. */
+export async function fetchDecorationItems(
+  query: DecorationItemQuery
+): Promise<DecorationItemPage> {
+  const byType = listAllDecorationItems().filter(
+    (item) => item.itemType === query.itemType
+  );
+  const matched = sortBySortOrder(filterByKeyword(byType, query));
+  const offset = (query.page - 1) * query.pageSize;
+
+  return {
+    items: matched.slice(offset, offset + query.pageSize),
+    totalCount: matched.length,
+    typeTotalCount: byType.length,
+  };
+}
+
+/**
+ * 아이템ID 중복 확인. 시안에 admins의 [중복확인] 같은 별도 버튼은 없지만, 아이템ID가 유일
+ * 식별자이자 수정/삭제 대상 키라 등록 Server Action이 저장 직전에 반드시 확인한다
+ * (`_actions.ts` 참조) — 화면에는 이 확인이 보이지 않고 실패 시 필드 오류로만 나타난다.
+ */
+export async function isDecorationItemIdTaken(itemId: string): Promise<boolean> {
+  const normalized = itemId.trim().toLowerCase();
+  return listAllDecorationItems().some(
+    (item) => item.itemId.toLowerCase() === normalized
+  );
+}
+
+/*
+ * ─── 쓰기 경로 ────────────────────────────────────────────────────────────────
+ * 백엔드에 이 도메인의 API가 전혀 없어 세 함수 모두 mock 저장소에 위임한다
+ * (`lib/data/mock/decoration-item-store.ts`의 주석에 한계를 적어 두었다).
+ */
+
+export async function createDecorationItem(
+  input: CreateDecorationItemInput
+): Promise<void> {
+  createMockDecorationItem(input);
+}
+
+export async function updateDecorationItem(
+  itemId: string,
+  input: UpdateDecorationItemInput
+): Promise<void> {
+  updateMockDecorationItem(itemId, input);
+}
+
+export async function deleteDecorationItem(itemId: string): Promise<void> {
+  deleteMockDecorationItem(itemId);
+}
 
lib/domain/decoration-item-form.ts (added)
+++ lib/domain/decoration-item-form.ts
@@ -0,0 +1,167 @@
+/**
+ * 꾸미기 아이템 등록/수정 입력 규칙 — 순수 검증 로직만 담는다(외부 의존 없음).
+ *
+ * 시안(ADM_ITM_102_p/103_p) 기준 필수 항목: 유형·아이템ID(등록 전용)·아이템명·카테고리·포인트.
+ * 설명은 선택이고, 정렬순서는 시안에 별도 필수(*) 표시가 없다 — 다만 화면이 항상 기본값(현재
+ * 유형의 총 등록 개수+1, 또는 수정 시 기존 값)을 채워 제출하므로 실질적으로 비어 있는 경우가
+ * 없고, 그래도 숫자 형식은 여기서 검증한다(Server Action은 UI를 거치지 않고 직접 호출될 수
+ * 있다 — 화면의 기본값 채움은 편의일 뿐 신뢰 경계가 아니다).
+ *
+ * **이 파일이 검증의 단일 진실원천이다.** Server Action(`_actions.ts`)이 저장 직전에 여기를
+ * 거친다.
+ *
+ * 아이템ID는 백엔드 코드 체계가 없어(사용자 확정 — 이 도메인 전체가 mock) 시안 예시
+ * (`ITEM-HAT-002`)를 참고한 느슨한 형식만 강제한다: 영문/숫자 세그먼트를 하이픈으로 구분.
+ *
+ * **등록과 수정의 검증을 분리한 이유**: 수정 팝업에서 아이템ID는 읽기 전용이다(시안
+ * ADM_ITM_103_p). 수정은 실제로 바뀔 수 있는 항목(유형/아이템명/카테고리/포인트/설명/사용여부/
+ * 정렬순서)만 검증한다.
+ */
+
+import {
+  DECORATION_ITEM_CATEGORIES,
+  DECORATION_ITEM_TYPE_OPTIONS,
+  type DecorationItemCategory,
+  type DecorationItemType,
+} from '@/lib/domain/decoration-item';
+
+export const DECORATION_ITEM_ID_HELP_TEXT =
+  '영문·숫자와 하이픈(-)으로 입력해 주세요. 예: ITEM-HAT-002';
+
+const ITEM_ID_MIN_LENGTH = 3;
+const ITEM_ID_MAX_LENGTH = 50;
+const ITEM_ID_PATTERN = /^[A-Za-z0-9]+(-[A-Za-z0-9]+)*$/;
+const NAME_MAX_LENGTH = 100;
+const DESCRIPTION_MAX_LENGTH = 500;
+
+/** 등록·수정 양쪽에서 실제로 바뀔 수 있는 항목. */
+export type DecorationItemEditableValues = {
+  itemType: DecorationItemType;
+  name: string;
+  category: DecorationItemCategory;
+  points: number;
+  description: string;
+  isActive: boolean;
+  sortOrder: number;
+};
+
+/** 등록은 위 항목에 더해 아이템ID를 입력받는다(수정에서는 읽기 전용). */
+export type DecorationItemCreateValues = DecorationItemEditableValues & {
+  itemId: string;
+};
+
+/** 필드별 오류 메시지 — 키는 폼 필드 이름과 일치시켜 화면이 그대로 붙여 쓸 수 있게 한다. */
+export type DecorationItemFormErrors = Partial<
+  Record<keyof DecorationItemCreateValues, string>
+>;
+
+export type ValidationResult<T> =
+  | { ok: true; values: T }
+  | { ok: false; errors: DecorationItemFormErrors };
+
+function isDecorationItemType(value: string): value is DecorationItemType {
+  return DECORATION_ITEM_TYPE_OPTIONS.some((option) => option.value === value);
+}
+
+function isDecorationItemCategory(
+  value: string
+): value is DecorationItemCategory {
+  return (DECORATION_ITEM_CATEGORIES as readonly string[]).includes(value);
+}
+
+/**
+ * 아이템ID 형식 검증 — 문제가 있으면 안내 문구, 없으면 null. 중복 확인은 여기서 하지 않는다
+ * (Repository/Server Action의 몫 — `decoration-item-repository.ts` 주석 참조).
+ */
+export function validateDecorationItemId(itemId: string): string | null {
+  const value = itemId.trim();
+
+  if (!value) {
+    return '아이템 ID를 입력해 주세요.';
+  }
+  if (value.length < ITEM_ID_MIN_LENGTH || value.length > ITEM_ID_MAX_LENGTH) {
+    return `아이템 ID는 ${ITEM_ID_MIN_LENGTH}~${ITEM_ID_MAX_LENGTH}자로 입력해 주세요.`;
+  }
+  if (!ITEM_ID_PATTERN.test(value)) {
+    return DECORATION_ITEM_ID_HELP_TEXT;
+  }
+  return null;
+}
+
+/**
+ * 등록·수정 공통 항목 검증. 오류는 넘겨받은 객체에 채워 넣고, 정규화된 값을 돌려준다.
+ *
+ * `points`/`sortOrder`는 `_actions.ts`가 FormData 문자열을 숫자로 변환해 넘긴다 — 빈 문자열을
+ * `Number.isInteger`가 걸러낼 수 있도록 그 변환은 `Number('')`가 아니라 명시적으로 실패시키는
+ * 헬퍼(`_actions.ts`의 `parseFormNumber`)를 거친 값이어야 한다(빈 문자열을 그냥 `Number()`에
+ * 넘기면 0으로 조용히 통과해 필수 값 누락을 놓친다).
+ */
+function validateEditableValues(
+  values: DecorationItemEditableValues,
+  errors: DecorationItemFormErrors
+): DecorationItemEditableValues {
+  const name = values.name.trim();
+  const description = values.description.trim();
+
+  if (!isDecorationItemType(values.itemType)) {
+    errors.itemType = '유형을 선택해 주세요.';
+  }
+
+  if (!name) {
+    errors.name = '아이템명을 입력해 주세요.';
+  } else if (name.length > NAME_MAX_LENGTH) {
+    errors.name = `아이템명은 ${NAME_MAX_LENGTH}자 이내로 입력해 주세요.`;
+  }
+
+  if (!isDecorationItemCategory(values.category)) {
+    errors.category = '카테고리를 선택해 주세요.';
+  }
+
+  if (!Number.isInteger(values.points) || values.points < 0) {
+    errors.points = '포인트는 0 이상의 숫자로 입력해 주세요.';
+  }
+
+  if (description.length > DESCRIPTION_MAX_LENGTH) {
+    errors.description = `설명은 ${DESCRIPTION_MAX_LENGTH}자 이내로 입력해 주세요.`;
+  }
+
+  if (!Number.isInteger(values.sortOrder) || values.sortOrder < 1) {
+    errors.sortOrder = '정렬순서는 1 이상의 숫자로 입력해 주세요.';
+  }
+
+  return { ...values, name, description };
+}
+
+/** 시안 ADM_ITM_102_p — 등록 검증(아이템ID 포함). */
+export function validateDecorationItemCreate(
+  values: DecorationItemCreateValues
+): ValidationResult<DecorationItemCreateValues> {
+  const errors: DecorationItemFormErrors = {};
+
+  const itemIdError = validateDecorationItemId(values.itemId);
+  if (itemIdError) {
+    errors.itemId = itemIdError;
+  }
+
+  const editable = validateEditableValues(values, errors);
+
+  if (Object.keys(errors).length > 0) {
+    return { ok: false, errors };
+  }
+
+  return { ok: true, values: { ...editable, itemId: values.itemId.trim() } };
+}
+
+/** 시안 ADM_ITM_103_p — 수정 검증. 아이템ID는 읽기 전용이라 검증 대상이 아니다(파일 상단 주석). */
+export function validateDecorationItemUpdate(
+  values: DecorationItemEditableValues
+): ValidationResult<DecorationItemEditableValues> {
+  const errors: DecorationItemFormErrors = {};
+  const editable = validateEditableValues(values, errors);
+
+  if (Object.keys(errors).length > 0) {
+    return { ok: false, errors };
+  }
+
+  return { ok: true, values: editable };
+}
 
lib/domain/decoration-item-query.ts (added)
+++ lib/domain/decoration-item-query.ts
@@ -0,0 +1,154 @@
+/**
+ * 꾸미기 아이템 목록의 유형탭·검색·페이징 조건 — 순수 규칙(허용 값·기본값·URL 직렬화)만
+ * 담는다. next/react 의존이 없다(`URLSearchParams`는 서버·브라우저 양쪽에서 쓸 수 있는 표준
+ * Web API).
+ *
+ * `app/(protected)/(basic)/decoration-items/page.tsx`가 `searchParams`를
+ * `parseDecorationItemQuery`로 정규화하는 지점이자 단일 진실원천이며, 유형 탭·검색바·툴바·
+ * 페이지네이션은 모두 `buildDecorationItemHref`로 같은 규칙에 따라 URL을 만들어 파라미터
+ * 이름·기본값이 여러 파일에 흩어져 드리프트하는 것을 막는다(admin-member-query.ts와 동일한
+ * 설계).
+ *
+ * **유형 탭(개별/셋트)도 이 쿼리의 일부다** — 시안이 "셋트아이템도 동일한 항목으로 처리하며
+ * 별도 화면이 없다"고 명시해, 탭 전환은 라우트 이동이 아니라 이 화면 안에서 `itemType` 값만
+ * 바뀌는 것으로 구현한다.
+ *
+ * 시안(ADM_ITM_101)에는 admins의 "정렬" select에 대응하는 항목이 없다 — 목록은 항상 정렬순서
+ * 오름차순 고정이라 이 파일에 정렬 옵션을 두지 않는다(Repository가 고정 규칙으로 정렬한다).
+ */
+
+import {
+  DECORATION_ITEM_TYPE_OPTIONS,
+  DEFAULT_DECORATION_ITEM_TYPE,
+  type DecorationItemType,
+} from '@/lib/domain/decoration-item';
+
+/** 라우트 경로 — 이 파일 안에서만 하드코딩하고 나머지는 이 상수를 참조한다. */
+export const DECORATION_ITEMS_PATH = '/decoration-items';
+
+/** 검색 대상 — 시안(ADM_ITM_101 ①) "아이템명 / 아이템ID" 그대로. */
+export type DecorationItemSearchField = 'name' | 'itemId';
+
+export const DECORATION_ITEM_SEARCH_FIELD_OPTIONS: ReadonlyArray<{
+  value: DecorationItemSearchField;
+  label: string;
+}> = [
+  { value: 'name', label: '아이템명' },
+  { value: 'itemId', label: '아이템ID' },
+];
+
+export const DECORATION_ITEM_PAGE_SIZE_OPTIONS = [10, 30, 50] as const;
+export type DecorationItemPageSize =
+  (typeof DECORATION_ITEM_PAGE_SIZE_OPTIONS)[number];
+
+export const DEFAULT_DECORATION_ITEM_SEARCH_FIELD: DecorationItemSearchField =
+  'name';
+export const DEFAULT_DECORATION_ITEM_PAGE_SIZE: DecorationItemPageSize = 10;
+const DEFAULT_PAGE = 1;
+const MAX_KEYWORD_LENGTH = 100;
+
+export type DecorationItemQuery = {
+  itemType: DecorationItemType;
+  searchField: DecorationItemSearchField;
+  keyword: string;
+  page: number;
+  pageSize: DecorationItemPageSize;
+};
+
+/** Next.js `page.tsx`의 `searchParams`가 리졸브하는 값 형태를 그대로 옮긴 구조 타입 —
+ *  next 패키지를 import하지 않고도 같은 shape을 표현해 domain 계층의 무의존 규칙을 지킨다. */
+type RawSearchParams = Record<string, string | string[] | undefined>;
+
+function readParam(params: RawSearchParams, key: string): string | undefined {
+  const value = params[key];
+  return Array.isArray(value) ? value[0] : value;
+}
+
+function isDecorationItemType(
+  value: string | undefined
+): value is DecorationItemType {
+  return (
+    value !== undefined &&
+    DECORATION_ITEM_TYPE_OPTIONS.some((option) => option.value === value)
+  );
+}
+
+function isDecorationItemSearchField(
+  value: string | undefined
+): value is DecorationItemSearchField {
+  return (
+    value !== undefined &&
+    DECORATION_ITEM_SEARCH_FIELD_OPTIONS.some((option) => option.value === value)
+  );
+}
+
+function isDecorationItemPageSize(
+  value: number
+): value is DecorationItemPageSize {
+  return (DECORATION_ITEM_PAGE_SIZE_OPTIONS as readonly number[]).includes(
+    value
+  );
+}
+
+/**
+ * URL의 searchParams를 검증된 `DecorationItemQuery`로 정규화한다. 값이 없거나 허용 목록을
+ * 벗어나면 기본값으로 fallback한다 — searchParams는 사용자가 임의로 조작 가능한 값이라
+ * 신뢰하지 않는다.
+ */
+export function parseDecorationItemQuery(
+  searchParams: RawSearchParams
+): DecorationItemQuery {
+  const itemTypeRaw = readParam(searchParams, 'itemType');
+  const searchFieldRaw = readParam(searchParams, 'searchField');
+  const keywordRaw = readParam(searchParams, 'keyword');
+  const pageRaw = Number(readParam(searchParams, 'page'));
+  const pageSizeRaw = Number(readParam(searchParams, 'pageSize'));
+
+  return {
+    itemType: isDecorationItemType(itemTypeRaw)
+      ? itemTypeRaw
+      : DEFAULT_DECORATION_ITEM_TYPE,
+    searchField: isDecorationItemSearchField(searchFieldRaw)
+      ? searchFieldRaw
+      : DEFAULT_DECORATION_ITEM_SEARCH_FIELD,
+    keyword: (keywordRaw ?? '').trim().slice(0, MAX_KEYWORD_LENGTH),
+    page: Number.isInteger(pageRaw) && pageRaw > 0 ? pageRaw : DEFAULT_PAGE,
+    pageSize: isDecorationItemPageSize(pageSizeRaw)
+      ? pageSizeRaw
+      : DEFAULT_DECORATION_ITEM_PAGE_SIZE,
+  };
+}
+
+/**
+ * `DecorationItemQuery`(+ 부분 override)를 `/decoration-items` 링크로 직렬화한다.
+ * `parseDecorationItemQuery`의 역연산이며, 기본값과 같은 필드는 URL에서 생략해 링크를 짧게
+ * 유지한다.
+ */
+export function buildDecorationItemHref(
+  query: DecorationItemQuery,
+  overrides: Partial<DecorationItemQuery> = {}
+): string {
+  const merged = { ...query, ...overrides };
+  const params = new URLSearchParams();
+
+  if (merged.itemType !== DEFAULT_DECORATION_ITEM_TYPE) {
+    params.set('itemType', merged.itemType);
+  }
+  if (merged.searchField !== DEFAULT_DECORATION_ITEM_SEARCH_FIELD) {
+    params.set('searchField', merged.searchField);
+  }
+  if (merged.keyword) {
+    params.set('keyword', merged.keyword);
+  }
+  if (merged.pageSize !== DEFAULT_DECORATION_ITEM_PAGE_SIZE) {
+    params.set('pageSize', String(merged.pageSize));
+  }
+  if (merged.page !== DEFAULT_PAGE) {
+    params.set('page', String(merged.page));
+  }
+
+  const queryString = params.toString();
+  return queryString
+    ? `${DECORATION_ITEMS_PATH}?${queryString}`
+    : DECORATION_ITEMS_PATH;
+}
 
lib/domain/decoration-item.ts (added)
+++ lib/domain/decoration-item.ts
@@ -0,0 +1,92 @@
+/**
+ * 꾸미기 아이템 도메인 타입 — 순수 데이터 표현, 외부 의존 없음.
+ *
+ * **백엔드(edupay-backend)에 이 도메인의 API가 전혀 없다** — 조회조차 없다(사용자 확정 사항).
+ * 그래서 이 타입의 실제 값은 전부 `lib/data/mock/decoration-item-store.ts`가 만들어 내고,
+ * "백엔드 응답 → 도메인 매핑" 절차 자체가 존재하지 않는다 — admin-member.ts/student-member.ts와
+ * 달리 `null`이 "백엔드가 아직 주지 않는 항목"을 뜻하지 않는다(이 타입에서 `description`의
+ * `null`은 순수하게 "값 없음"이다).
+ */
+
+/**
+ * 유형 — 개별아이템 / 셋트아이템. 시안(ADM_ITM_101, ADM_ITM_102_p) 두 곳 모두 "셋트아이템도
+ * 동일한 항목으로 처리하며 별도 화면이 없다"고 명시한다 — 목록의 유형 탭과 등록/수정 폼의 값이
+ * 모두 이 타입 하나를 공유한다.
+ */
+export type DecorationItemType = 'individual' | 'set';
+
+export const DECORATION_ITEM_TYPE_OPTIONS: ReadonlyArray<{
+  value: DecorationItemType;
+  label: string;
+}> = [
+  { value: 'individual', label: '개별아이템' },
+  { value: 'set', label: '셋트아이템' },
+];
+
+export const DEFAULT_DECORATION_ITEM_TYPE: DecorationItemType = 'individual';
+
+/**
+ * 카테고리 — **백엔드 코드테이블이 없어 시안 예시값을 그대로 mock 상수로 둔다**(사용자 확정
+ * 사항. 시안 설명: "추후 확장성을 위해 필요, As is는 단일 카테고리로 운영"). 백엔드에 코드테이블
+ * API가 생기면 이 상수를 지우고 그 응답으로 대체한다.
+ */
+export const DECORATION_ITEM_CATEGORIES = ['계절', '축하', '시즌'] as const;
+export type DecorationItemCategory = (typeof DECORATION_ITEM_CATEGORIES)[number];
+
+export type DecorationItem = {
+  /**
+   * 아이템ID — 등록 시 사용자가 직접 입력하는 유일 식별자(예: `ITEM-HAT-002`). 백엔드가 없어
+   * 별도 내부 id를 두지 않는다 — 목록 행의 key이자 수정/삭제 Server Action의 입력값이다.
+   * 등록 후에는 변경할 수 없다(시안 ADM_ITM_103_p).
+   */
+  itemId: string;
+  itemType: DecorationItemType;
+  name: string;
+  category: DecorationItemCategory;
+  /** 오픈 가능한 포인트. */
+  points: number;
+  description: string | null;
+  /** 사용여부 — 표에서는 O/X로 표기한다(`formatDecorationItemActiveLabel`). */
+  isActive: boolean;
+  /**
+   * 정렬순서 — 목록 기본 정렬 기준(오름차순, 시안 ADM_ITM_101). **유형(개별/셋트)마다 독립된
+   * 순번 공간이다** — 두 탭이 같은 화면을 재사용할 뿐 사실상 별개 목록이라, 한쪽의 정렬순서가
+   * 다른 쪽 순번에 영향을 주지 않는다(mock 생성기·Repository가 이 규칙을 지킨다).
+   */
+  sortOrder: number;
+  /** ISO 8601 문자열 — 수정일시. */
+  updatedAt: string;
+};
+
+/** 사용여부 → 화면 표기(시안: O/X). */
+export function formatDecorationItemActiveLabel(isActive: boolean): string {
+  return isActive ? 'O' : 'X';
+}
+
+/** 포인트 → 화면 표기(시안: "100 P" 형태). */
+export function formatDecorationItemPoints(points: number): string {
+  return `${points.toLocaleString('ko-KR')} P`;
+}
+
+/**
+ * ISO 수정일시 → "YYYY-MM-DD HH:mm". mock이 생성하는 값이 항상 `Date#toISOString()` 형식
+ * (`YYYY-MM-DDTHH:mm:ss.sssZ`)이라 별도 날짜 라이브러리 없이 문자열 절단만으로 충분하다(신규
+ * 의존성 추가 금지 — CLAUDE.md §4.2).
+ */
+export function formatDecorationItemUpdatedAt(updatedAt: string): string {
+  return updatedAt.slice(0, 16).replace('T', ' ');
+}
+
+/**
+ * 썸네일 자리의 플레이스홀더 텍스트 — **의도적으로 이미지가 아니다.** 시안(ADM_ITM_102_p)은
+ * 썸네일 이미지 업로드 필드를 요구하지만, 사용자가 "썸네일을 따로 등록하지 않고 추후 백엔드가
+ * 자동생성해주는 형식"이라고 확정해 등록/수정 폼에서 업로드 필드를 제외했다(시안과 다르게 가는
+ * 지점). 목록의 썸네일 컬럼 자체는 유지하되(백엔드가 자동생성한 이미지를 표시할 자리), 지금은
+ * 생성해 줄 백엔드가 없어 아이템ID의 중간 세그먼트(예: `ITEM-HAT-002` → `HAT`)를 뽑아 대신
+ * 보여준다 — 실제 썸네일 API가 생기면 표 컴포넌트의 이 자리만 `<img>`로 바꾸면 된다.
+ */
+export function getDecorationItemThumbnailLabel(itemId: string): string {
+  const segments = itemId.split('-');
+  const middle = segments.length >= 2 ? segments[1] : itemId;
+  return middle.slice(0, 3).toUpperCase();
+}
Add a comment
List