feat: 꾸미기 아이템 도메인 타입과 mock Repository 추가
Co-Authored-By: Claude Opus 5
@92e0c0a678848917d70f14559d6f07450ceac04b
+++ lib/data/mock/decoration-item-store.ts
... | ... | @@ -0,0 +1,212 @@ |
| 1 | +import 'server-only'; | |
| 2 | +import { | |
| 3 | + DECORATION_ITEM_CATEGORIES, | |
| 4 | + type DecorationItem, | |
| 5 | + type DecorationItemCategory, | |
| 6 | + type DecorationItemType, | |
| 7 | +} from '@/lib/domain/decoration-item'; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * 꾸미기 아이템의 **mock 저장소** — 백엔드(edupay-backend)에 이 도메인의 API가 전혀 없어 | |
| 11 | + * (조회조차 없음, 사용자 확정 사항) admin-member-store.ts처럼 "백엔드 응답 위에 변경분을 | |
| 12 | + * 얹는 오버레이"가 아니라, **이 파일이 유일한 데이터 원본**이다. 조회·등록·수정·삭제 전부 | |
| 13 | + * 여기서 끝난다. | |
| 14 | + * | |
| 15 | + * **한계를 분명히 해 둔다 — 이건 데모용이지 저장소가 아니다.** | |
| 16 | + * - 서버 프로세스 메모리에만 있다. 재시작하면 시드로 되돌아가고, 인스턴스가 여럿이면 | |
| 17 | + * 공유되지 않는다. | |
| 18 | + * - 개발 중 HMR로 모듈이 다시 평가돼도 상태가 초기화되지 않도록 globalThis에 붙인다 | |
| 19 | + * (admin-member-store.ts와 동일한 편법 — mock 전용이며 실제 데이터 계층에는 쓰지 않는다). | |
| 20 | + * | |
| 21 | + * 백엔드 API가 생기면 이 파일을 삭제하고 `decoration-item-repository.ts`의 함수 본문만 실제 | |
| 22 | + * 호출로 교체한다 — 화면·Server Action은 그대로다. | |
| 23 | + */ | |
| 24 | + | |
| 25 | +/** 아이템ID 접두사 — 시안 예시(`ITEM-HAT-002`, `ITEM-TOP-014`, `ITEM-ACC-007`)의 접두사를 | |
| 26 | + * 순환시킨다. 카테고리(계절/축하/시즌)와는 별개 축이다 — 이쪽은 "무엇을 꾸미는 아이템인지", | |
| 27 | + * 카테고리는 "언제/어떤 테마인지"를 나타낸다. */ | |
| 28 | +const ID_PREFIXES = ['HAT', 'TOP', 'ACC', 'BG', 'FACE'] as const; | |
| 29 | +type IdPrefix = (typeof ID_PREFIXES)[number]; | |
| 30 | +const ID_PREFIX_LABELS: Record<IdPrefix, string> = { | |
| 31 | + HAT: '모자', | |
| 32 | + TOP: '상의', | |
| 33 | + ACC: '액세서리', | |
| 34 | + BG: '배경', | |
| 35 | + FACE: '표정', | |
| 36 | +}; | |
| 37 | + | |
| 38 | +/** 시안의 "총 39개"에 맞춘 시드 건수(사용자 확정 사항). */ | |
| 39 | +const SEED_ITEM_COUNT = 39; | |
| 40 | +/** 결정적 생성을 위한 고정 기준일(UTC) — `Date.now()`/`Math.random()`을 쓰지 않는다. */ | |
| 41 | +const SEED_BASE_DATE_UTC = Date.UTC(2026, 0, 1); | |
| 42 | +const DAY_MS = 24 * 60 * 60 * 1000; | |
| 43 | + | |
| 44 | +/** | |
| 45 | + * 39건의 시드 아이템을 결정적으로 생성한다. 인덱스 기반이라 매번 같은 결과를 낸다 | |
| 46 | + * (`Math.random()` 금지 — CLAUDE.md 작업 지시). | |
| 47 | + * | |
| 48 | + * - 유형은 인덱스가 3의 배수일 때만 `set`으로 둬 개별 26 / 셋트 13으로 나눈다 — 두 탭 모두 | |
| 49 | + * 10개씩 페이지네이션했을 때 여러 페이지가 생겨(3페이지/2페이지) 탭 전환·페이지 이동을 함께 | |
| 50 | + * 확인할 수 있다. | |
| 51 | + * - 정렬순서는 유형별 카운터로 별도 채번한다 — `DecorationItem.sortOrder` 주석대로 두 탭이 | |
| 52 | + * 서로 다른 순번 공간을 쓰기 때문이다. | |
| 53 | + * - 사용여부는 7건마다 미사용(X)을 섞어 표에서 O/X가 둘 다 보이게 한다. | |
| 54 | + */ | |
| 55 | +function generateSeedItems(): DecorationItem[] { | |
| 56 | + const prefixCounters = new Map<IdPrefix, number>(); | |
| 57 | + const typeCounters: Record<DecorationItemType, number> = { | |
| 58 | + individual: 0, | |
| 59 | + set: 0, | |
| 60 | + }; | |
| 61 | + const items: DecorationItem[] = []; | |
| 62 | + | |
| 63 | + for (let index = 0; index < SEED_ITEM_COUNT; index += 1) { | |
| 64 | + const prefix = ID_PREFIXES[index % ID_PREFIXES.length]; | |
| 65 | + const prefixSeq = (prefixCounters.get(prefix) ?? 0) + 1; | |
| 66 | + prefixCounters.set(prefix, prefixSeq); | |
| 67 | + const paddedSeq = String(prefixSeq).padStart(3, '0'); | |
| 68 | + | |
| 69 | + const itemType: DecorationItemType = index % 3 === 0 ? 'set' : 'individual'; | |
| 70 | + typeCounters[itemType] += 1; | |
| 71 | + | |
| 72 | + const category: DecorationItemCategory = | |
| 73 | + DECORATION_ITEM_CATEGORIES[index % DECORATION_ITEM_CATEGORIES.length]; | |
| 74 | + const label = ID_PREFIX_LABELS[prefix]; | |
| 75 | + | |
| 76 | + items.push({ | |
| 77 | + itemId: `ITEM-${prefix}-${paddedSeq}`, | |
| 78 | + itemType, | |
| 79 | + name: | |
| 80 | + itemType === 'set' | |
| 81 | + ? `${label} 세트 ${paddedSeq}` | |
| 82 | + : `${label} 아이템 ${paddedSeq}`, | |
| 83 | + category, | |
| 84 | + points: ((index % 5) + 1) * 50, | |
| 85 | + description: index % 2 === 0 ? `${label} ${paddedSeq} 설명입니다.` : null, | |
| 86 | + isActive: index % 7 !== 0, | |
| 87 | + sortOrder: typeCounters[itemType], | |
| 88 | + updatedAt: new Date(SEED_BASE_DATE_UTC + index * DAY_MS).toISOString(), | |
| 89 | + }); | |
| 90 | + } | |
| 91 | + | |
| 92 | + return items; | |
| 93 | +} | |
| 94 | + | |
| 95 | +/** 생성/수정 시 저장하지 않는 필드(itemId)를 제외한 패치 대상. */ | |
| 96 | +type DecorationItemPatch = Partial<Omit<DecorationItem, 'itemId'>>; | |
| 97 | + | |
| 98 | +type MockStoreState = { | |
| 99 | + seed: DecorationItem[]; | |
| 100 | + created: DecorationItem[]; | |
| 101 | + patches: Map<string, DecorationItemPatch>; | |
| 102 | + deletedIds: Set<string>; | |
| 103 | +}; | |
| 104 | + | |
| 105 | +const globalStore = globalThis as typeof globalThis & { | |
| 106 | + __decorationItemMockStore?: MockStoreState; | |
| 107 | +}; | |
| 108 | + | |
| 109 | +function getState(): MockStoreState { | |
| 110 | + globalStore.__decorationItemMockStore ??= { | |
| 111 | + seed: generateSeedItems(), | |
| 112 | + created: [], | |
| 113 | + patches: new Map(), | |
| 114 | + deletedIds: new Set(), | |
| 115 | + }; | |
| 116 | + return globalStore.__decorationItemMockStore; | |
| 117 | +} | |
| 118 | + | |
| 119 | +export type CreateDecorationItemInput = { | |
| 120 | + itemId: string; | |
| 121 | + itemType: DecorationItemType; | |
| 122 | + name: string; | |
| 123 | + category: DecorationItemCategory; | |
| 124 | + points: number; | |
| 125 | + description: string; | |
| 126 | + isActive: boolean; | |
| 127 | + sortOrder: number; | |
| 128 | +}; | |
| 129 | + | |
| 130 | +export type UpdateDecorationItemInput = Omit< | |
| 131 | + CreateDecorationItemInput, | |
| 132 | + 'itemId' | |
| 133 | +>; | |
| 134 | + | |
| 135 | +/** | |
| 136 | + * 전체 아이템을 반환한다(시드 + 등록분, 삭제분 제외, 패치 적용). 검색·정렬·유형 필터·페이징은 | |
| 137 | + * 이 함수가 하지 않는다 — Repository가 전체를 대상으로 처리한다(admin-member-store.ts의 | |
| 138 | + * `applyMockOverlay`와 같은 책임 분리). | |
| 139 | + */ | |
| 140 | +export function listAllDecorationItems(): DecorationItem[] { | |
| 141 | + const { seed, created, patches, deletedIds } = getState(); | |
| 142 | + | |
| 143 | + const merged = [...created, ...seed]; | |
| 144 | + | |
| 145 | + return merged | |
| 146 | + .filter((item) => !deletedIds.has(item.itemId)) | |
| 147 | + .map((item) => { | |
| 148 | + const patch = patches.get(item.itemId); | |
| 149 | + return patch ? { ...item, ...patch } : item; | |
| 150 | + }); | |
| 151 | +} | |
| 152 | + | |
| 153 | +export function createMockDecorationItem( | |
| 154 | + input: CreateDecorationItemInput | |
| 155 | +): DecorationItem { | |
| 156 | + const state = getState(); | |
| 157 | + | |
| 158 | + const item: DecorationItem = { | |
| 159 | + itemId: input.itemId, | |
| 160 | + itemType: input.itemType, | |
| 161 | + name: input.name, | |
| 162 | + category: input.category, | |
| 163 | + points: input.points, | |
| 164 | + description: input.description || null, | |
| 165 | + isActive: input.isActive, | |
| 166 | + sortOrder: input.sortOrder, | |
| 167 | + updatedAt: new Date().toISOString(), | |
| 168 | + }; | |
| 169 | + | |
| 170 | + state.created.unshift(item); | |
| 171 | + return item; | |
| 172 | +} | |
| 173 | + | |
| 174 | +/** | |
| 175 | + * 수정 — 신규 등록 행은 원본을 직접 고치고, 시드에서 온 행은 패치로 기록해 둔다(원본 배열을 | |
| 176 | + * 공유 상수처럼 다루지 않기 위해 매 조회마다 덧입힌다). | |
| 177 | + */ | |
| 178 | +export function updateMockDecorationItem( | |
| 179 | + itemId: string, | |
| 180 | + input: UpdateDecorationItemInput | |
| 181 | +): void { | |
| 182 | + const state = getState(); | |
| 183 | + | |
| 184 | + const patch: DecorationItemPatch = { | |
| 185 | + itemType: input.itemType, | |
| 186 | + name: input.name, | |
| 187 | + category: input.category, | |
| 188 | + points: input.points, | |
| 189 | + description: input.description || null, | |
| 190 | + isActive: input.isActive, | |
| 191 | + sortOrder: input.sortOrder, | |
| 192 | + updatedAt: new Date().toISOString(), | |
| 193 | + }; | |
| 194 | + | |
| 195 | + const createdIndex = state.created.findIndex((item) => item.itemId === itemId); | |
| 196 | + if (createdIndex >= 0) { | |
| 197 | + state.created[createdIndex] = { ...state.created[createdIndex], ...patch }; | |
| 198 | + return; | |
| 199 | + } | |
| 200 | + | |
| 201 | + state.patches.set(itemId, { ...state.patches.get(itemId), ...patch }); | |
| 202 | +} | |
| 203 | + | |
| 204 | +export function deleteMockDecorationItem(itemId: string): void { | |
| 205 | + const state = getState(); | |
| 206 | + | |
| 207 | + state.created = state.created.filter((item) => item.itemId !== itemId); | |
| 208 | + state.patches.delete(itemId); | |
| 209 | + // 시드 배열(state.seed) 자체는 건드리지 않고 삭제된 id만 기억한다 — listAllDecorationItems가 | |
| 210 | + // 조회 때마다 이 집합으로 걸러낸다(admin-member-store.ts의 deletedIds와 동일한 방식). | |
| 211 | + state.deletedIds.add(itemId); | |
| 212 | +} |
+++ lib/data/repositories/decoration-item-repository.ts
... | ... | @@ -0,0 +1,128 @@ |
| 1 | +import 'server-only'; | |
| 2 | +import { | |
| 3 | + createMockDecorationItem, | |
| 4 | + deleteMockDecorationItem, | |
| 5 | + listAllDecorationItems, | |
| 6 | + updateMockDecorationItem, | |
| 7 | + type CreateDecorationItemInput, | |
| 8 | + type UpdateDecorationItemInput, | |
| 9 | +} from '@/lib/data/mock/decoration-item-store'; | |
| 10 | +import type { DecorationItem } from '@/lib/domain/decoration-item'; | |
| 11 | +import type { | |
| 12 | + DecorationItemQuery, | |
| 13 | + DecorationItemSearchField, | |
| 14 | +} from '@/lib/domain/decoration-item-query'; | |
| 15 | + | |
| 16 | +/** | |
| 17 | + * 꾸미기 아이템 Repository — **백엔드에 이 도메인의 API가 전혀 없다**(조회조차 없음, 사용자 | |
| 18 | + * 확정 사항 — edupay-backend 저장소에 대응 컨트롤러/서비스/매퍼가 없음을 확인했다). 그래서 | |
| 19 | + * admin-member-repository.ts처럼 "백엔드 응답 + mock 오버레이" 구조가 아니라, 이 계층 전체가 | |
| 20 | + * `lib/data/mock/decoration-item-store.ts` 하나로만 동작한다 — 조회·등록·수정·삭제 전부. | |
| 21 | + * | |
| 22 | + * **캐시 전략**: 실제 `fetch` 호출이 없어 `cache: 'no-store'`나 `next: { revalidate, tags }` | |
| 23 | + * 같은 HTTP 캐시 옵션을 붙일 대상이 없다(CLAUDE.md §2.6이 요구하는 "명시"는 여기서는 "해당 | |
| 24 | + * 없음"을 명시하는 것으로 갈음한다). 대신 신선도는 두 가지로 보장한다: | |
| 25 | + * 1. 호출부인 `page.tsx`가 `verifySession()`으로 쿠키를 읽어 이미 이 라우트를 동적 렌더링으로 | |
| 26 | + * 강제한다(요청마다 새로 실행). | |
| 27 | + * 2. 각 Server Action이 저장 직후 `revalidatePath(DECORATION_ITEMS_PATH)`로 라우터 캐시를 | |
| 28 | + * 무효화한다. | |
| 29 | + * admin-member-repository.ts와 동일한 신선도 보장 방식이다. | |
| 30 | + * | |
| 31 | + * 백엔드에 API가 생기면 이 파일의 "쓰기 경로" 세 함수와 조회 함수의 본문만 실제 `backendFetch` | |
| 32 | + * 호출로 바꾸면 되고, 화면·Server Action은 그대로다. | |
| 33 | + */ | |
| 34 | + | |
| 35 | +const SEARCH_VALUE_BY_FIELD: Record< | |
| 36 | + DecorationItemSearchField, | |
| 37 | + (item: DecorationItem) => string | |
| 38 | +> = { | |
| 39 | + name: (item) => item.name, | |
| 40 | + itemId: (item) => item.itemId, | |
| 41 | +}; | |
| 42 | + | |
| 43 | +function filterByKeyword( | |
| 44 | + items: DecorationItem[], | |
| 45 | + query: DecorationItemQuery | |
| 46 | +): DecorationItem[] { | |
| 47 | + const keyword = query.keyword.trim().toLowerCase(); | |
| 48 | + if (!keyword) { | |
| 49 | + return items; | |
| 50 | + } | |
| 51 | + | |
| 52 | + const readValue = SEARCH_VALUE_BY_FIELD[query.searchField]; | |
| 53 | + return items.filter((item) => readValue(item).toLowerCase().includes(keyword)); | |
| 54 | +} | |
| 55 | + | |
| 56 | +/** 정렬순서 오름차순(시안 기본 정렬). 값이 같으면 아이템ID로 안정 정렬해 페이지를 넘나들 때 | |
| 57 | + * 순서가 흔들리지 않게 한다. */ | |
| 58 | +function sortBySortOrder(items: DecorationItem[]): DecorationItem[] { | |
| 59 | + return [...items].sort((a, b) => | |
| 60 | + a.sortOrder !== b.sortOrder | |
| 61 | + ? a.sortOrder - b.sortOrder | |
| 62 | + : a.itemId.localeCompare(b.itemId) | |
| 63 | + ); | |
| 64 | +} | |
| 65 | + | |
| 66 | +export type DecorationItemPage = { | |
| 67 | + items: DecorationItem[]; | |
| 68 | + /** 검색어까지 적용한 결과 건수 — 요약 문구·페이지네이션 기준. */ | |
| 69 | + totalCount: number; | |
| 70 | + /** | |
| 71 | + * 검색어와 무관하게 현재 유형(개별/셋트) 전체 등록 건수. 등록/수정 팝업의 | |
| 72 | + * "정렬순서 (총 등록 N개)" 힌트와 신규 등록 기본 정렬순서(맨 끝에 추가) 계산에 쓴다 — 검색 | |
| 73 | + * 결과가 아니라 그 유형에 실제로 존재하는 전체 개수여야 하므로 `totalCount`와 분리했다. | |
| 74 | + */ | |
| 75 | + typeTotalCount: number; | |
| 76 | +}; | |
| 77 | + | |
| 78 | +/** 검색·유형·페이징이 적용된 꾸미기 아이템 목록을 조회한다. */ | |
| 79 | +export async function fetchDecorationItems( | |
| 80 | + query: DecorationItemQuery | |
| 81 | +): Promise<DecorationItemPage> { | |
| 82 | + const byType = listAllDecorationItems().filter( | |
| 83 | + (item) => item.itemType === query.itemType | |
| 84 | + ); | |
| 85 | + const matched = sortBySortOrder(filterByKeyword(byType, query)); | |
| 86 | + const offset = (query.page - 1) * query.pageSize; | |
| 87 | + | |
| 88 | + return { | |
| 89 | + items: matched.slice(offset, offset + query.pageSize), | |
| 90 | + totalCount: matched.length, | |
| 91 | + typeTotalCount: byType.length, | |
| 92 | + }; | |
| 93 | +} | |
| 94 | + | |
| 95 | +/** | |
| 96 | + * 아이템ID 중복 확인. 시안에 admins의 [중복확인] 같은 별도 버튼은 없지만, 아이템ID가 유일 | |
| 97 | + * 식별자이자 수정/삭제 대상 키라 등록 Server Action이 저장 직전에 반드시 확인한다 | |
| 98 | + * (`_actions.ts` 참조) — 화면에는 이 확인이 보이지 않고 실패 시 필드 오류로만 나타난다. | |
| 99 | + */ | |
| 100 | +export async function isDecorationItemIdTaken(itemId: string): Promise<boolean> { | |
| 101 | + const normalized = itemId.trim().toLowerCase(); | |
| 102 | + return listAllDecorationItems().some( | |
| 103 | + (item) => item.itemId.toLowerCase() === normalized | |
| 104 | + ); | |
| 105 | +} | |
| 106 | + | |
| 107 | +/* | |
| 108 | + * ─── 쓰기 경로 ──────────────────────────────────────────────────────────────── | |
| 109 | + * 백엔드에 이 도메인의 API가 전혀 없어 세 함수 모두 mock 저장소에 위임한다 | |
| 110 | + * (`lib/data/mock/decoration-item-store.ts`의 주석에 한계를 적어 두었다). | |
| 111 | + */ | |
| 112 | + | |
| 113 | +export async function createDecorationItem( | |
| 114 | + input: CreateDecorationItemInput | |
| 115 | +): Promise<void> { | |
| 116 | + createMockDecorationItem(input); | |
| 117 | +} | |
| 118 | + | |
| 119 | +export async function updateDecorationItem( | |
| 120 | + itemId: string, | |
| 121 | + input: UpdateDecorationItemInput | |
| 122 | +): Promise<void> { | |
| 123 | + updateMockDecorationItem(itemId, input); | |
| 124 | +} | |
| 125 | + | |
| 126 | +export async function deleteDecorationItem(itemId: string): Promise<void> { | |
| 127 | + deleteMockDecorationItem(itemId); | |
| 128 | +} |
+++ lib/domain/decoration-item-form.ts
... | ... | @@ -0,0 +1,167 @@ |
| 1 | +/** | |
| 2 | + * 꾸미기 아이템 등록/수정 입력 규칙 — 순수 검증 로직만 담는다(외부 의존 없음). | |
| 3 | + * | |
| 4 | + * 시안(ADM_ITM_102_p/103_p) 기준 필수 항목: 유형·아이템ID(등록 전용)·아이템명·카테고리·포인트. | |
| 5 | + * 설명은 선택이고, 정렬순서는 시안에 별도 필수(*) 표시가 없다 — 다만 화면이 항상 기본값(현재 | |
| 6 | + * 유형의 총 등록 개수+1, 또는 수정 시 기존 값)을 채워 제출하므로 실질적으로 비어 있는 경우가 | |
| 7 | + * 없고, 그래도 숫자 형식은 여기서 검증한다(Server Action은 UI를 거치지 않고 직접 호출될 수 | |
| 8 | + * 있다 — 화면의 기본값 채움은 편의일 뿐 신뢰 경계가 아니다). | |
| 9 | + * | |
| 10 | + * **이 파일이 검증의 단일 진실원천이다.** Server Action(`_actions.ts`)이 저장 직전에 여기를 | |
| 11 | + * 거친다. | |
| 12 | + * | |
| 13 | + * 아이템ID는 백엔드 코드 체계가 없어(사용자 확정 — 이 도메인 전체가 mock) 시안 예시 | |
| 14 | + * (`ITEM-HAT-002`)를 참고한 느슨한 형식만 강제한다: 영문/숫자 세그먼트를 하이픈으로 구분. | |
| 15 | + * | |
| 16 | + * **등록과 수정의 검증을 분리한 이유**: 수정 팝업에서 아이템ID는 읽기 전용이다(시안 | |
| 17 | + * ADM_ITM_103_p). 수정은 실제로 바뀔 수 있는 항목(유형/아이템명/카테고리/포인트/설명/사용여부/ | |
| 18 | + * 정렬순서)만 검증한다. | |
| 19 | + */ | |
| 20 | + | |
| 21 | +import { | |
| 22 | + DECORATION_ITEM_CATEGORIES, | |
| 23 | + DECORATION_ITEM_TYPE_OPTIONS, | |
| 24 | + type DecorationItemCategory, | |
| 25 | + type DecorationItemType, | |
| 26 | +} from '@/lib/domain/decoration-item'; | |
| 27 | + | |
| 28 | +export const DECORATION_ITEM_ID_HELP_TEXT = | |
| 29 | + '영문·숫자와 하이픈(-)으로 입력해 주세요. 예: ITEM-HAT-002'; | |
| 30 | + | |
| 31 | +const ITEM_ID_MIN_LENGTH = 3; | |
| 32 | +const ITEM_ID_MAX_LENGTH = 50; | |
| 33 | +const ITEM_ID_PATTERN = /^[A-Za-z0-9]+(-[A-Za-z0-9]+)*$/; | |
| 34 | +const NAME_MAX_LENGTH = 100; | |
| 35 | +const DESCRIPTION_MAX_LENGTH = 500; | |
| 36 | + | |
| 37 | +/** 등록·수정 양쪽에서 실제로 바뀔 수 있는 항목. */ | |
| 38 | +export type DecorationItemEditableValues = { | |
| 39 | + itemType: DecorationItemType; | |
| 40 | + name: string; | |
| 41 | + category: DecorationItemCategory; | |
| 42 | + points: number; | |
| 43 | + description: string; | |
| 44 | + isActive: boolean; | |
| 45 | + sortOrder: number; | |
| 46 | +}; | |
| 47 | + | |
| 48 | +/** 등록은 위 항목에 더해 아이템ID를 입력받는다(수정에서는 읽기 전용). */ | |
| 49 | +export type DecorationItemCreateValues = DecorationItemEditableValues & { | |
| 50 | + itemId: string; | |
| 51 | +}; | |
| 52 | + | |
| 53 | +/** 필드별 오류 메시지 — 키는 폼 필드 이름과 일치시켜 화면이 그대로 붙여 쓸 수 있게 한다. */ | |
| 54 | +export type DecorationItemFormErrors = Partial< | |
| 55 | + Record<keyof DecorationItemCreateValues, string> | |
| 56 | +>; | |
| 57 | + | |
| 58 | +export type ValidationResult<T> = | |
| 59 | + | { ok: true; values: T } | |
| 60 | + | { ok: false; errors: DecorationItemFormErrors }; | |
| 61 | + | |
| 62 | +function isDecorationItemType(value: string): value is DecorationItemType { | |
| 63 | + return DECORATION_ITEM_TYPE_OPTIONS.some((option) => option.value === value); | |
| 64 | +} | |
| 65 | + | |
| 66 | +function isDecorationItemCategory( | |
| 67 | + value: string | |
| 68 | +): value is DecorationItemCategory { | |
| 69 | + return (DECORATION_ITEM_CATEGORIES as readonly string[]).includes(value); | |
| 70 | +} | |
| 71 | + | |
| 72 | +/** | |
| 73 | + * 아이템ID 형식 검증 — 문제가 있으면 안내 문구, 없으면 null. 중복 확인은 여기서 하지 않는다 | |
| 74 | + * (Repository/Server Action의 몫 — `decoration-item-repository.ts` 주석 참조). | |
| 75 | + */ | |
| 76 | +export function validateDecorationItemId(itemId: string): string | null { | |
| 77 | + const value = itemId.trim(); | |
| 78 | + | |
| 79 | + if (!value) { | |
| 80 | + return '아이템 ID를 입력해 주세요.'; | |
| 81 | + } | |
| 82 | + if (value.length < ITEM_ID_MIN_LENGTH || value.length > ITEM_ID_MAX_LENGTH) { | |
| 83 | + return `아이템 ID는 ${ITEM_ID_MIN_LENGTH}~${ITEM_ID_MAX_LENGTH}자로 입력해 주세요.`; | |
| 84 | + } | |
| 85 | + if (!ITEM_ID_PATTERN.test(value)) { | |
| 86 | + return DECORATION_ITEM_ID_HELP_TEXT; | |
| 87 | + } | |
| 88 | + return null; | |
| 89 | +} | |
| 90 | + | |
| 91 | +/** | |
| 92 | + * 등록·수정 공통 항목 검증. 오류는 넘겨받은 객체에 채워 넣고, 정규화된 값을 돌려준다. | |
| 93 | + * | |
| 94 | + * `points`/`sortOrder`는 `_actions.ts`가 FormData 문자열을 숫자로 변환해 넘긴다 — 빈 문자열을 | |
| 95 | + * `Number.isInteger`가 걸러낼 수 있도록 그 변환은 `Number('')`가 아니라 명시적으로 실패시키는 | |
| 96 | + * 헬퍼(`_actions.ts`의 `parseFormNumber`)를 거친 값이어야 한다(빈 문자열을 그냥 `Number()`에 | |
| 97 | + * 넘기면 0으로 조용히 통과해 필수 값 누락을 놓친다). | |
| 98 | + */ | |
| 99 | +function validateEditableValues( | |
| 100 | + values: DecorationItemEditableValues, | |
| 101 | + errors: DecorationItemFormErrors | |
| 102 | +): DecorationItemEditableValues { | |
| 103 | + const name = values.name.trim(); | |
| 104 | + const description = values.description.trim(); | |
| 105 | + | |
| 106 | + if (!isDecorationItemType(values.itemType)) { | |
| 107 | + errors.itemType = '유형을 선택해 주세요.'; | |
| 108 | + } | |
| 109 | + | |
| 110 | + if (!name) { | |
| 111 | + errors.name = '아이템명을 입력해 주세요.'; | |
| 112 | + } else if (name.length > NAME_MAX_LENGTH) { | |
| 113 | + errors.name = `아이템명은 ${NAME_MAX_LENGTH}자 이내로 입력해 주세요.`; | |
| 114 | + } | |
| 115 | + | |
| 116 | + if (!isDecorationItemCategory(values.category)) { | |
| 117 | + errors.category = '카테고리를 선택해 주세요.'; | |
| 118 | + } | |
| 119 | + | |
| 120 | + if (!Number.isInteger(values.points) || values.points < 0) { | |
| 121 | + errors.points = '포인트는 0 이상의 숫자로 입력해 주세요.'; | |
| 122 | + } | |
| 123 | + | |
| 124 | + if (description.length > DESCRIPTION_MAX_LENGTH) { | |
| 125 | + errors.description = `설명은 ${DESCRIPTION_MAX_LENGTH}자 이내로 입력해 주세요.`; | |
| 126 | + } | |
| 127 | + | |
| 128 | + if (!Number.isInteger(values.sortOrder) || values.sortOrder < 1) { | |
| 129 | + errors.sortOrder = '정렬순서는 1 이상의 숫자로 입력해 주세요.'; | |
| 130 | + } | |
| 131 | + | |
| 132 | + return { ...values, name, description }; | |
| 133 | +} | |
| 134 | + | |
| 135 | +/** 시안 ADM_ITM_102_p — 등록 검증(아이템ID 포함). */ | |
| 136 | +export function validateDecorationItemCreate( | |
| 137 | + values: DecorationItemCreateValues | |
| 138 | +): ValidationResult<DecorationItemCreateValues> { | |
| 139 | + const errors: DecorationItemFormErrors = {}; | |
| 140 | + | |
| 141 | + const itemIdError = validateDecorationItemId(values.itemId); | |
| 142 | + if (itemIdError) { | |
| 143 | + errors.itemId = itemIdError; | |
| 144 | + } | |
| 145 | + | |
| 146 | + const editable = validateEditableValues(values, errors); | |
| 147 | + | |
| 148 | + if (Object.keys(errors).length > 0) { | |
| 149 | + return { ok: false, errors }; | |
| 150 | + } | |
| 151 | + | |
| 152 | + return { ok: true, values: { ...editable, itemId: values.itemId.trim() } }; | |
| 153 | +} | |
| 154 | + | |
| 155 | +/** 시안 ADM_ITM_103_p — 수정 검증. 아이템ID는 읽기 전용이라 검증 대상이 아니다(파일 상단 주석). */ | |
| 156 | +export function validateDecorationItemUpdate( | |
| 157 | + values: DecorationItemEditableValues | |
| 158 | +): ValidationResult<DecorationItemEditableValues> { | |
| 159 | + const errors: DecorationItemFormErrors = {}; | |
| 160 | + const editable = validateEditableValues(values, errors); | |
| 161 | + | |
| 162 | + if (Object.keys(errors).length > 0) { | |
| 163 | + return { ok: false, errors }; | |
| 164 | + } | |
| 165 | + | |
| 166 | + return { ok: true, values: editable }; | |
| 167 | +} |
+++ lib/domain/decoration-item-query.ts
... | ... | @@ -0,0 +1,154 @@ |
| 1 | +/** | |
| 2 | + * 꾸미기 아이템 목록의 유형탭·검색·페이징 조건 — 순수 규칙(허용 값·기본값·URL 직렬화)만 | |
| 3 | + * 담는다. next/react 의존이 없다(`URLSearchParams`는 서버·브라우저 양쪽에서 쓸 수 있는 표준 | |
| 4 | + * Web API). | |
| 5 | + * | |
| 6 | + * `app/(protected)/(basic)/decoration-items/page.tsx`가 `searchParams`를 | |
| 7 | + * `parseDecorationItemQuery`로 정규화하는 지점이자 단일 진실원천이며, 유형 탭·검색바·툴바· | |
| 8 | + * 페이지네이션은 모두 `buildDecorationItemHref`로 같은 규칙에 따라 URL을 만들어 파라미터 | |
| 9 | + * 이름·기본값이 여러 파일에 흩어져 드리프트하는 것을 막는다(admin-member-query.ts와 동일한 | |
| 10 | + * 설계). | |
| 11 | + * | |
| 12 | + * **유형 탭(개별/셋트)도 이 쿼리의 일부다** — 시안이 "셋트아이템도 동일한 항목으로 처리하며 | |
| 13 | + * 별도 화면이 없다"고 명시해, 탭 전환은 라우트 이동이 아니라 이 화면 안에서 `itemType` 값만 | |
| 14 | + * 바뀌는 것으로 구현한다. | |
| 15 | + * | |
| 16 | + * 시안(ADM_ITM_101)에는 admins의 "정렬" select에 대응하는 항목이 없다 — 목록은 항상 정렬순서 | |
| 17 | + * 오름차순 고정이라 이 파일에 정렬 옵션을 두지 않는다(Repository가 고정 규칙으로 정렬한다). | |
| 18 | + */ | |
| 19 | + | |
| 20 | +import { | |
| 21 | + DECORATION_ITEM_TYPE_OPTIONS, | |
| 22 | + DEFAULT_DECORATION_ITEM_TYPE, | |
| 23 | + type DecorationItemType, | |
| 24 | +} from '@/lib/domain/decoration-item'; | |
| 25 | + | |
| 26 | +/** 라우트 경로 — 이 파일 안에서만 하드코딩하고 나머지는 이 상수를 참조한다. */ | |
| 27 | +export const DECORATION_ITEMS_PATH = '/decoration-items'; | |
| 28 | + | |
| 29 | +/** 검색 대상 — 시안(ADM_ITM_101 ①) "아이템명 / 아이템ID" 그대로. */ | |
| 30 | +export type DecorationItemSearchField = 'name' | 'itemId'; | |
| 31 | + | |
| 32 | +export const DECORATION_ITEM_SEARCH_FIELD_OPTIONS: ReadonlyArray<{ | |
| 33 | + value: DecorationItemSearchField; | |
| 34 | + label: string; | |
| 35 | +}> = [ | |
| 36 | + { value: 'name', label: '아이템명' }, | |
| 37 | + { value: 'itemId', label: '아이템ID' }, | |
| 38 | +]; | |
| 39 | + | |
| 40 | +export const DECORATION_ITEM_PAGE_SIZE_OPTIONS = [10, 30, 50] as const; | |
| 41 | +export type DecorationItemPageSize = | |
| 42 | + (typeof DECORATION_ITEM_PAGE_SIZE_OPTIONS)[number]; | |
| 43 | + | |
| 44 | +export const DEFAULT_DECORATION_ITEM_SEARCH_FIELD: DecorationItemSearchField = | |
| 45 | + 'name'; | |
| 46 | +export const DEFAULT_DECORATION_ITEM_PAGE_SIZE: DecorationItemPageSize = 10; | |
| 47 | +const DEFAULT_PAGE = 1; | |
| 48 | +const MAX_KEYWORD_LENGTH = 100; | |
| 49 | + | |
| 50 | +export type DecorationItemQuery = { | |
| 51 | + itemType: DecorationItemType; | |
| 52 | + searchField: DecorationItemSearchField; | |
| 53 | + keyword: string; | |
| 54 | + page: number; | |
| 55 | + pageSize: DecorationItemPageSize; | |
| 56 | +}; | |
| 57 | + | |
| 58 | +/** Next.js `page.tsx`의 `searchParams`가 리졸브하는 값 형태를 그대로 옮긴 구조 타입 — | |
| 59 | + * next 패키지를 import하지 않고도 같은 shape을 표현해 domain 계층의 무의존 규칙을 지킨다. */ | |
| 60 | +type RawSearchParams = Record<string, string | string[] | undefined>; | |
| 61 | + | |
| 62 | +function readParam(params: RawSearchParams, key: string): string | undefined { | |
| 63 | + const value = params[key]; | |
| 64 | + return Array.isArray(value) ? value[0] : value; | |
| 65 | +} | |
| 66 | + | |
| 67 | +function isDecorationItemType( | |
| 68 | + value: string | undefined | |
| 69 | +): value is DecorationItemType { | |
| 70 | + return ( | |
| 71 | + value !== undefined && | |
| 72 | + DECORATION_ITEM_TYPE_OPTIONS.some((option) => option.value === value) | |
| 73 | + ); | |
| 74 | +} | |
| 75 | + | |
| 76 | +function isDecorationItemSearchField( | |
| 77 | + value: string | undefined | |
| 78 | +): value is DecorationItemSearchField { | |
| 79 | + return ( | |
| 80 | + value !== undefined && | |
| 81 | + DECORATION_ITEM_SEARCH_FIELD_OPTIONS.some((option) => option.value === value) | |
| 82 | + ); | |
| 83 | +} | |
| 84 | + | |
| 85 | +function isDecorationItemPageSize( | |
| 86 | + value: number | |
| 87 | +): value is DecorationItemPageSize { | |
| 88 | + return (DECORATION_ITEM_PAGE_SIZE_OPTIONS as readonly number[]).includes( | |
| 89 | + value | |
| 90 | + ); | |
| 91 | +} | |
| 92 | + | |
| 93 | +/** | |
| 94 | + * URL의 searchParams를 검증된 `DecorationItemQuery`로 정규화한다. 값이 없거나 허용 목록을 | |
| 95 | + * 벗어나면 기본값으로 fallback한다 — searchParams는 사용자가 임의로 조작 가능한 값이라 | |
| 96 | + * 신뢰하지 않는다. | |
| 97 | + */ | |
| 98 | +export function parseDecorationItemQuery( | |
| 99 | + searchParams: RawSearchParams | |
| 100 | +): DecorationItemQuery { | |
| 101 | + const itemTypeRaw = readParam(searchParams, 'itemType'); | |
| 102 | + const searchFieldRaw = readParam(searchParams, 'searchField'); | |
| 103 | + const keywordRaw = readParam(searchParams, 'keyword'); | |
| 104 | + const pageRaw = Number(readParam(searchParams, 'page')); | |
| 105 | + const pageSizeRaw = Number(readParam(searchParams, 'pageSize')); | |
| 106 | + | |
| 107 | + return { | |
| 108 | + itemType: isDecorationItemType(itemTypeRaw) | |
| 109 | + ? itemTypeRaw | |
| 110 | + : DEFAULT_DECORATION_ITEM_TYPE, | |
| 111 | + searchField: isDecorationItemSearchField(searchFieldRaw) | |
| 112 | + ? searchFieldRaw | |
| 113 | + : DEFAULT_DECORATION_ITEM_SEARCH_FIELD, | |
| 114 | + keyword: (keywordRaw ?? '').trim().slice(0, MAX_KEYWORD_LENGTH), | |
| 115 | + page: Number.isInteger(pageRaw) && pageRaw > 0 ? pageRaw : DEFAULT_PAGE, | |
| 116 | + pageSize: isDecorationItemPageSize(pageSizeRaw) | |
| 117 | + ? pageSizeRaw | |
| 118 | + : DEFAULT_DECORATION_ITEM_PAGE_SIZE, | |
| 119 | + }; | |
| 120 | +} | |
| 121 | + | |
| 122 | +/** | |
| 123 | + * `DecorationItemQuery`(+ 부분 override)를 `/decoration-items` 링크로 직렬화한다. | |
| 124 | + * `parseDecorationItemQuery`의 역연산이며, 기본값과 같은 필드는 URL에서 생략해 링크를 짧게 | |
| 125 | + * 유지한다. | |
| 126 | + */ | |
| 127 | +export function buildDecorationItemHref( | |
| 128 | + query: DecorationItemQuery, | |
| 129 | + overrides: Partial<DecorationItemQuery> = {} | |
| 130 | +): string { | |
| 131 | + const merged = { ...query, ...overrides }; | |
| 132 | + const params = new URLSearchParams(); | |
| 133 | + | |
| 134 | + if (merged.itemType !== DEFAULT_DECORATION_ITEM_TYPE) { | |
| 135 | + params.set('itemType', merged.itemType); | |
| 136 | + } | |
| 137 | + if (merged.searchField !== DEFAULT_DECORATION_ITEM_SEARCH_FIELD) { | |
| 138 | + params.set('searchField', merged.searchField); | |
| 139 | + } | |
| 140 | + if (merged.keyword) { | |
| 141 | + params.set('keyword', merged.keyword); | |
| 142 | + } | |
| 143 | + if (merged.pageSize !== DEFAULT_DECORATION_ITEM_PAGE_SIZE) { | |
| 144 | + params.set('pageSize', String(merged.pageSize)); | |
| 145 | + } | |
| 146 | + if (merged.page !== DEFAULT_PAGE) { | |
| 147 | + params.set('page', String(merged.page)); | |
| 148 | + } | |
| 149 | + | |
| 150 | + const queryString = params.toString(); | |
| 151 | + return queryString | |
| 152 | + ? `${DECORATION_ITEMS_PATH}?${queryString}` | |
| 153 | + : DECORATION_ITEMS_PATH; | |
| 154 | +} |
+++ lib/domain/decoration-item.ts
... | ... | @@ -0,0 +1,92 @@ |
| 1 | +/** | |
| 2 | + * 꾸미기 아이템 도메인 타입 — 순수 데이터 표현, 외부 의존 없음. | |
| 3 | + * | |
| 4 | + * **백엔드(edupay-backend)에 이 도메인의 API가 전혀 없다** — 조회조차 없다(사용자 확정 사항). | |
| 5 | + * 그래서 이 타입의 실제 값은 전부 `lib/data/mock/decoration-item-store.ts`가 만들어 내고, | |
| 6 | + * "백엔드 응답 → 도메인 매핑" 절차 자체가 존재하지 않는다 — admin-member.ts/student-member.ts와 | |
| 7 | + * 달리 `null`이 "백엔드가 아직 주지 않는 항목"을 뜻하지 않는다(이 타입에서 `description`의 | |
| 8 | + * `null`은 순수하게 "값 없음"이다). | |
| 9 | + */ | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * 유형 — 개별아이템 / 셋트아이템. 시안(ADM_ITM_101, ADM_ITM_102_p) 두 곳 모두 "셋트아이템도 | |
| 13 | + * 동일한 항목으로 처리하며 별도 화면이 없다"고 명시한다 — 목록의 유형 탭과 등록/수정 폼의 값이 | |
| 14 | + * 모두 이 타입 하나를 공유한다. | |
| 15 | + */ | |
| 16 | +export type DecorationItemType = 'individual' | 'set'; | |
| 17 | + | |
| 18 | +export const DECORATION_ITEM_TYPE_OPTIONS: ReadonlyArray<{ | |
| 19 | + value: DecorationItemType; | |
| 20 | + label: string; | |
| 21 | +}> = [ | |
| 22 | + { value: 'individual', label: '개별아이템' }, | |
| 23 | + { value: 'set', label: '셋트아이템' }, | |
| 24 | +]; | |
| 25 | + | |
| 26 | +export const DEFAULT_DECORATION_ITEM_TYPE: DecorationItemType = 'individual'; | |
| 27 | + | |
| 28 | +/** | |
| 29 | + * 카테고리 — **백엔드 코드테이블이 없어 시안 예시값을 그대로 mock 상수로 둔다**(사용자 확정 | |
| 30 | + * 사항. 시안 설명: "추후 확장성을 위해 필요, As is는 단일 카테고리로 운영"). 백엔드에 코드테이블 | |
| 31 | + * API가 생기면 이 상수를 지우고 그 응답으로 대체한다. | |
| 32 | + */ | |
| 33 | +export const DECORATION_ITEM_CATEGORIES = ['계절', '축하', '시즌'] as const; | |
| 34 | +export type DecorationItemCategory = (typeof DECORATION_ITEM_CATEGORIES)[number]; | |
| 35 | + | |
| 36 | +export type DecorationItem = { | |
| 37 | + /** | |
| 38 | + * 아이템ID — 등록 시 사용자가 직접 입력하는 유일 식별자(예: `ITEM-HAT-002`). 백엔드가 없어 | |
| 39 | + * 별도 내부 id를 두지 않는다 — 목록 행의 key이자 수정/삭제 Server Action의 입력값이다. | |
| 40 | + * 등록 후에는 변경할 수 없다(시안 ADM_ITM_103_p). | |
| 41 | + */ | |
| 42 | + itemId: string; | |
| 43 | + itemType: DecorationItemType; | |
| 44 | + name: string; | |
| 45 | + category: DecorationItemCategory; | |
| 46 | + /** 오픈 가능한 포인트. */ | |
| 47 | + points: number; | |
| 48 | + description: string | null; | |
| 49 | + /** 사용여부 — 표에서는 O/X로 표기한다(`formatDecorationItemActiveLabel`). */ | |
| 50 | + isActive: boolean; | |
| 51 | + /** | |
| 52 | + * 정렬순서 — 목록 기본 정렬 기준(오름차순, 시안 ADM_ITM_101). **유형(개별/셋트)마다 독립된 | |
| 53 | + * 순번 공간이다** — 두 탭이 같은 화면을 재사용할 뿐 사실상 별개 목록이라, 한쪽의 정렬순서가 | |
| 54 | + * 다른 쪽 순번에 영향을 주지 않는다(mock 생성기·Repository가 이 규칙을 지킨다). | |
| 55 | + */ | |
| 56 | + sortOrder: number; | |
| 57 | + /** ISO 8601 문자열 — 수정일시. */ | |
| 58 | + updatedAt: string; | |
| 59 | +}; | |
| 60 | + | |
| 61 | +/** 사용여부 → 화면 표기(시안: O/X). */ | |
| 62 | +export function formatDecorationItemActiveLabel(isActive: boolean): string { | |
| 63 | + return isActive ? 'O' : 'X'; | |
| 64 | +} | |
| 65 | + | |
| 66 | +/** 포인트 → 화면 표기(시안: "100 P" 형태). */ | |
| 67 | +export function formatDecorationItemPoints(points: number): string { | |
| 68 | + return `${points.toLocaleString('ko-KR')} P`; | |
| 69 | +} | |
| 70 | + | |
| 71 | +/** | |
| 72 | + * ISO 수정일시 → "YYYY-MM-DD HH:mm". mock이 생성하는 값이 항상 `Date#toISOString()` 형식 | |
| 73 | + * (`YYYY-MM-DDTHH:mm:ss.sssZ`)이라 별도 날짜 라이브러리 없이 문자열 절단만으로 충분하다(신규 | |
| 74 | + * 의존성 추가 금지 — CLAUDE.md §4.2). | |
| 75 | + */ | |
| 76 | +export function formatDecorationItemUpdatedAt(updatedAt: string): string { | |
| 77 | + return updatedAt.slice(0, 16).replace('T', ' '); | |
| 78 | +} | |
| 79 | + | |
| 80 | +/** | |
| 81 | + * 썸네일 자리의 플레이스홀더 텍스트 — **의도적으로 이미지가 아니다.** 시안(ADM_ITM_102_p)은 | |
| 82 | + * 썸네일 이미지 업로드 필드를 요구하지만, 사용자가 "썸네일을 따로 등록하지 않고 추후 백엔드가 | |
| 83 | + * 자동생성해주는 형식"이라고 확정해 등록/수정 폼에서 업로드 필드를 제외했다(시안과 다르게 가는 | |
| 84 | + * 지점). 목록의 썸네일 컬럼 자체는 유지하되(백엔드가 자동생성한 이미지를 표시할 자리), 지금은 | |
| 85 | + * 생성해 줄 백엔드가 없어 아이템ID의 중간 세그먼트(예: `ITEM-HAT-002` → `HAT`)를 뽑아 대신 | |
| 86 | + * 보여준다 — 실제 썸네일 API가 생기면 표 컴포넌트의 이 자리만 `<img>`로 바꾸면 된다. | |
| 87 | + */ | |
| 88 | +export function getDecorationItemThumbnailLabel(itemId: string): string { | |
| 89 | + const segments = itemId.split('-'); | |
| 90 | + const middle = segments.length >= 2 ? segments[1] : itemId; | |
| 91 | + return middle.slice(0, 3).toUpperCase(); | |
| 92 | +} |
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?