feat: 꾸미기 아이템 관리를 백엔드 API로 전환
mock 저장소를 제거하고 /api/v1/mngr/item CRUD와 파일 업로드 API에 연결한다. 기존 구현은 "이 도메인에는 백엔드 API가 전혀 없다"는 전제로 만들어져 있었으나, 백엔드에 등록·수정·삭제까지 갖춘 API가 존재해 스키마에 맞춰 도메인 타입부터 다시 맞췄다. - 아이템ID는 사용자 입력이 아니라 백엔드 자동 채번(itemSn)이다. 시안도 "등록 후 자동발급 (Read Only)"이라 등록 폼의 입력 필드를 없애고 목록·수정 팝업에는 itemSn을 그대로 노출한다. - 유형은 개별=N, 셋트=S로 매핑한다. 목록 조회는 searchItemType이 필수라 값이 없으면 아무것도 조회되지 않는다. - 카테고리는 공통코드가 정비되기 전까지 프론트 임시 코드로 개발한다(사용자 지시). 표기는 백엔드가 조인해 준 이름을 우선 쓰고 없으면 임시 목록에서 찾는다. - 이미지는 파일 하나만 받아 atchFileId에만 저장한다(썸네일은 추후 백엔드 자동생성 예정). 브라우저가 백엔드에 직접 올리지 않도록 Server Action이 File을 받아 중계한다. 목록 썸네일은 인증이 필요 없는 이미지 GET 주소를 서버에서 만들어 내려준다. - 총건수 보정은 학생 목록과 동일하다(백엔드 PaginationUtil에 count 쿼리가 없다). lib/http/backend-fetch.ts에 PUT/DELETE와 form·multipart 본문을 추가했다. 아이템 쓰기 API는 @RequestBody가 아니라 @ParameterObject로 받으므로 JSON을 보내면 전 필드가 null인 채 저장된다.
@f43332c5d615113700dc2ae2eda1966ed353211a
--- app/(protected)/(basic)/decoration-items/_actions.ts
+++ app/(protected)/(basic)/decoration-items/_actions.ts
... | ... | @@ -5,13 +5,10 @@ |
| 5 | 5 |
import {
|
| 6 | 6 |
createDecorationItem, |
| 7 | 7 |
deleteDecorationItem, |
| 8 |
- isDecorationItemIdTaken, |
|
| 9 | 8 |
updateDecorationItem, |
| 9 |
+ uploadDecorationItemImage, |
|
| 10 | 10 |
} from '@/lib/data/repositories/decoration-item-repository'; |
| 11 |
-import type {
|
|
| 12 |
- DecorationItemCategory, |
|
| 13 |
- DecorationItemType, |
|
| 14 |
-} from '@/lib/domain/decoration-item'; |
|
| 11 |
+import type { DecorationItemType } from '@/lib/domain/decoration-item';
|
|
| 15 | 12 |
import {
|
| 16 | 13 |
validateDecorationItemCreate, |
| 17 | 14 |
validateDecorationItemUpdate, |
... | ... | @@ -29,19 +26,19 @@ |
| 29 | 26 |
* 검증은 화면이 아니라 여기서 확정한다(`lib/domain/decoration-item-form.ts`의 규칙을 호출) — |
| 30 | 27 |
* 화면의 required 속성·기본값 채움은 편의일 뿐 신뢰 경계가 아니다. |
| 31 | 28 |
* |
| 32 |
- * 실제 저장은 Repository에 맡긴다. 백엔드에 이 도메인의 API가 전혀 없어(조회조차 없음, 사용자 |
|
| 33 |
- * 확정 사항) Repository가 mock 저장소로 위임하고 있지만, **이 파일은 그 사실을 알지 못한다** — |
|
| 34 |
- * 백엔드 API가 생겨도 이 파일은 바뀌지 않는다. |
|
| 29 |
+ * **이미지 업로드도 이 계층을 지난다** — 브라우저는 백엔드를 직접 호출하지 않으므로(토큰이 |
|
| 30 |
+ * httpOnly 세션 안에만 있다) 폼이 실어 보낸 File을 여기서 받아 Repository를 통해 백엔드에 |
|
| 31 |
+ * 중계하고, 돌려받은 파일 ID를 그대로 아이템 저장에 쓴다. |
|
| 35 | 32 |
* |
| 36 |
- * `DecorationItemFormState` 타입과 그 초깃값(`INITIAL_DECORATION_ITEM_FORM_STATE`)은 이 |
|
| 37 |
- * 파일이 아니라 `lib/domain/decoration-item-form.ts`에 있다 — Next.js가 `'use server'` |
|
| 38 |
- * 파일에서 함수가 아닌 값(일반 객체 상수)을 export하는 것을 런타임에 거부하기 때문이다 |
|
| 39 |
- * (`decoration-item-form.ts`의 해당 타입 주석 참조). 타입만 이 파일에서 다시 쓰는 것은 |
|
| 40 |
- * 문제 없다 — 타입은 컴파일 시 지워져 런타임 export로 남지 않는다. |
|
| 33 |
+ * `DecorationItemFormState` 타입과 그 초깃값은 이 파일이 아니라 domain 계층에 있다 — Next.js가 |
|
| 34 |
+ * `'use server'` 파일에서 함수가 아닌 값을 export하는 것을 런타임에 거부하기 때문이다 |
|
| 35 |
+ * (`decoration-item-form.ts`의 해당 타입 주석 참조). |
|
| 41 | 36 |
*/ |
| 42 | 37 |
|
| 43 | 38 |
const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.'; |
| 44 |
-const DUPLICATE_ITEM_ID_MESSAGE = '이미 사용 중인 아이템 ID입니다.'; |
|
| 39 |
+const SAVE_FAILED_MESSAGE = '저장하지 못했습니다. 잠시 후 다시 시도해 주세요.'; |
|
| 40 |
+const DELETE_FAILED_MESSAGE = '삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.'; |
|
| 41 |
+const IMAGE_UPLOAD_FAILED_MESSAGE = '이미지를 업로드하지 못했습니다.'; |
|
| 45 | 42 |
|
| 46 | 43 |
function readString(formData: FormData, key: string): string {
|
| 47 | 44 |
const value = formData.get(key); |
... | ... | @@ -58,48 +55,90 @@ |
| 58 | 55 |
return trimmed === '' ? NaN : Number(trimmed); |
| 59 | 56 |
} |
| 60 | 57 |
|
| 58 |
+function parseItemSn(formData: FormData): number | null {
|
|
| 59 |
+ const value = parseFormNumber(formData, 'itemSn'); |
|
| 60 |
+ return Number.isInteger(value) && value > 0 ? value : null; |
|
| 61 |
+} |
|
| 62 |
+ |
|
| 63 |
+/** |
|
| 64 |
+ * 새로 선택한 파일과 폼이 유지하고 있던 기존 파일 ID를 함께 읽는다. 등록 화면에는 기존 ID가 |
|
| 65 |
+ * 없으므로 파일을 고르지 않으면 둘 다 비고, 그 상태는 도메인 검증(`imageFileId` 필수)이 잡는다. |
|
| 66 |
+ */ |
|
| 67 |
+function readImageInput(formData: FormData): {
|
|
| 68 |
+ newFile: File | null; |
|
| 69 |
+ currentImageFileId: string; |
|
| 70 |
+} {
|
|
| 71 |
+ const file = formData.get('imageFile');
|
|
| 72 |
+ return {
|
|
| 73 |
+ newFile: file instanceof File && file.size > 0 ? file : null, |
|
| 74 |
+ currentImageFileId: readString(formData, 'imageFileId').trim(), |
|
| 75 |
+ }; |
|
| 76 |
+} |
|
| 77 |
+ |
|
| 78 |
+/** |
|
| 79 |
+ * 업로드 전 검증 단계에서만 쓰는 자리표시자 — "새 파일이 선택됐으므로 이미지 필수 조건은 |
|
| 80 |
+ * 충족될 예정"이라는 뜻이다. 실제 저장 값으로는 절대 쓰이지 않는다(업로드 성공 후 진짜 파일 |
|
| 81 |
+ * ID로 교체된다). |
|
| 82 |
+ * |
|
| 83 |
+ * 검증을 업로드보다 먼저 하기 위해 필요하다 — 순서를 반대로 하면 다른 항목이 잘못됐을 때 |
|
| 84 |
+ * 파일만 백엔드에 올라간 채 저장은 실패해 고아 파일이 쌓인다(파일 삭제 API를 부를 방법도 없다). |
|
| 85 |
+ */ |
|
| 86 |
+const PENDING_UPLOAD_PLACEHOLDER = 'pending-upload'; |
|
| 87 |
+ |
|
| 61 | 88 |
/** |
| 62 | 89 |
* 등록·수정이 공유하는 입력 항목을 읽는다. 유형·카테고리는 select/radio 값을 그대로 읽고, |
| 63 |
- * 허용 목록을 벗어난 값(위조된 요청 포함)은 뒤이은 `validateEditableValues`가 걸러낸다 — 여기서 |
|
| 64 |
- * 하는 캐스팅은 타입을 맞추는 것일 뿐 신뢰를 부여하지 않는다. |
|
| 90 |
+ * 허용 목록을 벗어난 값(위조된 요청 포함)은 뒤이은 검증이 걸러낸다 — 여기서 하는 캐스팅은 타입을 |
|
| 91 |
+ * 맞추는 것일 뿐 신뢰를 부여하지 않는다. |
|
| 65 | 92 |
*/ |
| 66 |
-function readEditableValues(formData: FormData): DecorationItemEditableValues {
|
|
| 93 |
+function readEditableValues( |
|
| 94 |
+ formData: FormData, |
|
| 95 |
+ imageFileId: string |
|
| 96 |
+): DecorationItemEditableValues {
|
|
| 67 | 97 |
return {
|
| 68 | 98 |
itemType: readString(formData, 'itemType') as DecorationItemType, |
| 69 | 99 |
name: readString(formData, 'name'), |
| 70 |
- category: readString(formData, 'category') as DecorationItemCategory, |
|
| 100 |
+ categoryCode: readString(formData, 'categoryCode'), |
|
| 71 | 101 |
points: parseFormNumber(formData, 'points'), |
| 72 | 102 |
description: readString(formData, 'description'), |
| 73 | 103 |
isActive: readString(formData, 'isActive') === 'true', |
| 74 | 104 |
sortOrder: parseFormNumber(formData, 'sortOrder'), |
| 105 |
+ imageFileId, |
|
| 75 | 106 |
}; |
| 76 | 107 |
} |
| 77 | 108 |
|
| 78 |
-/** 시안 ADM_ITM_102_p — 꾸미기 아이템 등록. */ |
|
| 109 |
+/** 시안 ADM_ITM_102_p — 꾸미기 아이템 등록. 아이템ID(itemSn)는 백엔드가 자동 채번한다. */ |
|
| 79 | 110 |
export async function createDecorationItemAction( |
| 80 | 111 |
_prevState: DecorationItemFormState, |
| 81 | 112 |
formData: FormData |
| 82 | 113 |
): Promise<DecorationItemFormState> {
|
| 83 | 114 |
await verifySession(); |
| 84 | 115 |
|
| 85 |
- const validation = validateDecorationItemCreate({
|
|
| 86 |
- ...readEditableValues(formData), |
|
| 87 |
- itemId: readString(formData, 'itemId'), |
|
| 88 |
- }); |
|
| 116 |
+ const { newFile, currentImageFileId } = readImageInput(formData);
|
|
| 89 | 117 |
|
| 118 |
+ const validation = validateDecorationItemCreate( |
|
| 119 |
+ readEditableValues( |
|
| 120 |
+ formData, |
|
| 121 |
+ newFile ? PENDING_UPLOAD_PLACEHOLDER : currentImageFileId |
|
| 122 |
+ ) |
|
| 123 |
+ ); |
|
| 90 | 124 |
if (!validation.ok) {
|
| 91 | 125 |
return { status: 'error', errors: validation.errors };
|
| 92 | 126 |
} |
| 93 | 127 |
|
| 94 |
- const { itemId, ...editable } = validation.values;
|
|
| 95 |
- |
|
| 96 |
- // 시안에는 admins의 [중복확인] 같은 버튼이 없지만, 아이템ID가 유일 식별자이자 수정/삭제 |
|
| 97 |
- // 대상 키라 저장 직전에 반드시 다시 확인한다(decoration-item-repository.ts 주석 참조). |
|
| 98 |
- if (await isDecorationItemIdTaken(itemId)) {
|
|
| 99 |
- return { status: 'error', errors: { itemId: DUPLICATE_ITEM_ID_MESSAGE } };
|
|
| 128 |
+ let imageFileId = currentImageFileId; |
|
| 129 |
+ if (newFile) {
|
|
| 130 |
+ try {
|
|
| 131 |
+ imageFileId = await uploadDecorationItemImage(newFile); |
|
| 132 |
+ } catch {
|
|
| 133 |
+ return { status: 'error', message: IMAGE_UPLOAD_FAILED_MESSAGE };
|
|
| 134 |
+ } |
|
| 100 | 135 |
} |
| 101 | 136 |
|
| 102 |
- await createDecorationItem({ itemId, ...editable });
|
|
| 137 |
+ try {
|
|
| 138 |
+ await createDecorationItem({ ...validation.values, imageFileId });
|
|
| 139 |
+ } catch {
|
|
| 140 |
+ return { status: 'error', message: SAVE_FAILED_MESSAGE };
|
|
| 141 |
+ } |
|
| 103 | 142 |
|
| 104 | 143 |
revalidatePath(DECORATION_ITEMS_PATH); |
| 105 | 144 |
return { status: 'success' };
|
... | ... | @@ -112,17 +151,37 @@ |
| 112 | 151 |
): Promise<DecorationItemFormState> {
|
| 113 | 152 |
await verifySession(); |
| 114 | 153 |
|
| 115 |
- const itemId = readString(formData, 'itemId'); |
|
| 116 |
- if (!itemId) {
|
|
| 154 |
+ const itemSn = parseItemSn(formData); |
|
| 155 |
+ if (itemSn === null) {
|
|
| 117 | 156 |
return { status: 'error', message: INVALID_REQUEST_MESSAGE };
|
| 118 | 157 |
} |
| 119 | 158 |
|
| 120 |
- const validation = validateDecorationItemUpdate(readEditableValues(formData)); |
|
| 159 |
+ const { newFile, currentImageFileId } = readImageInput(formData);
|
|
| 160 |
+ |
|
| 161 |
+ const validation = validateDecorationItemUpdate( |
|
| 162 |
+ readEditableValues( |
|
| 163 |
+ formData, |
|
| 164 |
+ newFile ? PENDING_UPLOAD_PLACEHOLDER : currentImageFileId |
|
| 165 |
+ ) |
|
| 166 |
+ ); |
|
| 121 | 167 |
if (!validation.ok) {
|
| 122 | 168 |
return { status: 'error', errors: validation.errors };
|
| 123 | 169 |
} |
| 124 | 170 |
|
| 125 |
- await updateDecorationItem(itemId, validation.values); |
|
| 171 |
+ let imageFileId = currentImageFileId; |
|
| 172 |
+ if (newFile) {
|
|
| 173 |
+ try {
|
|
| 174 |
+ imageFileId = await uploadDecorationItemImage(newFile); |
|
| 175 |
+ } catch {
|
|
| 176 |
+ return { status: 'error', message: IMAGE_UPLOAD_FAILED_MESSAGE };
|
|
| 177 |
+ } |
|
| 178 |
+ } |
|
| 179 |
+ |
|
| 180 |
+ try {
|
|
| 181 |
+ await updateDecorationItem(itemSn, { ...validation.values, imageFileId });
|
|
| 182 |
+ } catch {
|
|
| 183 |
+ return { status: 'error', message: SAVE_FAILED_MESSAGE };
|
|
| 184 |
+ } |
|
| 126 | 185 |
|
| 127 | 186 |
revalidatePath(DECORATION_ITEMS_PATH); |
| 128 | 187 |
return { status: 'success' };
|
... | ... | @@ -133,19 +192,22 @@ |
| 133 | 192 |
* 여기서는 인증과 입력만 확인한다. |
| 134 | 193 |
* |
| 135 | 194 |
* 폼 제출이 아니라 얼럿의 [삭제] 클릭에 반응하는 단발 호출이라 `useActionState`의 |
| 136 |
- * (prevState, formData) 규약 대신 id를 직접 받는다(admins의 `deleteAdminMemberAction`과 동일한 |
|
| 137 |
- * 이유). |
|
| 195 |
+ * (prevState, formData) 규약 대신 식별자를 직접 받는다. |
|
| 138 | 196 |
*/ |
| 139 | 197 |
export async function deleteDecorationItemAction( |
| 140 |
- itemId: string |
|
| 198 |
+ itemSn: number |
|
| 141 | 199 |
): Promise<DecorationItemFormState> {
|
| 142 | 200 |
await verifySession(); |
| 143 | 201 |
|
| 144 |
- if (!itemId) {
|
|
| 202 |
+ if (!Number.isInteger(itemSn) || itemSn <= 0) {
|
|
| 145 | 203 |
return { status: 'error', message: INVALID_REQUEST_MESSAGE };
|
| 146 | 204 |
} |
| 147 | 205 |
|
| 148 |
- await deleteDecorationItem(itemId); |
|
| 206 |
+ try {
|
|
| 207 |
+ await deleteDecorationItem(itemSn); |
|
| 208 |
+ } catch {
|
|
| 209 |
+ return { status: 'error', message: DELETE_FAILED_MESSAGE };
|
|
| 210 |
+ } |
|
| 149 | 211 |
|
| 150 | 212 |
revalidatePath(DECORATION_ITEMS_PATH); |
| 151 | 213 |
return { status: 'success' };
|
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-create-modal.tsx
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-create-modal.tsx
... | ... | @@ -7,15 +7,9 @@ |
| 7 | 7 |
import { Modal } from '@/components/ui/modal';
|
| 8 | 8 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 9 | 9 |
import type { DecorationItemType } from '@/lib/domain/decoration-item';
|
| 10 |
-import {
|
|
| 11 |
- DECORATION_ITEM_ID_HELP_TEXT, |
|
| 12 |
- INITIAL_DECORATION_ITEM_FORM_STATE, |
|
| 13 |
-} from '@/lib/domain/decoration-item-form'; |
|
| 10 |
+import { INITIAL_DECORATION_ITEM_FORM_STATE } from '@/lib/domain/decoration-item-form';
|
|
| 14 | 11 |
import { createDecorationItemAction } from '../_actions';
|
| 15 |
-import {
|
|
| 16 |
- DecorationItemFormFields, |
|
| 17 |
- FieldError, |
|
| 18 |
-} from './decoration-item-form-fields'; |
|
| 12 |
+import { DecorationItemFormFields } from './decoration-item-form-fields';
|
|
| 19 | 13 |
|
| 20 | 14 |
interface DecorationItemCreateModalProps {
|
| 21 | 15 |
/** 등록 버튼을 누른 시점의 활성 탭(유형) — 폼의 유형 기본값으로 쓴다. */ |
... | ... | @@ -29,9 +23,8 @@ |
| 29 | 23 |
/** |
| 30 | 24 |
* 꾸미기 아이템 등록 팝업(시안 ADM_ITM_102_p). |
| 31 | 25 |
* |
| 32 |
- * 아이템ID는 시안에 [중복확인] 버튼이 없지만 유일 식별자이자 수정/삭제 대상 키라 Server |
|
| 33 |
- * Action이 저장 직전에 중복을 확인한다(`_actions.ts` 주석 참조) — 화면에는 그 확인 과정이 |
|
| 34 |
- * 보이지 않고 실패 시 필드 오류로만 나타난다. |
|
| 26 |
+ * 아이템ID는 입력 항목이 아니다 — 시안대로 "등록 후 자동발급됩니다."를 읽기 전용으로 보여주고, |
|
| 27 |
+ * 실제 값은 백엔드가 `itemSn`(자동증가)으로 채운다. |
|
| 35 | 28 |
* |
| 36 | 29 |
* 저장 버튼은 footer 슬롯에서 `form={FORM_ID}` 속성으로 폼과 연결한다(admins의 등록 팝업과
|
| 37 | 30 |
* 동일한 패턴) — 버튼이 실제 DOM상 form의 자손이 아니어도 같은 문서 안에서 id만 일치하면 그 |
... | ... | @@ -82,18 +75,9 @@ |
| 82 | 75 |
* 는 필수 항목입니다. |
| 83 | 76 |
</p> |
| 84 | 77 |
|
| 85 |
- <Field label="아이템 ID *"> |
|
| 86 |
- <Input |
|
| 87 |
- type="text" |
|
| 88 |
- name="itemId" |
|
| 89 |
- placeholder="예: ITEM-HAT-002" |
|
| 90 |
- autoComplete="off" |
|
| 91 |
- /> |
|
| 78 |
+ <Field label="아이템 ID"> |
|
| 79 |
+ <Input type="text" value="등록 후 자동발급됩니다." readOnly /> |
|
| 92 | 80 |
</Field> |
| 93 |
- <p className="text-body-sm text-foreground-muted"> |
|
| 94 |
- {DECORATION_ITEM_ID_HELP_TEXT}
|
|
| 95 |
- </p> |
|
| 96 |
- <FieldError message={errors.itemId} />
|
|
| 97 | 81 |
|
| 98 | 82 |
<DecorationItemFormFields |
| 99 | 83 |
defaultItemType={defaultItemType}
|
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-edit-modal.tsx
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-edit-modal.tsx
... | ... | @@ -24,7 +24,7 @@ |
| 24 | 24 |
* 전용**이다. |
| 25 | 25 |
* |
| 26 | 26 |
* readOnly로 보여주는 아이템ID `Input`에는 `name`을 주지 않아 제출 대상에서 아예 빠지게 하고, |
| 27 |
- * 실제 수정 대상은 별도 hidden input(`itemId`)으로 넘긴다 — Server Action도 hidden 값만 |
|
| 27 |
+ * 실제 수정 대상은 별도 hidden input(`itemSn`)으로 넘긴다 — Server Action도 hidden 값만 |
|
| 28 | 28 |
* 읽으므로 readOnly 필드를 위조해서 보내도 수정 대상이 바뀌지 않는다(admins의 이름/ID readOnly |
| 29 | 29 |
* 처리와 동일한 방어 방식). |
| 30 | 30 |
*/ |
... | ... | @@ -69,14 +69,14 @@ |
| 69 | 69 |
} |
| 70 | 70 |
> |
| 71 | 71 |
<form id={FORM_ID} action={formAction} className="flex flex-col gap-4">
|
| 72 |
- <input type="hidden" name="itemId" value={item.itemId} />
|
|
| 72 |
+ <input type="hidden" name="itemSn" value={item.itemSn} />
|
|
| 73 | 73 |
|
| 74 | 74 |
<p className="text-right text-body-sm text-danger"> |
| 75 | 75 |
* 는 필수 항목입니다. |
| 76 | 76 |
</p> |
| 77 | 77 |
|
| 78 | 78 |
<Field label="아이템 ID"> |
| 79 |
- <Input type="text" value={item.itemId} readOnly />
|
|
| 79 |
+ <Input type="text" value={item.itemSn} readOnly />
|
|
| 80 | 80 |
</Field> |
| 81 | 81 |
|
| 82 | 82 |
<DecorationItemFormFields |
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-form-fields.tsx
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-form-fields.tsx
... | ... | @@ -7,6 +7,7 @@ |
| 7 | 7 |
import {
|
| 8 | 8 |
DECORATION_ITEM_CATEGORIES, |
| 9 | 9 |
DECORATION_ITEM_TYPE_OPTIONS, |
| 10 |
+ DEFAULT_DECORATION_ITEM_CATEGORY_CODE, |
|
| 10 | 11 |
type DecorationItem, |
| 11 | 12 |
type DecorationItemType, |
| 12 | 13 |
} from '@/lib/domain/decoration-item'; |
... | ... | @@ -29,23 +30,18 @@ |
| 29 | 30 |
} |
| 30 | 31 |
|
| 31 | 32 |
/** |
| 32 |
- * 등록·수정 팝업이 공유하는 입력 항목 — 유형/아이템명/카테고리/포인트/설명/사용여부/정렬순서 |
|
| 33 |
- * (시안 ADM_ITM_102_p / 103_p). 아이템ID와 그 읽기전용 여부만 각 팝업이 따로 그린다. |
|
| 33 |
+ * 등록·수정 팝업이 공유하는 입력 항목 — 유형/아이템명/카테고리/포인트/썸네일 이미지/설명/ |
|
| 34 |
+ * 사용여부/정렬순서(시안 ADM_ITM_102_p / 103_p). 아이템ID 표시만 각 팝업이 따로 그린다. |
|
| 34 | 35 |
* |
| 35 | 36 |
* **유형(개별/셋트) 전환 시 폼 구성이 바뀔 수 있다는 시안 설명이 있지만, 두 유형의 입력 항목이 |
| 36 |
- * 완전히 같아 조건부 렌더링을 두지 않았다** — 유형 값 자체만 폼에 실어 저장한다(사용자 확정 |
|
| 37 |
- * 사항). 목록의 유형 탭과 시각적으로 구분하기 위해(탭은 "지금 보고 있는 목록", 이 라디오는 |
|
| 38 |
- * "저장할 값") 별도 UI가 필요했는데, 전용 탭 컴포넌트가 없어(§10.3 — 신설은 design 레인 소관) |
|
| 39 |
- * 이미 있는 `RadioGroup`을 재사용했다(students의 사용여부 라디오와 동일한 재사용 패턴). |
|
| 37 |
+ * 완전히 같아 조건부 렌더링을 두지 않았다** — 유형 값 자체만 폼에 실어 저장한다. |
|
| 40 | 38 |
* |
| 41 |
- * **썸네일 이미지 업로드 필드는 의도적으로 없다** — 시안은 업로드를 요구하지만, 사용자가 |
|
| 42 |
- * "썸네일을 따로 등록하지 않고 추후 백엔드가 자동생성해주는 형식"이라고 확정했다(시안과 |
|
| 43 |
- * 다르게 가는 지점). 목록의 썸네일 컬럼 자체는 유지한다 — `decoration-item.ts`의 |
|
| 44 |
- * `getDecorationItemThumbnailLabel` 참조. |
|
| 39 |
+ * **이미지는 파일 하나만 받는다** — 시안의 "썸네일, 아이템 이미지 각각 등록? → NO"에 따른 것이고, |
|
| 40 |
+ * 저장은 백엔드의 `atchFileId`에만 한다(썸네일은 추후 백엔드가 자동생성할 예정, 사용자 확정 사항). |
|
| 41 |
+ * 수정 팝업에서 파일을 새로 고르지 않으면 hidden 필드로 유지한 기존 파일 ID가 그대로 저장된다. |
|
| 45 | 42 |
* |
| 46 |
- * 설명은 여러 줄 입력이 자연스럽지만 공용 textarea 컴포넌트가 없어(신설은 design 레인 소관, |
|
| 47 |
- * §10.4) 로직 우선 단계에서는 단일행 `Input`으로 대체했다 — design 레인에 필요 컴포넌트로 |
|
| 48 |
- * 보고한다. |
|
| 43 |
+ * 설명은 여러 줄 입력이 자연스럽지만 공용 textarea 컴포넌트가 없어 로직 우선 단계에서는 단일행 |
|
| 44 |
+ * `Input`으로 둔다(신설은 design 레인 소관). |
|
| 49 | 45 |
*/ |
| 50 | 46 |
export function DecorationItemFormFields({
|
| 51 | 47 |
item, |
... | ... | @@ -79,19 +75,21 @@ |
| 79 | 75 |
|
| 80 | 76 |
<Field label="카테고리 *"> |
| 81 | 77 |
<Select |
| 82 |
- name="category" |
|
| 83 |
- defaultValue={item?.category ?? DECORATION_ITEM_CATEGORIES[0]}
|
|
| 78 |
+ name="categoryCode" |
|
| 79 |
+ defaultValue={
|
|
| 80 |
+ item?.categoryCode ?? DEFAULT_DECORATION_ITEM_CATEGORY_CODE |
|
| 81 |
+ } |
|
| 84 | 82 |
> |
| 85 | 83 |
{DECORATION_ITEM_CATEGORIES.map((category) => (
|
| 86 |
- <option key={category} value={category}>
|
|
| 87 |
- {category}
|
|
| 84 |
+ <option key={category.code} value={category.code}>
|
|
| 85 |
+ {category.label}
|
|
| 88 | 86 |
</option> |
| 89 | 87 |
))} |
| 90 | 88 |
</Select> |
| 91 | 89 |
</Field> |
| 92 |
- <FieldError message={errors.category} />
|
|
| 90 |
+ <FieldError message={errors.categoryCode} />
|
|
| 93 | 91 |
|
| 94 |
- <Field label="포인트 *"> |
|
| 92 |
+ <Field label="포인트(오픈 가능한 포인트) *"> |
|
| 95 | 93 |
<Input |
| 96 | 94 |
type="number" |
| 97 | 95 |
name="points" |
... | ... | @@ -103,6 +101,23 @@ |
| 103 | 101 |
</Field> |
| 104 | 102 |
<FieldError message={errors.points} />
|
| 105 | 103 |
|
| 104 |
+ <Field label="썸네일 이미지 *"> |
|
| 105 |
+ <Input type="file" name="imageFile" accept="image/*" /> |
|
| 106 |
+ </Field> |
|
| 107 |
+ {/* 수정 팝업에서 파일을 새로 고르지 않았을 때 기존 이미지를 유지하는 값. 등록 팝업에서는
|
|
| 108 |
+ 빈 문자열이라 파일을 고르지 않으면 검증에서 걸린다. */} |
|
| 109 |
+ <input |
|
| 110 |
+ type="hidden" |
|
| 111 |
+ name="imageFileId" |
|
| 112 |
+ defaultValue={item?.imageFileId ?? ''}
|
|
| 113 |
+ /> |
|
| 114 |
+ {item?.imageFileId && (
|
|
| 115 |
+ <p className="text-body-sm text-foreground-muted"> |
|
| 116 |
+ 현재 등록된 이미지가 있습니다. 새로 선택하면 교체됩니다. |
|
| 117 |
+ </p> |
|
| 118 |
+ )} |
|
| 119 |
+ <FieldError message={errors.imageFileId} />
|
|
| 120 |
+ |
|
| 106 | 121 |
<Field label="설명"> |
| 107 | 122 |
<Input |
| 108 | 123 |
type="text" |
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-row-actions.tsx
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-row-actions.tsx
... | ... | @@ -35,7 +35,7 @@ |
| 35 | 35 |
function runDelete() {
|
| 36 | 36 |
hideAlert(); |
| 37 | 37 |
startDeleting(async () => {
|
| 38 |
- const result = await deleteDecorationItemAction(item.itemId); |
|
| 38 |
+ const result = await deleteDecorationItemAction(item.itemSn); |
|
| 39 | 39 |
if (result.status === 'error') {
|
| 40 | 40 |
showToast({
|
| 41 | 41 |
variant: 'danger', |
... | ... | @@ -51,7 +51,7 @@ |
| 51 | 51 |
showAlert({
|
| 52 | 52 |
variant: 'danger', |
| 53 | 53 |
title: '아이템을 삭제하시겠습니까?', |
| 54 |
- message: `${item.name}(${item.itemId})을(를) 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
|
|
| 54 |
+ message: `${item.name}(아이템ID ${item.itemSn})을(를) 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
|
|
| 55 | 55 |
actions: ( |
| 56 | 56 |
<> |
| 57 | 57 |
<Button type="button" variant="ghost" onClick={hideAlert}>
|
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-table.tsx
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-table.tsx
... | ... | @@ -7,10 +7,11 @@ |
| 7 | 7 |
TableRow, |
| 8 | 8 |
} from '@/components/ui/table'; |
| 9 | 9 |
import {
|
| 10 |
+ EMPTY_FIELD_PLACEHOLDER, |
|
| 10 | 11 |
formatDecorationItemActiveLabel, |
| 12 |
+ formatDecorationItemCategory, |
|
| 11 | 13 |
formatDecorationItemPoints, |
| 12 | 14 |
formatDecorationItemUpdatedAt, |
| 13 |
- getDecorationItemThumbnailLabel, |
|
| 14 | 15 |
type DecorationItem, |
| 15 | 16 |
} from '@/lib/domain/decoration-item'; |
| 16 | 17 |
import { DecorationItemRowActions } from './decoration-item-row-actions';
|
... | ... | @@ -44,12 +45,11 @@ |
| 44 | 45 |
* 정렬이 정렬순서 오름차순이라(Repository가 보장) "가장 작은 정렬순서가 1번"이 직관적이다. |
| 45 | 46 |
* admins가 전체 건수에서 거꾸로 세는 이유(생성일 최신순 정렬)가 여기에는 없다. |
| 46 | 47 |
* |
| 47 |
- * **썸네일은 아직 실제 이미지가 없다** — 시안은 업로드 필드를 요구하지만 사용자가 "썸네일을 |
|
| 48 |
- * 따로 등록하지 않고 추후 백엔드가 자동생성해주는 형식"이라고 확정했고(등록/수정 폼에 업로드 |
|
| 49 |
- * 필드가 없는 이유, `decoration-item-form-fields.tsx` 참조), 그 백엔드 자체가 아직 없어 |
|
| 50 |
- * 지금은 생성해 줄 이미지도 없다. 열 자리는 유지하고 아이템ID의 중간 세그먼트(예: |
|
| 51 |
- * `ITEM-HAT-002` → `HAT`)를 뽑아 보여주는 텍스트 박스로 대신한다 — 백엔드가 실제 썸네일을 |
|
| 52 |
- * 내려주면 이 셀만 `<img>`로 바꾸면 된다. |
|
| 48 |
+ * **썸네일은 백엔드 이미지 URL을 그대로 건다** — 등록한 파일 ID(`atchFileId`)로 만든 |
|
| 49 |
+ * `GET /api/v1/common/file/image` 주소이며, 이 GET은 인증이 필요 없어 브라우저가 백엔드를 직접 |
|
| 50 |
+ * 호출한다(사용자 확정 사항). URL 문자열은 서버에서 만들어 내려주므로 백엔드 주소 환경변수는 |
|
| 51 |
+ * 여전히 서버 전용이다. `next/image`가 아니라 `<img>`를 쓰는 이유는 외부 호스트를 |
|
| 52 |
+ * `next.config`에 등록해야 하는 설정 변경을 이번 범위(로직)에서 하지 않기 때문이다. |
|
| 53 | 53 |
* |
| 54 | 54 |
* "관리" 열은 행별 수정/삭제 트리거(`DecorationItemRowActions`)에 위임한다 — 상호작용이 |
| 55 | 55 |
* 필요한 것은 그 셀뿐이라 이 테이블 자체는 Server Component로 유지하고 최말단만 클라이언트 |
... | ... | @@ -74,20 +74,23 @@ |
| 74 | 74 |
</TableHead> |
| 75 | 75 |
<TableBody> |
| 76 | 76 |
{items.map((item, index) => (
|
| 77 |
- <TableRow key={item.itemId}>
|
|
| 77 |
+ <TableRow key={item.itemSn}>
|
|
| 78 | 78 |
<TableCell>{offset + index + 1}</TableCell>
|
| 79 |
- <TableCell>{item.itemId}</TableCell>
|
|
| 79 |
+ <TableCell>{item.itemSn}</TableCell>
|
|
| 80 | 80 |
<TableCell> |
| 81 |
- <span |
|
| 82 |
- aria-hidden="true" |
|
| 83 |
- className="inline-flex size-9 items-center justify-center rounded-md bg-surface-muted text-label-sm text-foreground-muted" |
|
| 84 |
- > |
|
| 85 |
- {getDecorationItemThumbnailLabel(item.itemId)}
|
|
| 86 |
- </span> |
|
| 87 |
- <span className="sr-only">썸네일 준비 중</span> |
|
| 81 |
+ {item.imageUrl ? (
|
|
| 82 |
+ // eslint-disable-next-line @next/next/no-img-element -- 위 주석 참조(외부 호스트 설정 회피) |
|
| 83 |
+ <img |
|
| 84 |
+ src={item.imageUrl}
|
|
| 85 |
+ alt={`${item.name} 썸네일`}
|
|
| 86 |
+ className="size-9 rounded-md object-cover" |
|
| 87 |
+ /> |
|
| 88 |
+ ) : ( |
|
| 89 |
+ EMPTY_FIELD_PLACEHOLDER |
|
| 90 |
+ )} |
|
| 88 | 91 |
</TableCell> |
| 89 | 92 |
<TableCell>{item.name}</TableCell>
|
| 90 |
- <TableCell>{item.category}</TableCell>
|
|
| 93 |
+ <TableCell>{formatDecorationItemCategory(item)}</TableCell>
|
|
| 91 | 94 |
<TableCell>{formatDecorationItemPoints(item.points)}</TableCell>
|
| 92 | 95 |
<TableCell>{formatDecorationItemActiveLabel(item.isActive)}</TableCell>
|
| 93 | 96 |
<TableCell>{item.sortOrder}</TableCell>
|
--- app/(protected)/(basic)/decoration-items/page.tsx
+++ app/(protected)/(basic)/decoration-items/page.tsx
... | ... | @@ -35,10 +35,15 @@ |
| 35 | 35 |
await verifySession(); |
| 36 | 36 |
|
| 37 | 37 |
const query = parseDecorationItemQuery(await searchParams); |
| 38 |
- const { items, totalCount, typeTotalCount } =
|
|
| 38 |
+ const { items, totalCount, isTotalCountExact, typeTotalCount } =
|
|
| 39 | 39 |
await fetchDecorationItems(query); |
| 40 | 40 |
|
| 41 |
- const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize)); |
|
| 41 |
+ // 전체 건수가 확정되지 않았다면(백엔드가 count를 주지 않아 하한값만 아는 상태) 다음 페이지를 |
|
| 42 |
+ // 한 칸 열어 둔다 — 열어 두지 않으면 가득 찬 페이지 뒤의 데이터에 접근할 방법이 없어진다 |
|
| 43 |
+ // (학생 목록과 동일한 보정. Repository 주석 참조). |
|
| 44 |
+ const totalPages = isTotalCountExact |
|
| 45 |
+ ? Math.max(1, Math.ceil(totalCount / query.pageSize)) |
|
| 46 |
+ : query.page + 1; |
|
| 42 | 47 |
// 요청 페이지가 범위를 벗어나면(예: 삭제로 마지막 페이지가 사라짐, 또는 유형 탭 전환으로 |
| 43 | 48 |
// 전체 건수가 줄어듦) 마지막 페이지로 맞춘다 — 표의 순번 계산도 이 값을 기준으로 해야 헤더의 |
| 44 | 49 |
// "현재페이지"와 어긋나지 않는다. |
... | ... | @@ -55,7 +60,9 @@ |
| 55 | 60 |
<DecorationItemListToolbar query={query} typeTotalCount={typeTotalCount} />
|
| 56 | 61 |
|
| 57 | 62 |
<p className="text-body-md text-foreground-muted"> |
| 58 |
- 총 {totalCount}개 | 현재페이지 {currentPage}/{totalPages}
|
|
| 63 |
+ 총 {totalCount}개{isTotalCountExact ? '' : ' 이상'} | 현재페이지{' '}
|
|
| 64 |
+ {currentPage}
|
|
| 65 |
+ {isTotalCountExact ? `/${totalPages}` : ''}
|
|
| 59 | 66 |
</p> |
| 60 | 67 |
|
| 61 | 68 |
{items.length === 0 ? (
|
--- lib/data/mock/decoration-item-store.ts
... | ... | @@ -1,212 +0,0 @@ |
| 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
+++ lib/data/repositories/decoration-item-repository.ts
... | ... | @@ -1,128 +1,359 @@ |
| 1 | 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';
|
|
| 2 |
+import { getSessionAccessToken } from '@/lib/auth/dal';
|
|
| 3 |
+import { getApiBaseUrl } from '@/lib/env';
|
|
| 4 |
+import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
|
|
| 5 |
+import type { DecorationItem, DecorationItemType } from '@/lib/domain/decoration-item';
|
|
| 6 |
+import type { DecorationItemEditableValues } from '@/lib/domain/decoration-item-form';
|
|
| 11 | 7 |
import type {
|
| 12 | 8 |
DecorationItemQuery, |
| 13 | 9 |
DecorationItemSearchField, |
| 14 | 10 |
} from '@/lib/domain/decoration-item-query'; |
| 15 | 11 |
|
| 16 | 12 |
/** |
| 17 |
- * 꾸미기 아이템 Repository — **백엔드에 이 도메인의 API가 전혀 없다**(조회조차 없음, 사용자 |
|
| 18 |
- * 확정 사항 — edupay-backend 저장소에 대응 컨트롤러/서비스/매퍼가 없음을 확인했다). 그래서 |
|
| 19 |
- * admin-member-repository.ts처럼 "백엔드 응답 + mock 오버레이" 구조가 아니라, 이 계층 전체가 |
|
| 20 |
- * `lib/data/mock/decoration-item-store.ts` 하나로만 동작한다 — 조회·등록·수정·삭제 전부. |
|
| 13 |
+ * 꾸미기 아이템 Repository — 이 도메인을 백엔드에서 "어떻게 읽고 쓰는지"만 안다(엔드포인트· |
|
| 14 |
+ * 파라미터·응답 매핑). 통신 규약은 `lib/http/backend-fetch.ts`가, 토큰 보관·검증은 `lib/auth`가 |
|
| 15 |
+ * 소유하므로 여기에 들어오지 않는다. |
|
| 21 | 16 |
* |
| 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와 동일한 신선도 보장 방식이다. |
|
| 17 |
+ * ``` |
|
| 18 |
+ * GET /api/v1/mngr/item/pagination 목록 (ROLE_ADMIN) |
|
| 19 |
+ * GET /api/v1/mngr/item/{itemSn} 단건
|
|
| 20 |
+ * POST /api/v1/mngr/item 등록 |
|
| 21 |
+ * PUT /api/v1/mngr/item/{itemSn} 수정
|
|
| 22 |
+ * DELETE /api/v1/mngr/item/{itemSn} 삭제
|
|
| 23 |
+ * POST /api/v1/common/file/upload/{moduleId} 이미지 업로드(multipart)
|
|
| 24 |
+ * ``` |
|
| 30 | 25 |
* |
| 31 |
- * 백엔드에 API가 생기면 이 파일의 "쓰기 경로" 세 함수와 조회 함수의 본문만 실제 `backendFetch` |
|
| 32 |
- * 호출로 바꾸면 되고, 화면·Server Action은 그대로다. |
|
| 26 |
+ * 아래는 백엔드 저장소(edupay-backend, develop)의 실제 구현을 읽고 확인한 것이다 — |
|
| 27 |
+ * MngrItemApiController / MngrItemServiceImpl / MngrItemMapper.xml / FileCommonApiController. |
|
| 28 |
+ * |
|
| 29 |
+ * - **쓰기 API는 JSON이 아니라 form 인코딩이다.** 컨트롤러가 `@RequestBody`가 아닌 |
|
| 30 |
+ * `@ParameterObject`로 받으므로 JSON을 보내면 전 필드가 null인 채 저장된다(오류도 나지 않는다). |
|
| 31 |
+ * - **목록은 `searchItemType`이 필수다** — SQL의 WHERE에 `AND a.ITEM_TYPE = #{searchItemType}`이
|
|
| 32 |
+ * 무조건 붙는다. 값이 없으면 아무것도 조회되지 않는다. |
|
| 33 |
+ * - **검색은 아이템명(`searchCondition="1"`)만 구현돼 있다.** 아이템ID 검색 분기는 없어, 그 |
|
| 34 |
+ * 값을 보내면 조건 없이 전체가 반환된다(사용자 지시로 화면 선택지는 유지하고 백엔드에 추가 요청). |
|
| 35 |
+ * - **정렬은 고정이다** — `ROW_NUMBER() OVER (ORDER BY SORT_ORDR DESC)`를 다시 역순으로 정렬해 |
|
| 36 |
+ * 결과적으로 정렬순서 오름차순이며(시안 ADM_ITM_101과 일치) 정렬 파라미터는 없다. |
|
| 37 |
+ * - **응답의 `totalCount`는 전체 건수가 아니라 그 페이지의 행 수다**(학생·관리자 목록과 동일한 |
|
| 38 |
+ * `PaginationUtil` 결함). 그대로 믿으면 페이지가 가득 찰 때마다 다음 페이지에 도달할 수 없어 |
|
| 39 |
+ * 하한값으로 보정한다. |
|
| 40 |
+ * |
|
| 41 |
+ * 캐시: 조회는 `no-store` — 개인화 데이터는 아니지만 관리 화면이라 신선도가 우선이고 검색 조건이 |
|
| 42 |
+ * 매 요청 달라진다. 쓰기는 캐시 대상이 아니며 호출부(Server Action)가 `revalidatePath`로 목록을 |
|
| 43 |
+ * 재검증한다. |
|
| 33 | 44 |
*/ |
| 34 | 45 |
|
| 35 |
-const SEARCH_VALUE_BY_FIELD: Record< |
|
| 36 |
- DecorationItemSearchField, |
|
| 37 |
- (item: DecorationItem) => string |
|
| 38 |
-> = {
|
|
| 39 |
- name: (item) => item.name, |
|
| 40 |
- itemId: (item) => item.itemId, |
|
| 46 |
+const DECORATION_ITEM_PATH = '/api/v1/mngr/item'; |
|
| 47 |
+const FILE_UPLOAD_PATH = '/api/v1/common/file/upload'; |
|
| 48 |
+const FILE_IMAGE_PATH = '/api/v1/common/file/image'; |
|
| 49 |
+ |
|
| 50 |
+/** 파일 업로드 경로 변수 — 아이템 이미지용 모듈 ID(사용자 확정 사항). */ |
|
| 51 |
+const ITEM_FILE_MODULE_ID = 'MODULE_ITEM'; |
|
| 52 |
+ |
|
| 53 |
+/** 화면·URL의 유형 값 ↔ 백엔드 `itemType` 코드값(사용자 확정 사항). */ |
|
| 54 |
+const ITEM_TYPE_CODE: Record<DecorationItemType, string> = {
|
|
| 55 |
+ individual: 'N', |
|
| 56 |
+ set: 'S', |
|
| 41 | 57 |
}; |
| 42 | 58 |
|
| 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 |
- } |
|
| 59 |
+const ITEM_TYPE_BY_CODE: Record<string, DecorationItemType> = {
|
|
| 60 |
+ N: 'individual', |
|
| 61 |
+ S: 'set', |
|
| 62 |
+}; |
|
| 51 | 63 |
|
| 52 |
- const readValue = SEARCH_VALUE_BY_FIELD[query.searchField]; |
|
| 53 |
- return items.filter((item) => readValue(item).toLowerCase().includes(keyword)); |
|
| 64 |
+/** |
|
| 65 |
+ * 화면의 "검색 대상" → 백엔드 `searchCondition` 값. |
|
| 66 |
+ * |
|
| 67 |
+ * **백엔드에 구현된 분기는 `"1"`(아이템명) 하나뿐이다.** 아이템ID 검색은 사용자 지시로 화면 |
|
| 68 |
+ * 선택지를 유지한 채 백엔드에 추가를 요청하기로 했고, 그때 쓸 값으로 `"2"`를 예약해 둔다 |
|
| 69 |
+ * (학생 목록이 1=이름·2=아이디·3=휴대전화를 쓰는 관례와 같은 순번). **백엔드가 이 값을 모르는 |
|
| 70 |
+ * 동안에는 검색어가 무시되고 전체 목록이 반환된다.** |
|
| 71 |
+ */ |
|
| 72 |
+const SEARCH_CONDITION_BY_FIELD: Record<DecorationItemSearchField, string> = {
|
|
| 73 |
+ name: '1', |
|
| 74 |
+ itemId: '2', |
|
| 75 |
+}; |
|
| 76 |
+ |
|
| 77 |
+/** 이름순 정렬이 아니라 "전체 건수를 알기 위해" 한 번에 받아올 최대 행 수(아래 주석 참조). */ |
|
| 78 |
+const TYPE_TOTAL_COUNT_FETCH_LIMIT = 1_000; |
|
| 79 |
+ |
|
| 80 |
+function isRecord(value: unknown): value is Record<string, unknown> {
|
|
| 81 |
+ return value !== null && typeof value === 'object'; |
|
| 54 | 82 |
} |
| 55 | 83 |
|
| 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) |
|
| 84 |
+function readString(source: Record<string, unknown>, key: string): string | null {
|
|
| 85 |
+ const value = source[key]; |
|
| 86 |
+ return typeof value === 'string' && value.length > 0 ? value : null; |
|
| 87 |
+} |
|
| 88 |
+ |
|
| 89 |
+function readNumber(source: Record<string, unknown>, key: string): number | null {
|
|
| 90 |
+ const value = source[key]; |
|
| 91 |
+ return typeof value === 'number' ? value : null; |
|
| 92 |
+} |
|
| 93 |
+ |
|
| 94 |
+/** 업로드된 파일 ID로 백엔드 이미지 URL을 만든다 — 이 GET은 인증이 필요 없어 브라우저가 직접 부른다. */ |
|
| 95 |
+function buildImageUrl(imageFileId: string | null): string | null {
|
|
| 96 |
+ if (!imageFileId) {
|
|
| 97 |
+ return null; |
|
| 98 |
+ } |
|
| 99 |
+ |
|
| 100 |
+ const base = getApiBaseUrl().replace(/\/+$/, ''); |
|
| 101 |
+ const url = new URL(`${base}${FILE_IMAGE_PATH}`);
|
|
| 102 |
+ url.searchParams.set('atchFileId', imageFileId);
|
|
| 103 |
+ // 아이템은 파일을 1개만 등록하므로 첨부 순번은 항상 1이다(백엔드도 미지정 시 1로 기본값 처리). |
|
| 104 |
+ url.searchParams.set('fileSn', '1');
|
|
| 105 |
+ return url.toString(); |
|
| 106 |
+} |
|
| 107 |
+ |
|
| 108 |
+/** |
|
| 109 |
+ * 백엔드 응답 1건 → 도메인 타입. 식별자·이름 등 목록의 존재 이유인 값이 없으면 예외로 끊는다 |
|
| 110 |
+ * (fail-fast) — 빈 화면을 조용히 보여주는 것보다 계약 위반을 즉시 드러내는 편이 낫다. |
|
| 111 |
+ * |
|
| 112 |
+ * `rnum`은 담지 않는다 — 표의 "번호"는 현재 페이지 기준 표시 순번이고 표 컴포넌트가 계산한다. |
|
| 113 |
+ */ |
|
| 114 |
+function toDecorationItem(raw: unknown): DecorationItem {
|
|
| 115 |
+ if (!isRecord(raw)) {
|
|
| 116 |
+ throw new Error('아이템 응답 항목의 형식이 올바르지 않습니다.');
|
|
| 117 |
+ } |
|
| 118 |
+ |
|
| 119 |
+ const itemSn = readNumber(raw, 'itemSn'); |
|
| 120 |
+ if (itemSn === null) {
|
|
| 121 |
+ throw new Error('아이템 응답에 itemSn이 없습니다.');
|
|
| 122 |
+ } |
|
| 123 |
+ |
|
| 124 |
+ const imageFileId = readString(raw, 'atchFileId'); |
|
| 125 |
+ |
|
| 126 |
+ return {
|
|
| 127 |
+ itemSn, |
|
| 128 |
+ // 코드값이 비었거나 모르는 값이면 화면 기본 유형으로 떨어뜨리지 않고 개별로 둔다 — |
|
| 129 |
+ // 목록은 유형별로 조회하므로 실제로는 요청한 유형의 행만 온다. |
|
| 130 |
+ itemType: ITEM_TYPE_BY_CODE[readString(raw, 'itemType') ?? ''] ?? 'individual', |
|
| 131 |
+ name: readString(raw, 'itemNm') ?? '', |
|
| 132 |
+ categoryCode: readString(raw, 'itemCateCd') ?? '', |
|
| 133 |
+ categoryName: readString(raw, 'itemCateNm'), |
|
| 134 |
+ points: readNumber(raw, 'itemAmount') ?? 0, |
|
| 135 |
+ description: readString(raw, 'itemExplan'), |
|
| 136 |
+ isActive: readString(raw, 'useYn') === 'Y', |
|
| 137 |
+ sortOrder: readNumber(raw, 'sortOrdr') ?? 0, |
|
| 138 |
+ imageFileId, |
|
| 139 |
+ imageUrl: buildImageUrl(imageFileId), |
|
| 140 |
+ updatedAt: readString(raw, 'lastMdfcnDt'), |
|
| 141 |
+ }; |
|
| 142 |
+} |
|
| 143 |
+ |
|
| 144 |
+function buildListParams( |
|
| 145 |
+ query: DecorationItemQuery, |
|
| 146 |
+ pageIndex: number, |
|
| 147 |
+ recordCountPerPage: number |
|
| 148 |
+): Record<string, string | number> {
|
|
| 149 |
+ const keyword = query.keyword.trim(); |
|
| 150 |
+ |
|
| 151 |
+ return {
|
|
| 152 |
+ searchItemType: ITEM_TYPE_CODE[query.itemType], |
|
| 153 |
+ searchCondition: keyword ? SEARCH_CONDITION_BY_FIELD[query.searchField] : '', |
|
| 154 |
+ searchKeyword: keyword, |
|
| 155 |
+ // 유효한 페이징 파라미터는 이 둘뿐이다 — 서버가 offset을 직접 계산해 firstIndex· |
|
| 156 |
+ // recordCountPerPage를 덮어쓰고 나머지(pageUnit·pageSize·lastIndex)는 읽지 않는다. |
|
| 157 |
+ pageIndex, |
|
| 158 |
+ recordCountPerPage, |
|
| 159 |
+ }; |
|
| 160 |
+} |
|
| 161 |
+ |
|
| 162 |
+/** 목록 조회 1회 — 응답 검증까지만 하고 건수 판단은 호출부에 맡긴다. */ |
|
| 163 |
+async function requestItemList( |
|
| 164 |
+ query: DecorationItemQuery, |
|
| 165 |
+ pageIndex: number, |
|
| 166 |
+ recordCountPerPage: number |
|
| 167 |
+): Promise<{ items: DecorationItem[]; reportedTotalCount: number }> {
|
|
| 168 |
+ const accessToken = await getSessionAccessToken(); |
|
| 169 |
+ |
|
| 170 |
+ const result = await backendFetch<unknown>( |
|
| 171 |
+ `${DECORATION_ITEM_PATH}/pagination`,
|
|
| 172 |
+ {
|
|
| 173 |
+ method: 'GET', |
|
| 174 |
+ query: buildListParams(query, pageIndex, recordCountPerPage), |
|
| 175 |
+ accessToken: accessToken ?? undefined, |
|
| 176 |
+ cache: 'no-store', |
|
| 177 |
+ } |
|
| 63 | 178 |
); |
| 179 |
+ |
|
| 180 |
+ if (!result.ok) {
|
|
| 181 |
+ throw new BackendRequestError(result); |
|
| 182 |
+ } |
|
| 183 |
+ |
|
| 184 |
+ const data = result.data; |
|
| 185 |
+ if (!isRecord(data) || !Array.isArray(data.list)) {
|
|
| 186 |
+ throw new Error('아이템 목록 응답의 형식이 올바르지 않습니다.');
|
|
| 187 |
+ } |
|
| 188 |
+ |
|
| 189 |
+ return {
|
|
| 190 |
+ items: data.list.map(toDecorationItem), |
|
| 191 |
+ reportedTotalCount: |
|
| 192 |
+ typeof data.totalCount === 'number' ? data.totalCount : data.list.length, |
|
| 193 |
+ }; |
|
| 64 | 194 |
} |
| 65 | 195 |
|
| 66 | 196 |
export type DecorationItemPage = {
|
| 67 | 197 |
items: DecorationItem[]; |
| 68 |
- /** 검색어까지 적용한 결과 건수 — 요약 문구·페이지네이션 기준. */ |
|
| 198 |
+ /** 검색어까지 적용한 결과 건수. `isTotalCountExact`가 false면 "적어도 이만큼"이라는 하한값이다. */ |
|
| 69 | 199 |
totalCount: number; |
| 200 |
+ isTotalCountExact: boolean; |
|
| 70 | 201 |
/** |
| 71 |
- * 검색어와 무관하게 현재 유형(개별/셋트) 전체 등록 건수. 등록/수정 팝업의 |
|
| 72 |
- * "정렬순서 (총 등록 N개)" 힌트와 신규 등록 기본 정렬순서(맨 끝에 추가) 계산에 쓴다 — 검색 |
|
| 73 |
- * 결과가 아니라 그 유형에 실제로 존재하는 전체 개수여야 하므로 `totalCount`와 분리했다. |
|
| 202 |
+ * 검색어와 무관하게 현재 유형(개별/셋트) 전체 등록 건수 — 등록/수정 팝업의 |
|
| 203 |
+ * "정렬순서 (총 등록 N개)" 힌트와 신규 등록 기본 정렬순서 계산에 쓴다. |
|
| 74 | 204 |
*/ |
| 75 | 205 |
typeTotalCount: number; |
| 76 | 206 |
}; |
| 77 | 207 |
|
| 78 |
-/** 검색·유형·페이징이 적용된 꾸미기 아이템 목록을 조회한다. */ |
|
| 208 |
+/** |
|
| 209 |
+ * 검색·유형·페이징이 적용된 목록을 조회한다. |
|
| 210 |
+ * |
|
| 211 |
+ * **`totalCount` 보정**: 백엔드는 count 쿼리 없이 `list.size()`를 총건수로 내려준다 |
|
| 212 |
+ * (`PaginationUtil.execute`). 그대로 쓰면 한 페이지가 가득 찰 때마다 `totalPages`가 1로 계산돼 |
|
| 213 |
+ * 2페이지 이후에 접근할 수 없으므로, 학생 목록과 동일하게 하한값으로 보정한다 — 페이지가 가득 |
|
| 214 |
+ * 차지 않았으면 마지막 페이지이므로 `offset + 행 수`가 확정 총건수이고, 가득 찼으면 하한값만 |
|
| 215 |
+ * 알린다. 백엔드가 진짜 count를 넣으면 `Math.max`가 자동으로 그 값을 채택한다. |
|
| 216 |
+ * |
|
| 217 |
+ * **`typeTotalCount`는 별도 조회다** — 검색 결과가 아니라 그 유형에 실제로 존재하는 전체 개수여야 |
|
| 218 |
+ * 하는데(정렬순서 힌트·기본값의 근거) 백엔드가 총건수를 주지 않으므로, 검색어를 뺀 조건으로 큰 |
|
| 219 |
+ * 상한을 걸어 한 번 더 조회해 길이를 센다. 백엔드에 count가 생기면 이 왕복을 없앨 수 있다. |
|
| 220 |
+ */ |
|
| 79 | 221 |
export async function fetchDecorationItems( |
| 80 | 222 |
query: DecorationItemQuery |
| 81 | 223 |
): Promise<DecorationItemPage> {
|
| 82 |
- const byType = listAllDecorationItems().filter( |
|
| 83 |
- (item) => item.itemType === query.itemType |
|
| 224 |
+ const { items, reportedTotalCount } = await requestItemList(
|
|
| 225 |
+ query, |
|
| 226 |
+ query.page, |
|
| 227 |
+ query.pageSize |
|
| 84 | 228 |
); |
| 85 |
- const matched = sortBySortOrder(filterByKeyword(byType, query)); |
|
| 229 |
+ |
|
| 86 | 230 |
const offset = (query.page - 1) * query.pageSize; |
| 231 |
+ const confirmedCount = offset + items.length; |
|
| 232 |
+ const reachedLastPage = items.length < query.pageSize; |
|
| 233 |
+ |
|
| 234 |
+ const typeTotal = await requestItemList( |
|
| 235 |
+ { ...query, keyword: '' },
|
|
| 236 |
+ 1, |
|
| 237 |
+ TYPE_TOTAL_COUNT_FETCH_LIMIT |
|
| 238 |
+ ); |
|
| 87 | 239 |
|
| 88 | 240 |
return {
|
| 89 |
- items: matched.slice(offset, offset + query.pageSize), |
|
| 90 |
- totalCount: matched.length, |
|
| 91 |
- typeTotalCount: byType.length, |
|
| 241 |
+ items, |
|
| 242 |
+ totalCount: Math.max(reportedTotalCount, confirmedCount), |
|
| 243 |
+ isTotalCountExact: reachedLastPage || reportedTotalCount > confirmedCount, |
|
| 244 |
+ typeTotalCount: typeTotal.items.length, |
|
| 92 | 245 |
}; |
| 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 | 246 |
} |
| 106 | 247 |
|
| 107 | 248 |
/* |
| 108 | 249 |
* ─── 쓰기 경로 ──────────────────────────────────────────────────────────────── |
| 109 |
- * 백엔드에 이 도메인의 API가 전혀 없어 세 함수 모두 mock 저장소에 위임한다 |
|
| 110 |
- * (`lib/data/mock/decoration-item-store.ts`의 주석에 한계를 적어 두었다). |
|
| 250 |
+ * 컨트롤러가 `@ParameterObject`로 받으므로 전부 form 인코딩으로 보낸다(파일 상단 주석 참조). |
|
| 111 | 251 |
*/ |
| 112 | 252 |
|
| 253 |
+/** 등록·수정이 공유하는 전송 필드. */ |
|
| 254 |
+function buildWriteForm( |
|
| 255 |
+ values: DecorationItemEditableValues |
|
| 256 |
+): Record<string, string | number | undefined> {
|
|
| 257 |
+ return {
|
|
| 258 |
+ itemNm: values.name, |
|
| 259 |
+ itemCateCd: values.categoryCode, |
|
| 260 |
+ itemAmount: values.points, |
|
| 261 |
+ itemExplan: values.description, |
|
| 262 |
+ useYn: values.isActive ? 'Y' : 'N', |
|
| 263 |
+ sortOrdr: values.sortOrder, |
|
| 264 |
+ itemType: ITEM_TYPE_CODE[values.itemType], |
|
| 265 |
+ // 썸네일은 백엔드가 자동생성하도록 바뀔 예정이라 지금은 atchFileId만 보낸다(사용자 확정 사항). |
|
| 266 |
+ atchFileId: values.imageFileId, |
|
| 267 |
+ }; |
|
| 268 |
+} |
|
| 269 |
+ |
|
| 270 |
+async function sendWrite( |
|
| 271 |
+ path: string, |
|
| 272 |
+ method: 'POST' | 'PUT', |
|
| 273 |
+ values: DecorationItemEditableValues |
|
| 274 |
+): Promise<void> {
|
|
| 275 |
+ const accessToken = await getSessionAccessToken(); |
|
| 276 |
+ |
|
| 277 |
+ const result = await backendFetch<null>(path, {
|
|
| 278 |
+ method, |
|
| 279 |
+ form: buildWriteForm(values), |
|
| 280 |
+ accessToken: accessToken ?? undefined, |
|
| 281 |
+ cache: 'no-store', |
|
| 282 |
+ // 등록·수정 성공 응답은 `ApiResponseVO.success(null)` — data가 정상적으로 null이다. |
|
| 283 |
+ canHaveNullData: true, |
|
| 284 |
+ }); |
|
| 285 |
+ |
|
| 286 |
+ if (!result.ok) {
|
|
| 287 |
+ throw new BackendRequestError(result); |
|
| 288 |
+ } |
|
| 289 |
+} |
|
| 290 |
+ |
|
| 113 | 291 |
export async function createDecorationItem( |
| 114 |
- input: CreateDecorationItemInput |
|
| 292 |
+ values: DecorationItemEditableValues |
|
| 115 | 293 |
): Promise<void> {
|
| 116 |
- createMockDecorationItem(input); |
|
| 294 |
+ await sendWrite(DECORATION_ITEM_PATH, 'POST', values); |
|
| 117 | 295 |
} |
| 118 | 296 |
|
| 297 |
+/** |
|
| 298 |
+ * 수정. |
|
| 299 |
+ * |
|
| 300 |
+ * ⚠️ **백엔드 결함**: 컨트롤러의 update가 요청 VO에서 `itemType`을 빌더에 담지 않는데 UPDATE 문은 |
|
| 301 |
+ * `ITEM_TYPE = #{itemType}`을 쓴다 — 그래서 수정하면 ITEM_TYPE이 null이 되고, 목록은
|
|
| 302 |
+ * `ITEM_TYPE = searchItemType`으로 필터하므로 그 아이템이 개별·셋트 양쪽 탭에서 사라진다. |
|
| 303 |
+ * 우리가 `itemType`을 보내도 컨트롤러가 버리므로 프론트에서 우회할 수 없다(사용자 지시로 그대로 |
|
| 304 |
+ * 연동하고 이슈로 보고한다). `lastMdfcnDt`도 컨트롤러가 채우지 않아 수정일시가 null이 된다. |
|
| 305 |
+ */ |
|
| 119 | 306 |
export async function updateDecorationItem( |
| 120 |
- itemId: string, |
|
| 121 |
- input: UpdateDecorationItemInput |
|
| 307 |
+ itemSn: number, |
|
| 308 |
+ values: DecorationItemEditableValues |
|
| 122 | 309 |
): Promise<void> {
|
| 123 |
- updateMockDecorationItem(itemId, input); |
|
| 310 |
+ await sendWrite(`${DECORATION_ITEM_PATH}/${itemSn}`, 'PUT', values);
|
|
| 124 | 311 |
} |
| 125 | 312 |
|
| 126 |
-export async function deleteDecorationItem(itemId: string): Promise<void> {
|
|
| 127 |
- deleteMockDecorationItem(itemId); |
|
| 313 |
+export async function deleteDecorationItem(itemSn: number): Promise<void> {
|
|
| 314 |
+ const accessToken = await getSessionAccessToken(); |
|
| 315 |
+ |
|
| 316 |
+ const result = await backendFetch<null>(`${DECORATION_ITEM_PATH}/${itemSn}`, {
|
|
| 317 |
+ method: 'DELETE', |
|
| 318 |
+ accessToken: accessToken ?? undefined, |
|
| 319 |
+ cache: 'no-store', |
|
| 320 |
+ canHaveNullData: true, |
|
| 321 |
+ }); |
|
| 322 |
+ |
|
| 323 |
+ if (!result.ok) {
|
|
| 324 |
+ throw new BackendRequestError(result); |
|
| 325 |
+ } |
|
| 326 |
+} |
|
| 327 |
+ |
|
| 328 |
+/** |
|
| 329 |
+ * 이미지 업로드 — 성공 시 `atchFileId` 문자열을 돌려준다. |
|
| 330 |
+ * |
|
| 331 |
+ * 브라우저가 백엔드에 직접 올리지 않는다(토큰이 서버에만 있다) — Server Action이 받은 File을 |
|
| 332 |
+ * 그대로 백엔드로 중계한다. |
|
| 333 |
+ */ |
|
| 334 |
+export async function uploadDecorationItemImage(file: File): Promise<string> {
|
|
| 335 |
+ const accessToken = await getSessionAccessToken(); |
|
| 336 |
+ |
|
| 337 |
+ const multipart = new FormData(); |
|
| 338 |
+ multipart.set('file', file);
|
|
| 339 |
+ |
|
| 340 |
+ const result = await backendFetch<string>( |
|
| 341 |
+ `${FILE_UPLOAD_PATH}/${ITEM_FILE_MODULE_ID}`,
|
|
| 342 |
+ {
|
|
| 343 |
+ method: 'POST', |
|
| 344 |
+ multipart, |
|
| 345 |
+ accessToken: accessToken ?? undefined, |
|
| 346 |
+ cache: 'no-store', |
|
| 347 |
+ } |
|
| 348 |
+ ); |
|
| 349 |
+ |
|
| 350 |
+ if (!result.ok) {
|
|
| 351 |
+ throw new BackendRequestError(result); |
|
| 352 |
+ } |
|
| 353 |
+ |
|
| 354 |
+ if (typeof result.data !== 'string' || !result.data) {
|
|
| 355 |
+ throw new Error('이미지 업로드 응답에 파일 ID가 없습니다.');
|
|
| 356 |
+ } |
|
| 357 |
+ |
|
| 358 |
+ return result.data; |
|
| 128 | 359 |
} |
--- lib/domain/decoration-item-form.ts
+++ lib/domain/decoration-item-form.ts
... | ... | @@ -1,58 +1,46 @@ |
| 1 | 1 |
/** |
| 2 | 2 |
* 꾸미기 아이템 등록/수정 입력 규칙 — 순수 검증 로직만 담는다(외부 의존 없음). |
| 3 | 3 |
* |
| 4 |
- * 시안(ADM_ITM_102_p/103_p) 기준 필수 항목: 유형·아이템ID(등록 전용)·아이템명·카테고리·포인트. |
|
| 5 |
- * 설명은 선택이고, 정렬순서는 시안에 별도 필수(*) 표시가 없다 — 다만 화면이 항상 기본값(현재 |
|
| 6 |
- * 유형의 총 등록 개수+1, 또는 수정 시 기존 값)을 채워 제출하므로 실질적으로 비어 있는 경우가 |
|
| 7 |
- * 없고, 그래도 숫자 형식은 여기서 검증한다(Server Action은 UI를 거치지 않고 직접 호출될 수 |
|
| 8 |
- * 있다 — 화면의 기본값 채움은 편의일 뿐 신뢰 경계가 아니다). |
|
| 4 |
+ * 시안(ADM_ITM_102_p/103_p) 기준 필수 항목: 유형·아이템명·카테고리·포인트·썸네일 이미지. |
|
| 5 |
+ * 설명은 선택이고, 정렬순서는 별도 필수(*) 표시가 없다 — 다만 화면이 항상 기본값을 채워 |
|
| 6 |
+ * 제출하므로 실질적으로 비는 경우가 없고, 그래도 숫자 형식은 여기서 검증한다(Server Action은 |
|
| 7 |
+ * UI를 거치지 않고 직접 호출될 수 있다 — 화면의 기본값 채움은 편의일 뿐 신뢰 경계가 아니다). |
|
| 9 | 8 |
* |
| 10 |
- * **이 파일이 검증의 단일 진실원천이다.** Server Action(`_actions.ts`)이 저장 직전에 여기를 |
|
| 11 |
- * 거친다. |
|
| 9 |
+ * **아이템ID는 입력 항목이 아니다** — 시안이 "등록 후 자동발급됩니다(Read Only)"로 명시하고, |
|
| 10 |
+ * 백엔드도 `itemSn`을 자동증가로 채운다. 그래서 등록·수정의 검증 대상이 동일하다(예전 mock |
|
| 11 |
+ * 구현은 사용자가 직접 입력하는 값이었으나 실제 백엔드에는 그런 컬럼이 없다). |
|
| 12 | 12 |
* |
| 13 |
- * 아이템ID는 백엔드 코드 체계가 없어(사용자 확정 — 이 도메인 전체가 mock) 시안 예시 |
|
| 14 |
- * (`ITEM-HAT-002`)를 참고한 느슨한 형식만 강제한다: 영문/숫자 세그먼트를 하이픈으로 구분. |
|
| 15 |
- * |
|
| 16 |
- * **등록과 수정의 검증을 분리한 이유**: 수정 팝업에서 아이템ID는 읽기 전용이다(시안 |
|
| 17 |
- * ADM_ITM_103_p). 수정은 실제로 바뀔 수 있는 항목(유형/아이템명/카테고리/포인트/설명/사용여부/ |
|
| 18 |
- * 정렬순서)만 검증한다. |
|
| 13 |
+ * **이 파일이 검증의 단일 진실원천이다.** Server Action(`_actions.ts`)이 저장 직전에 여기를 거친다. |
|
| 19 | 14 |
*/ |
| 20 | 15 |
|
| 21 | 16 |
import {
|
| 22 | 17 |
DECORATION_ITEM_CATEGORIES, |
| 23 | 18 |
DECORATION_ITEM_TYPE_OPTIONS, |
| 24 |
- type DecorationItemCategory, |
|
| 25 | 19 |
type DecorationItemType, |
| 26 | 20 |
} from '@/lib/domain/decoration-item'; |
| 27 | 21 |
|
| 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 | 22 |
const NAME_MAX_LENGTH = 100; |
| 35 | 23 |
const DESCRIPTION_MAX_LENGTH = 500; |
| 36 | 24 |
|
| 37 |
-/** 등록·수정 양쪽에서 실제로 바뀔 수 있는 항목. */ |
|
| 25 |
+/** 등록·수정 양쪽에서 실제로 바뀔 수 있는 항목(= 백엔드 쓰기 API가 받는 항목). */ |
|
| 38 | 26 |
export type DecorationItemEditableValues = {
|
| 39 | 27 |
itemType: DecorationItemType; |
| 40 | 28 |
name: string; |
| 41 |
- category: DecorationItemCategory; |
|
| 29 |
+ categoryCode: string; |
|
| 42 | 30 |
points: number; |
| 43 | 31 |
description: string; |
| 44 | 32 |
isActive: boolean; |
| 45 | 33 |
sortOrder: number; |
| 46 |
-}; |
|
| 47 |
- |
|
| 48 |
-/** 등록은 위 항목에 더해 아이템ID를 입력받는다(수정에서는 읽기 전용). */ |
|
| 49 |
-export type DecorationItemCreateValues = DecorationItemEditableValues & {
|
|
| 50 |
- itemId: string; |
|
| 34 |
+ /** |
|
| 35 |
+ * 업로드된 이미지의 파일 ID. 등록 시에는 새로 업로드한 값, 수정 시에는 새로 올리지 않았다면 |
|
| 36 |
+ * 기존 값이 그대로 들어온다(화면이 hidden 필드로 유지한다). |
|
| 37 |
+ */ |
|
| 38 |
+ imageFileId: string; |
|
| 51 | 39 |
}; |
| 52 | 40 |
|
| 53 | 41 |
/** 필드별 오류 메시지 — 키는 폼 필드 이름과 일치시켜 화면이 그대로 붙여 쓸 수 있게 한다. */ |
| 54 | 42 |
export type DecorationItemFormErrors = Partial< |
| 55 |
- Record<keyof DecorationItemCreateValues, string> |
|
| 43 |
+ Record<keyof DecorationItemEditableValues, string> |
|
| 56 | 44 |
>; |
| 57 | 45 |
|
| 58 | 46 |
export type ValidationResult<T> = |
... | ... | @@ -60,13 +48,13 @@ |
| 60 | 48 |
| { ok: false; errors: DecorationItemFormErrors };
|
| 61 | 49 |
|
| 62 | 50 |
/** |
| 63 |
- * 등록/수정 Server Action의 `useActionState` 결과 상태. 원래는 admins처럼 `_actions.ts` |
|
| 64 |
- * (`'use server'` 파일)에 두려 했지만, Next.js는 **`'use server'` 파일이 async 함수 외의 |
|
| 65 |
- * 값을 export하는 것을 런타임에 거부한다**("A 'use server' file can only export async
|
|
| 66 |
- * functions, found object" — `INITIAL_DECORATION_ITEM_FORM_STATE`처럼 일반 객체 상수를 |
|
| 67 |
- * 함께 export하면 그 파일의 Server Action을 호출하는 즉시 500으로 깨진다. 빌드/타입체크는 |
|
| 68 |
- * 통과하고 실제로 폼을 제출해야만 드러나는 런타임 전용 제약이라 여기로 옮겼다 — domain |
|
| 69 |
- * 계층은 `'use server'`가 없어 값 export에 제약이 없다). |
|
| 51 |
+ * 등록/수정 Server Action의 `useActionState` 결과 상태. 원래는 `_actions.ts`(`'use server'` 파일)에 |
|
| 52 |
+ * 두려 했지만, Next.js는 **`'use server'` 파일이 async 함수 외의 값을 export하는 것을 런타임에 |
|
| 53 |
+ * 거부한다**("A 'use server' file can only export async functions, found object" —
|
|
| 54 |
+ * `INITIAL_DECORATION_ITEM_FORM_STATE`처럼 일반 객체 상수를 함께 export하면 그 파일의 Server |
|
| 55 |
+ * Action을 호출하는 즉시 500으로 깨진다. 빌드/타입체크는 통과하고 실제로 폼을 제출해야만 |
|
| 56 |
+ * 드러나는 런타임 전용 제약이라 여기로 옮겼다 — domain 계층은 `'use server'`가 없어 값 export에 |
|
| 57 |
+ * 제약이 없다). |
|
| 70 | 58 |
*/ |
| 71 | 59 |
export type DecorationItemFormState = |
| 72 | 60 |
| { status: 'idle' }
|
... | ... | @@ -81,33 +69,12 @@ |
| 81 | 69 |
return DECORATION_ITEM_TYPE_OPTIONS.some((option) => option.value === value); |
| 82 | 70 |
} |
| 83 | 71 |
|
| 84 |
-function isDecorationItemCategory( |
|
| 85 |
- value: string |
|
| 86 |
-): value is DecorationItemCategory {
|
|
| 87 |
- return (DECORATION_ITEM_CATEGORIES as readonly string[]).includes(value); |
|
| 72 |
+function isDecorationItemCategoryCode(value: string): boolean {
|
|
| 73 |
+ return DECORATION_ITEM_CATEGORIES.some((category) => category.code === value); |
|
| 88 | 74 |
} |
| 89 | 75 |
|
| 90 | 76 |
/** |
| 91 |
- * 아이템ID 형식 검증 — 문제가 있으면 안내 문구, 없으면 null. 중복 확인은 여기서 하지 않는다 |
|
| 92 |
- * (Repository/Server Action의 몫 — `decoration-item-repository.ts` 주석 참조). |
|
| 93 |
- */ |
|
| 94 |
-export function validateDecorationItemId(itemId: string): string | null {
|
|
| 95 |
- const value = itemId.trim(); |
|
| 96 |
- |
|
| 97 |
- if (!value) {
|
|
| 98 |
- return '아이템 ID를 입력해 주세요.'; |
|
| 99 |
- } |
|
| 100 |
- if (value.length < ITEM_ID_MIN_LENGTH || value.length > ITEM_ID_MAX_LENGTH) {
|
|
| 101 |
- return `아이템 ID는 ${ITEM_ID_MIN_LENGTH}~${ITEM_ID_MAX_LENGTH}자로 입력해 주세요.`;
|
|
| 102 |
- } |
|
| 103 |
- if (!ITEM_ID_PATTERN.test(value)) {
|
|
| 104 |
- return DECORATION_ITEM_ID_HELP_TEXT; |
|
| 105 |
- } |
|
| 106 |
- return null; |
|
| 107 |
-} |
|
| 108 |
- |
|
| 109 |
-/** |
|
| 110 |
- * 등록·수정 공통 항목 검증. 오류는 넘겨받은 객체에 채워 넣고, 정규화된 값을 돌려준다. |
|
| 77 |
+ * 등록·수정 공통 검증. 오류는 넘겨받은 객체에 채워 넣고, 정규화된 값을 돌려준다. |
|
| 111 | 78 |
* |
| 112 | 79 |
* `points`/`sortOrder`는 `_actions.ts`가 FormData 문자열을 숫자로 변환해 넘긴다 — 빈 문자열을 |
| 113 | 80 |
* `Number.isInteger`가 걸러낼 수 있도록 그 변환은 `Number('')`가 아니라 명시적으로 실패시키는
|
... | ... | @@ -131,8 +98,8 @@ |
| 131 | 98 |
errors.name = `아이템명은 ${NAME_MAX_LENGTH}자 이내로 입력해 주세요.`;
|
| 132 | 99 |
} |
| 133 | 100 |
|
| 134 |
- if (!isDecorationItemCategory(values.category)) {
|
|
| 135 |
- errors.category = '카테고리를 선택해 주세요.'; |
|
| 101 |
+ if (!isDecorationItemCategoryCode(values.categoryCode)) {
|
|
| 102 |
+ errors.categoryCode = '카테고리를 선택해 주세요.'; |
|
| 136 | 103 |
} |
| 137 | 104 |
|
| 138 | 105 |
if (!Number.isInteger(values.points) || values.points < 0) {
|
... | ... | @@ -147,31 +114,17 @@ |
| 147 | 114 |
errors.sortOrder = '정렬순서는 1 이상의 숫자로 입력해 주세요.'; |
| 148 | 115 |
} |
| 149 | 116 |
|
| 117 |
+ // 시안이 썸네일 이미지를 필수(*)로 표시한다. 업로드 자체의 실패는 Server Action이 별도 |
|
| 118 |
+ // 메시지로 처리하고, 여기서는 "결과 파일 ID가 없는 상태"만 잡는다. |
|
| 119 |
+ if (!values.imageFileId.trim()) {
|
|
| 120 |
+ errors.imageFileId = '썸네일 이미지를 등록해 주세요.'; |
|
| 121 |
+ } |
|
| 122 |
+ |
|
| 150 | 123 |
return { ...values, name, description };
|
| 151 | 124 |
} |
| 152 | 125 |
|
| 153 |
-/** 시안 ADM_ITM_102_p — 등록 검증(아이템ID 포함). */ |
|
| 126 |
+/** 시안 ADM_ITM_102_p — 등록 검증. */ |
|
| 154 | 127 |
export function validateDecorationItemCreate( |
| 155 |
- values: DecorationItemCreateValues |
|
| 156 |
-): ValidationResult<DecorationItemCreateValues> {
|
|
| 157 |
- const errors: DecorationItemFormErrors = {};
|
|
| 158 |
- |
|
| 159 |
- const itemIdError = validateDecorationItemId(values.itemId); |
|
| 160 |
- if (itemIdError) {
|
|
| 161 |
- errors.itemId = itemIdError; |
|
| 162 |
- } |
|
| 163 |
- |
|
| 164 |
- const editable = validateEditableValues(values, errors); |
|
| 165 |
- |
|
| 166 |
- if (Object.keys(errors).length > 0) {
|
|
| 167 |
- return { ok: false, errors };
|
|
| 168 |
- } |
|
| 169 |
- |
|
| 170 |
- return { ok: true, values: { ...editable, itemId: values.itemId.trim() } };
|
|
| 171 |
-} |
|
| 172 |
- |
|
| 173 |
-/** 시안 ADM_ITM_103_p — 수정 검증. 아이템ID는 읽기 전용이라 검증 대상이 아니다(파일 상단 주석). */ |
|
| 174 |
-export function validateDecorationItemUpdate( |
|
| 175 | 128 |
values: DecorationItemEditableValues |
| 176 | 129 |
): ValidationResult<DecorationItemEditableValues> {
|
| 177 | 130 |
const errors: DecorationItemFormErrors = {};
|
... | ... | @@ -183,3 +136,10 @@ |
| 183 | 136 |
|
| 184 | 137 |
return { ok: true, values: editable };
|
| 185 | 138 |
} |
| 139 |
+ |
|
| 140 |
+/** 시안 ADM_ITM_103_p — 수정 검증. 아이템ID는 읽기 전용이라 검증 대상이 아니다. */ |
|
| 141 |
+export function validateDecorationItemUpdate( |
|
| 142 |
+ values: DecorationItemEditableValues |
|
| 143 |
+): ValidationResult<DecorationItemEditableValues> {
|
|
| 144 |
+ return validateDecorationItemCreate(values); |
|
| 145 |
+} |
--- lib/domain/decoration-item.ts
+++ lib/domain/decoration-item.ts
... | ... | @@ -1,17 +1,18 @@ |
| 1 | 1 |
/** |
| 2 | 2 |
* 꾸미기 아이템 도메인 타입 — 순수 데이터 표현, 외부 의존 없음. |
| 3 | 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`은 순수하게 "값 없음"이다). |
|
| 4 |
+ * 데이터 출처는 백엔드(edupay-backend)의 `/api/v1/mngr/item/**`(ROLE_ADMIN 전용)이며, |
|
| 5 |
+ * `lib/data/repositories/decoration-item-repository.ts`가 응답을 이 타입으로 매핑한다. |
|
| 6 |
+ * 백엔드 테이블은 `TB_COM_ITEM`이다. |
|
| 9 | 7 |
*/ |
| 10 | 8 |
|
| 11 | 9 |
/** |
| 12 | 10 |
* 유형 — 개별아이템 / 셋트아이템. 시안(ADM_ITM_101, ADM_ITM_102_p) 두 곳 모두 "셋트아이템도 |
| 13 | 11 |
* 동일한 항목으로 처리하며 별도 화면이 없다"고 명시한다 — 목록의 유형 탭과 등록/수정 폼의 값이 |
| 14 | 12 |
* 모두 이 타입 하나를 공유한다. |
| 13 |
+ * |
|
| 14 |
+ * 값 자체는 화면·URL용 표현이고, 백엔드 코드값(개별 `N` / 셋트 `S`, 사용자 확정 사항)으로의 |
|
| 15 |
+ * 변환은 Repository가 담당한다 — URL(`?itemType=set`)을 읽을 수 있게 유지하기 위해서다. |
|
| 15 | 16 |
*/ |
| 16 | 17 |
export type DecorationItemType = 'individual' | 'set'; |
| 17 | 18 |
|
... | ... | @@ -26,37 +27,60 @@ |
| 26 | 27 |
export const DEFAULT_DECORATION_ITEM_TYPE: DecorationItemType = 'individual'; |
| 27 | 28 |
|
| 28 | 29 |
/** |
| 29 |
- * 카테고리 — **백엔드 코드테이블이 없어 시안 예시값을 그대로 mock 상수로 둔다**(사용자 확정 |
|
| 30 |
- * 사항. 시안 설명: "추후 확장성을 위해 필요, As is는 단일 카테고리로 운영"). 백엔드에 코드테이블 |
|
| 31 |
- * API가 생기면 이 상수를 지우고 그 응답으로 대체한다. |
|
| 30 |
+ * 카테고리 — **임시 값이다.** 백엔드는 공통코드테이블(`TB_SYS_COM_CD_DTL`, `COM_CD='ITEM_CATE_CD'`)을 |
|
| 31 |
+ * 조인해 `itemCateNm`을 내려주지만 그 코드가 아직 정비되지 않아, 사용자 지시에 따라 프론트에서 |
|
| 32 |
+ * 임의 코드로 개발한다(시안 등록 팝업의 예시값 계절/축하/시즌을 그대로 씀). |
|
| 33 |
+ * |
|
| 34 |
+ * **백엔드에 코드가 추가되면 이 상수를 지우고 `GET /api/v1/common/code/ITEM_CATE_CD`(인증 불필요) |
|
| 35 |
+ * 응답으로 교체한다.** 그때까지 저장되는 `itemCateCd`는 여기 정의된 임시 코드라, 실제 코드 체계가 |
|
| 36 |
+ * 정해지면 기존 데이터의 코드값 마이그레이션이 필요하다. |
|
| 32 | 37 |
*/ |
| 33 |
-export const DECORATION_ITEM_CATEGORIES = ['계절', '축하', '시즌'] as const; |
|
| 34 |
-export type DecorationItemCategory = (typeof DECORATION_ITEM_CATEGORIES)[number]; |
|
| 38 |
+export const DECORATION_ITEM_CATEGORIES: ReadonlyArray<{
|
|
| 39 |
+ code: string; |
|
| 40 |
+ label: string; |
|
| 41 |
+}> = [ |
|
| 42 |
+ { code: 'CATE01', label: '계절' },
|
|
| 43 |
+ { code: 'CATE02', label: '축하' },
|
|
| 44 |
+ { code: 'CATE03', label: '시즌' },
|
|
| 45 |
+]; |
|
| 46 |
+ |
|
| 47 |
+export const DEFAULT_DECORATION_ITEM_CATEGORY_CODE = |
|
| 48 |
+ DECORATION_ITEM_CATEGORIES[0].code; |
|
| 35 | 49 |
|
| 36 | 50 |
export type DecorationItem = {
|
| 37 | 51 |
/** |
| 38 |
- * 아이템ID — 등록 시 사용자가 직접 입력하는 유일 식별자(예: `ITEM-HAT-002`). 백엔드가 없어 |
|
| 39 |
- * 별도 내부 id를 두지 않는다 — 목록 행의 key이자 수정/삭제 Server Action의 입력값이다. |
|
| 40 |
- * 등록 후에는 변경할 수 없다(시안 ADM_ITM_103_p). |
|
| 52 |
+ * 백엔드 `itemSn` — 자동증가 PK이자 화면의 "아이템ID"로 그대로 노출하는 값(사용자 확정 사항). |
|
| 53 |
+ * 시안은 `ITEM-HAT-001` 형식의 자동발급 ID를 그리지만 백엔드에 그런 컬럼이 없다. |
|
| 54 |
+ * 목록 행의 key이자 수정/삭제 API의 경로 변수다. |
|
| 41 | 55 |
*/ |
| 42 |
- itemId: string; |
|
| 56 |
+ itemSn: number; |
|
| 43 | 57 |
itemType: DecorationItemType; |
| 44 | 58 |
name: string; |
| 45 |
- category: DecorationItemCategory; |
|
| 46 |
- /** 오픈 가능한 포인트. */ |
|
| 47 |
- points: number; |
|
| 48 |
- description: string | null; |
|
| 49 |
- /** 사용여부 — 표에서는 O/X로 표기한다(`formatDecorationItemActiveLabel`). */ |
|
| 50 |
- isActive: boolean; |
|
| 59 |
+ /** 백엔드 `itemCateCd` — 저장·전송에 쓰는 코드값. */ |
|
| 60 |
+ categoryCode: string; |
|
| 51 | 61 |
/** |
| 52 |
- * 정렬순서 — 목록 기본 정렬 기준(오름차순, 시안 ADM_ITM_101). **유형(개별/셋트)마다 독립된 |
|
| 53 |
- * 순번 공간이다** — 두 탭이 같은 화면을 재사용할 뿐 사실상 별개 목록이라, 한쪽의 정렬순서가 |
|
| 54 |
- * 다른 쪽 순번에 영향을 주지 않는다(mock 생성기·Repository가 이 규칙을 지킨다). |
|
| 62 |
+ * 백엔드 `itemCateNm` — 공통코드테이블 조인 결과. 코드가 코드테이블에 없으면 null로 온다 |
|
| 63 |
+ * (지금은 임시 코드를 쓰므로 대개 null이다 — 화면은 `formatDecorationItemCategory`로 보완한다). |
|
| 55 | 64 |
*/ |
| 65 |
+ categoryName: string | null; |
|
| 66 |
+ /** 백엔드 `itemAmount` — 오픈 가능한 포인트. */ |
|
| 67 |
+ points: number; |
|
| 68 |
+ /** 백엔드 `itemExplan`. */ |
|
| 69 |
+ description: string | null; |
|
| 70 |
+ /** 백엔드 `useYn`('Y'/'N') — 표에서는 O/X로 표기한다. */
|
|
| 71 |
+ isActive: boolean; |
|
| 72 |
+ /** 백엔드 `sortOrdr` — 목록 기본 정렬 기준(시안 ADM_ITM_101). */ |
|
| 56 | 73 |
sortOrder: number; |
| 57 |
- /** ISO 8601 문자열 — 수정일시. */ |
|
| 58 |
- updatedAt: string; |
|
| 74 |
+ /** 백엔드 `atchFileId` — 업로드된 이미지의 파일 ID. 등록하지 않았으면 null. */ |
|
| 75 |
+ imageFileId: string | null; |
|
| 76 |
+ /** 위 파일 ID로 만든 백엔드 이미지 URL(공개 GET). 파일이 없으면 null. */ |
|
| 77 |
+ imageUrl: string | null; |
|
| 78 |
+ /** 백엔드 `lastMdfcnDt` — `YYYY-MM-DD`(백엔드가 이 형식으로 포맷해 내려준다). 미수정이면 null. */ |
|
| 79 |
+ updatedAt: string | null; |
|
| 59 | 80 |
}; |
| 81 |
+ |
|
| 82 |
+/** 값이 없는 항목의 화면 표기. */ |
|
| 83 |
+export const EMPTY_FIELD_PLACEHOLDER = '-'; |
|
| 60 | 84 |
|
| 61 | 85 |
/** 사용여부 → 화면 표기(시안: O/X). */ |
| 62 | 86 |
export function formatDecorationItemActiveLabel(isActive: boolean): string {
|
... | ... | @@ -69,24 +93,24 @@ |
| 69 | 93 |
} |
| 70 | 94 |
|
| 71 | 95 |
/** |
| 72 |
- * ISO 수정일시 → "YYYY-MM-DD HH:mm". mock이 생성하는 값이 항상 `Date#toISOString()` 형식 |
|
| 73 |
- * (`YYYY-MM-DDTHH:mm:ss.sssZ`)이라 별도 날짜 라이브러리 없이 문자열 절단만으로 충분하다(신규 |
|
| 74 |
- * 의존성 추가 금지 — CLAUDE.md §4.2). |
|
| 96 |
+ * 카테고리 표기 — 백엔드가 코드테이블에서 찾은 이름을 우선 쓰고, 없으면 프론트 임시 목록에서 |
|
| 97 |
+ * 찾고, 그것도 없으면 코드값 자체를 보여준다. 임시 코드 단계에서는 두 번째 경로가 주로 쓰이고, |
|
| 98 |
+ * 백엔드 코드가 정비되면 자연스럽게 첫 번째 경로로 넘어간다. |
|
| 75 | 99 |
*/ |
| 76 |
-export function formatDecorationItemUpdatedAt(updatedAt: string): string {
|
|
| 77 |
- return updatedAt.slice(0, 16).replace('T', ' ');
|
|
| 100 |
+export function formatDecorationItemCategory(item: DecorationItem): string {
|
|
| 101 |
+ if (item.categoryName) {
|
|
| 102 |
+ return item.categoryName; |
|
| 103 |
+ } |
|
| 104 |
+ |
|
| 105 |
+ const known = DECORATION_ITEM_CATEGORIES.find( |
|
| 106 |
+ (category) => category.code === item.categoryCode |
|
| 107 |
+ ); |
|
| 108 |
+ return known?.label ?? item.categoryCode ?? EMPTY_FIELD_PLACEHOLDER; |
|
| 78 | 109 |
} |
| 79 | 110 |
|
| 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(); |
|
| 111 |
+/** 수정일시 표기 — 백엔드가 이미 `YYYY-MM-DD`로 포맷해 주므로 그대로 쓰고 null만 보완한다. */ |
|
| 112 |
+export function formatDecorationItemUpdatedAt( |
|
| 113 |
+ updatedAt: string | null |
|
| 114 |
+): string {
|
|
| 115 |
+ return updatedAt ?? EMPTY_FIELD_PLACEHOLDER; |
|
| 92 | 116 |
} |
--- lib/http/backend-fetch.ts
+++ lib/http/backend-fetch.ts
... | ... | @@ -50,8 +50,22 @@ |
| 50 | 50 |
} |
| 51 | 51 |
|
| 52 | 52 |
type BackendRequestInit = {
|
| 53 |
- method: 'GET' | 'POST'; |
|
| 53 |
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE'; |
|
| 54 |
+ /** JSON 본문. `@RequestBody`로 받는 엔드포인트(예: 로그인)에 쓴다. */ |
|
| 54 | 55 |
body?: unknown; |
| 56 |
+ /** |
|
| 57 |
+ * form 인코딩 본문(`application/x-www-form-urlencoded`). |
|
| 58 |
+ * |
|
| 59 |
+ * 백엔드의 일부 쓰기 API는 `@RequestBody`가 아니라 **`@ParameterObject`(= ModelAttribute |
|
| 60 |
+ * 바인딩)** 로 파라미터를 받는다(예: `POST/PUT /api/v1/mngr/item`). 그런 엔드포인트에 JSON을 |
|
| 61 |
+ * 보내면 바인딩이 하나도 되지 않아 **전 필드가 null인 채로 저장된다** — 400도 나지 않고 |
|
| 62 |
+ * 조용히 빈 레코드가 생기므로, 엔드포인트가 어느 쪽인지 확인하고 맞는 형식을 골라야 한다. |
|
| 63 |
+ * |
|
| 64 |
+ * `undefined`인 값은 전송에서 제외한다(백엔드가 "미전송"과 "빈 문자열"을 다르게 볼 수 있다). |
|
| 65 |
+ */ |
|
| 66 |
+ form?: Record<string, string | number | undefined>; |
|
| 67 |
+ /** multipart 본문. 파일 업로드 전용 — Content-Type은 브라우저/런타임이 boundary와 함께 붙인다. */ |
|
| 68 |
+ multipart?: FormData; |
|
| 55 | 69 |
/** 쿼리 스트링 파라미터. 값은 문자열로 직렬화해 붙인다. */ |
| 56 | 70 |
query?: Record<string, string | number>; |
| 57 | 71 |
/** |
... | ... | @@ -172,22 +186,58 @@ |
| 172 | 186 |
return { ok: true, data: response };
|
| 173 | 187 |
} |
| 174 | 188 |
|
| 189 |
+/** |
|
| 190 |
+ * 본문 형식에 맞는 `body`와 Content-Type을 고른다. 셋 중 하나만 지정된다는 전제이며, 우선순위는 |
|
| 191 |
+ * multipart > form > JSON이다. |
|
| 192 |
+ * |
|
| 193 |
+ * multipart일 때 Content-Type을 **직접 넣지 않는 것이 중요하다** — 직접 넣으면 boundary 파라미터가 |
|
| 194 |
+ * 빠져 서버가 본문을 파싱하지 못한다. FormData를 body로 주면 런타임이 boundary까지 붙여 준다. |
|
| 195 |
+ */ |
|
| 196 |
+function buildRequestBody(init: BackendRequestInit): {
|
|
| 197 |
+ body: BodyInit | undefined; |
|
| 198 |
+ contentType: string | undefined; |
|
| 199 |
+} {
|
|
| 200 |
+ if (init.multipart) {
|
|
| 201 |
+ return { body: init.multipart, contentType: undefined };
|
|
| 202 |
+ } |
|
| 203 |
+ |
|
| 204 |
+ if (init.form) {
|
|
| 205 |
+ const params = new URLSearchParams(); |
|
| 206 |
+ for (const [key, value] of Object.entries(init.form)) {
|
|
| 207 |
+ if (value !== undefined) {
|
|
| 208 |
+ params.set(key, String(value)); |
|
| 209 |
+ } |
|
| 210 |
+ } |
|
| 211 |
+ return {
|
|
| 212 |
+ body: params.toString(), |
|
| 213 |
+ contentType: 'application/x-www-form-urlencoded', |
|
| 214 |
+ }; |
|
| 215 |
+ } |
|
| 216 |
+ |
|
| 217 |
+ return {
|
|
| 218 |
+ body: init.body !== undefined ? JSON.stringify(init.body) : undefined, |
|
| 219 |
+ contentType: 'application/json', |
|
| 220 |
+ }; |
|
| 221 |
+} |
|
| 222 |
+ |
|
| 175 | 223 |
/** 백엔드 REST 호출 단일 진입점. */ |
| 176 | 224 |
export async function backendFetch<T>( |
| 177 | 225 |
path: string, |
| 178 | 226 |
init: BackendRequestInit |
| 179 | 227 |
): Promise<BackendResult<T>> {
|
| 228 |
+ const { body, contentType } = buildRequestBody(init);
|
|
| 229 |
+ |
|
| 180 | 230 |
let response: Response; |
| 181 | 231 |
try {
|
| 182 | 232 |
response = await fetch(resolveUrl(path, init.query), {
|
| 183 | 233 |
method: init.method, |
| 184 | 234 |
headers: {
|
| 185 |
- 'Content-Type': 'application/json', |
|
| 235 |
+ ...(contentType ? { 'Content-Type': contentType } : {}),
|
|
| 186 | 236 |
...(init.accessToken |
| 187 | 237 |
? { Authorization: `Bearer ${init.accessToken}` }
|
| 188 | 238 |
: {}),
|
| 189 | 239 |
}, |
| 190 |
- body: init.body !== undefined ? JSON.stringify(init.body) : undefined, |
|
| 240 |
+ body, |
|
| 191 | 241 |
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), |
| 192 | 242 |
cache: init.cache, |
| 193 | 243 |
next: init.next, |
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?