feat: 카테고리를 백엔드 공통코드로 — 프론트 임시 코드 제거
`GET /api/v1/mngr/code/list/ITEM_CATE_CD`(사용자 안내)로 카테고리를 가져온다. 종전에는 코드테이블이 정비되기 전이라 프론트에 임시 코드(CATE01 계절 / CATE02 축하 / CATE03 시즌)를 두고 있었다 — 그 상수를 지웠다. - lib/domain/common-code.ts — 코드 그룹 도메인. 코드 그룹 ID를 한곳에 모아 화면이 문자열을 직접 적지 않게 한다. - lib/data/repositories/common-code-repository.ts — 조회. `cache()`로 감싸 한 요청 안에서 화면(선택지 그리기)과 Server Action(입력값 검증)이 같은 목록을 한 번만 받아 쓰게 한다. 그리는 데 쓴 목록과 검증에 쓴 목록이 반드시 같아진다는 점이 중요하다. - 정렬은 백엔드 순서를 그대로 쓴다 — SQL이 아이템 목록과 같은 이중 역순 패턴이라 화면에서 다시 정렬하면 다른 화면과 어긋난다. - 코드 한 줄이 깨져도(코드값 없음) 그 줄만 건너뛴다. 목록 조회의 fail-fast와 다른 판단인데, 선택지 하나 때문에 화면 전체를 못 쓰게 만들 이유가 없기 때문이다. 검증도 상수 대신 조회 결과를 쓴다. `validateDecorationItemCreate/Update`가 허용 코드 목록을 **인자로** 받고(domain 계층은 통신을 하지 않는다), Server Action이 저장 직전에 다시 조회해 넘긴다 — 화면이 보낸 코드를 그대로 믿으면 코드테이블에 없는 값이 저장된다. 카테고리 표기도 단순해졌다. 목록 SQL이 코드테이블을 조인해 `itemCateNm`을 내려 주므로 프론트 목록에서 이름을 찾던 두 번째 경로가 사라지고, 코드가 지워졌을 때만 코드값으로 떨어진다. Co-Authored-By: Claude Opus 5
@c0243a16d2705abc74125728b0652b5c374547a0
--- app/(protected)/(basic)/decoration-items/_actions.ts
+++ app/(protected)/(basic)/decoration-items/_actions.ts
... | ... | @@ -2,12 +2,14 @@ |
| 2 | 2 |
|
| 3 | 3 |
import { revalidatePath } from 'next/cache';
|
| 4 | 4 |
import { verifySession } from '@/lib/auth/dal';
|
| 5 |
+import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository';
|
|
| 5 | 6 |
import {
|
| 6 | 7 |
createDecorationItem, |
| 7 | 8 |
deleteDecorationItem, |
| 8 | 9 |
updateDecorationItem, |
| 9 | 10 |
uploadDecorationItemImage, |
| 10 | 11 |
} from '@/lib/data/repositories/decoration-item-repository'; |
| 12 |
+import { CODE_GROUP } from '@/lib/domain/common-code';
|
|
| 11 | 13 |
import type { DecorationItemType } from '@/lib/domain/decoration-item';
|
| 12 | 14 |
import {
|
| 13 | 15 |
validateDecorationItemCreate, |
... | ... | @@ -24,7 +26,8 @@ |
| 24 | 26 |
* POST될 수 있어 이 확인이 유일한 최종 방어선이다(설계서 §3 SRP 체크). |
| 25 | 27 |
* |
| 26 | 28 |
* 검증은 화면이 아니라 여기서 확정한다(`lib/domain/decoration-item-form.ts`의 규칙을 호출) — |
| 27 |
- * 화면의 required 속성·기본값 채움은 편의일 뿐 신뢰 경계가 아니다. |
|
| 29 |
+ * 화면의 required 속성·기본값 채움은 편의일 뿐 신뢰 경계가 아니다. **허용 카테고리도 여기서 |
|
| 30 |
+ * 다시 조회해 넘긴다** — 화면이 보낸 코드를 그대로 믿으면 코드테이블에 없는 값이 저장된다. |
|
| 28 | 31 |
* |
| 29 | 32 |
* **이미지 업로드도 이 계층을 지난다** — 브라우저는 백엔드를 직접 호출하지 않으므로(토큰이 |
| 30 | 33 |
* httpOnly 세션 안에만 있다) 폼이 실어 보낸 File을 여기서 받아 Repository를 통해 백엔드에 |
... | ... | @@ -119,7 +122,8 @@ |
| 119 | 122 |
readEditableValues( |
| 120 | 123 |
formData, |
| 121 | 124 |
newFile ? PENDING_UPLOAD_PLACEHOLDER : currentImageFileId |
| 122 |
- ) |
|
| 125 |
+ ), |
|
| 126 |
+ await fetchCommonCodes(CODE_GROUP.decorationItemCategory) |
|
| 123 | 127 |
); |
| 124 | 128 |
if (!validation.ok) {
|
| 125 | 129 |
return { status: 'error', errors: validation.errors };
|
... | ... | @@ -162,7 +166,8 @@ |
| 162 | 166 |
readEditableValues( |
| 163 | 167 |
formData, |
| 164 | 168 |
newFile ? PENDING_UPLOAD_PLACEHOLDER : currentImageFileId |
| 165 |
- ) |
|
| 169 |
+ ), |
|
| 170 |
+ await fetchCommonCodes(CODE_GROUP.decorationItemCategory) |
|
| 166 | 171 |
); |
| 167 | 172 |
if (!validation.ok) {
|
| 168 | 173 |
return { status: 'error', errors: validation.errors };
|
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-create-modal.tsx
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-create-modal.tsx
... | ... | @@ -6,6 +6,7 @@ |
| 6 | 6 |
import { FoxInput } from '@fox/core/components/fox-input';
|
| 7 | 7 |
import { FoxModal } from '@fox/core/components/fox-modal';
|
| 8 | 8 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 9 |
+import type { CommonCode } from '@/lib/domain/common-code';
|
|
| 9 | 10 |
import type { DecorationItemType } from '@/lib/domain/decoration-item';
|
| 10 | 11 |
import { INITIAL_DECORATION_ITEM_FORM_STATE } from '@/lib/domain/decoration-item-form';
|
| 11 | 12 |
import { createDecorationItemAction } from '../_actions';
|
... | ... | @@ -15,6 +16,8 @@ |
| 15 | 16 |
/** 등록 버튼을 누른 시점의 활성 탭(유형) — 폼의 유형 기본값으로 쓴다. */ |
| 16 | 17 |
defaultItemType: DecorationItemType; |
| 17 | 18 |
typeTotalCount: number; |
| 19 |
+ /** 공통코드 `ITEM_CATE_CD` — 카테고리 선택지. */ |
|
| 20 |
+ categories: CommonCode[]; |
|
| 18 | 21 |
onClose: () => void; |
| 19 | 22 |
} |
| 20 | 23 |
|
... | ... | @@ -32,6 +35,7 @@ |
| 32 | 35 |
export function DecorationItemCreateModal({
|
| 33 | 36 |
defaultItemType, |
| 34 | 37 |
typeTotalCount, |
| 38 |
+ categories, |
|
| 35 | 39 |
onClose, |
| 36 | 40 |
}: DecorationItemCreateModalProps) {
|
| 37 | 41 |
const { showToast } = useFeedback();
|
... | ... | @@ -73,6 +77,7 @@ |
| 73 | 77 |
<DecorationItemFormFields |
| 74 | 78 |
defaultItemType={defaultItemType}
|
| 75 | 79 |
typeTotalCount={typeTotalCount}
|
| 80 |
+ categories={categories}
|
|
| 76 | 81 |
errors={errors}
|
| 77 | 82 |
itemIdField={
|
| 78 | 83 |
<FoxInput |
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-edit-modal.tsx
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-edit-modal.tsx
... | ... | @@ -6,6 +6,7 @@ |
| 6 | 6 |
import { FoxInput } from '@fox/core/components/fox-input';
|
| 7 | 7 |
import { FoxModal } from '@fox/core/components/fox-modal';
|
| 8 | 8 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 9 |
+import type { CommonCode } from '@/lib/domain/common-code';
|
|
| 9 | 10 |
import type { DecorationItem } from '@/lib/domain/decoration-item';
|
| 10 | 11 |
import { INITIAL_DECORATION_ITEM_FORM_STATE } from '@/lib/domain/decoration-item-form';
|
| 11 | 12 |
import { updateDecorationItemAction } from '../_actions';
|
... | ... | @@ -14,6 +15,8 @@ |
| 14 | 15 |
interface DecorationItemEditModalProps {
|
| 15 | 16 |
item: DecorationItem; |
| 16 | 17 |
typeTotalCount: number; |
| 18 |
+ /** 공통코드 `ITEM_CATE_CD` — 카테고리 선택지. */ |
|
| 19 |
+ categories: CommonCode[]; |
|
| 17 | 20 |
onClose: () => void; |
| 18 | 21 |
} |
| 19 | 22 |
|
... | ... | @@ -28,6 +31,7 @@ |
| 28 | 31 |
export function DecorationItemEditModal({
|
| 29 | 32 |
item, |
| 30 | 33 |
typeTotalCount, |
| 34 |
+ categories, |
|
| 31 | 35 |
onClose, |
| 32 | 36 |
}: DecorationItemEditModalProps) {
|
| 33 | 37 |
const { showToast } = useFeedback();
|
... | ... | @@ -72,6 +76,7 @@ |
| 72 | 76 |
item={item}
|
| 73 | 77 |
defaultItemType={item.itemType}
|
| 74 | 78 |
typeTotalCount={typeTotalCount}
|
| 79 |
+ categories={categories}
|
|
| 75 | 80 |
errors={errors}
|
| 76 | 81 |
itemIdField={
|
| 77 | 82 |
<FoxInput |
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-form-fields.tsx
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-form-fields.tsx
... | ... | @@ -9,10 +9,9 @@ |
| 9 | 9 |
import { FoxTab } from '@fox/core/components/fox-tab';
|
| 10 | 10 |
import { FoxTabItem } from '@fox/core/components/fox-tab-item';
|
| 11 | 11 |
import { FoxToggleSwitch } from '@fox/core/components/fox-toggle-switch';
|
| 12 |
+import type { CommonCode } from '@/lib/domain/common-code';
|
|
| 12 | 13 |
import {
|
| 13 |
- DECORATION_ITEM_CATEGORIES, |
|
| 14 | 14 |
DECORATION_ITEM_TYPE_OPTIONS, |
| 15 |
- DEFAULT_DECORATION_ITEM_CATEGORY_CODE, |
|
| 16 | 15 |
formatDecorationItemActiveLabel, |
| 17 | 16 |
type DecorationItem, |
| 18 | 17 |
type DecorationItemType, |
... | ... | @@ -27,6 +26,8 @@ |
| 27 | 26 |
defaultItemType: DecorationItemType; |
| 28 | 27 |
/** 현재 유형의 전체 등록 건수(검색어 무관) — 정렬순서의 "/ 총 N개"와 기본값에 쓴다. */ |
| 29 | 28 |
typeTotalCount: number; |
| 29 |
+ /** 공통코드 `ITEM_CATE_CD` — 카테고리 선택지. 서버에서 조회해 내려온다. */ |
|
| 30 |
+ categories: CommonCode[]; |
|
| 30 | 31 |
errors: DecorationItemFormErrors; |
| 31 | 32 |
/** 아이템ID 줄 — 등록은 자동발급 안내, 수정은 발급된 값이라 팝업마다 달라 밖에서 받는다. */ |
| 32 | 33 |
itemIdField: ReactNode; |
... | ... | @@ -54,14 +55,17 @@ |
| 54 | 55 |
item, |
| 55 | 56 |
defaultItemType, |
| 56 | 57 |
typeTotalCount, |
| 58 |
+ categories, |
|
| 57 | 59 |
errors, |
| 58 | 60 |
itemIdField, |
| 59 | 61 |
}: DecorationItemFormFieldsProps) {
|
| 60 | 62 |
const [itemType, setItemType] = useState<DecorationItemType>( |
| 61 | 63 |
item?.itemType ?? defaultItemType |
| 62 | 64 |
); |
| 65 |
+ // 수정 팝업은 기존 값, 등록 팝업은 첫 코드가 기본이다. 코드가 하나도 없으면 빈 값이고, |
|
| 66 |
+ // 그대로 저장하면 Server Action의 검증이 "카테고리를 선택해 주세요."로 잡는다. |
|
| 63 | 67 |
const [categoryCode, setCategoryCode] = useState( |
| 64 |
- item?.categoryCode || DEFAULT_DECORATION_ITEM_CATEGORY_CODE |
|
| 68 |
+ item?.categoryCode || categories[0]?.code || '' |
|
| 65 | 69 |
); |
| 66 | 70 |
const [isActive, setIsActive] = useState(item?.isActive ?? true); |
| 67 | 71 |
// 수정 팝업에서 파일을 새로 고르지 않았을 때 유지할 기존 이미지. 썸네일을 지우면 함께 비워 |
... | ... | @@ -109,10 +113,11 @@ |
| 109 | 113 |
<FoxSelect |
| 110 | 114 |
size="md" |
| 111 | 115 |
label="카테고리" |
| 112 |
- options={DECORATION_ITEM_CATEGORIES.map((category) => ({
|
|
| 116 |
+ options={categories.map((category) => ({
|
|
| 113 | 117 |
value: category.code, |
| 114 | 118 |
label: category.label, |
| 115 | 119 |
}))} |
| 120 |
+ placeholder="카테고리를 선택하세요." |
|
| 116 | 121 |
value={categoryCode}
|
| 117 | 122 |
onValueChange={setCategoryCode}
|
| 118 | 123 |
error={Boolean(errors.categoryCode)}
|
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-list.tsx
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-list.tsx
... | ... | @@ -12,6 +12,7 @@ |
| 12 | 12 |
import { FoxTab } from '@fox/core/components/fox-tab';
|
| 13 | 13 |
import { FoxTabItem } from '@fox/core/components/fox-tab-item';
|
| 14 | 14 |
import { FoxPlusIcon } from '@fox/core/icons';
|
| 15 |
+import type { CommonCode } from '@/lib/domain/common-code';
|
|
| 15 | 16 |
import {
|
| 16 | 17 |
DECORATION_ITEM_TYPE_OPTIONS, |
| 17 | 18 |
EMPTY_FIELD_PLACEHOLDER, |
... | ... | @@ -43,6 +44,8 @@ |
| 43 | 44 |
totalCount: number; |
| 44 | 45 |
/** 현재 유형(개별/셋트)의 검색어 무관 전체 건수 — 등록·수정 팝업의 정렬순서 힌트에 쓴다. */ |
| 45 | 46 |
typeTotalCount: number; |
| 47 |
+ /** 공통코드 `ITEM_CATE_CD` — 등록·수정 팝업의 카테고리 선택지. */ |
|
| 48 |
+ categories: CommonCode[]; |
|
| 46 | 49 |
} |
| 47 | 50 |
|
| 48 | 51 |
/** |
... | ... | @@ -67,6 +70,7 @@ |
| 67 | 70 |
totalPages, |
| 68 | 71 |
totalCount, |
| 69 | 72 |
typeTotalCount, |
| 73 |
+ categories, |
|
| 70 | 74 |
}: DecorationItemListProps) {
|
| 71 | 75 |
const router = useRouter(); |
| 72 | 76 |
const [isCreateOpen, setIsCreateOpen] = useState(false); |
... | ... | @@ -140,7 +144,11 @@ |
| 140 | 144 |
header: '관리', |
| 141 | 145 |
width: 160, |
| 142 | 146 |
render: (row) => ( |
| 143 |
- <DecorationItemRowActions item={row} typeTotalCount={typeTotalCount} />
|
|
| 147 |
+ <DecorationItemRowActions |
|
| 148 |
+ item={row}
|
|
| 149 |
+ typeTotalCount={typeTotalCount}
|
|
| 150 |
+ categories={categories}
|
|
| 151 |
+ /> |
|
| 144 | 152 |
), |
| 145 | 153 |
}, |
| 146 | 154 |
]; |
... | ... | @@ -226,6 +234,7 @@ |
| 226 | 234 |
<DecorationItemCreateModal |
| 227 | 235 |
defaultItemType={query.itemType}
|
| 228 | 236 |
typeTotalCount={typeTotalCount}
|
| 237 |
+ categories={categories}
|
|
| 229 | 238 |
onClose={() => setIsCreateOpen(false)}
|
| 230 | 239 |
/> |
| 231 | 240 |
)} |
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-row-actions.tsx
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-row-actions.tsx
... | ... | @@ -6,6 +6,7 @@ |
| 6 | 6 |
import { FoxIconButton } from '@fox/core/components/fox-icon-button';
|
| 7 | 7 |
import { FoxPencilSimpleIcon, FoxTrashIcon } from '@fox/core/icons';
|
| 8 | 8 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 9 |
+import type { CommonCode } from '@/lib/domain/common-code';
|
|
| 9 | 10 |
import type { DecorationItem } from '@/lib/domain/decoration-item';
|
| 10 | 11 |
import { deleteDecorationItemAction } from '../_actions';
|
| 11 | 12 |
import { DecorationItemEditModal } from './decoration-item-edit-modal';
|
... | ... | @@ -14,6 +15,7 @@ |
| 14 | 15 |
item: DecorationItem; |
| 15 | 16 |
/** 수정 팝업의 "/ 총 N개" 힌트로 그대로 흘려보낸다(현재 유형 전체 등록 건수). */ |
| 16 | 17 |
typeTotalCount: number; |
| 18 |
+ categories: CommonCode[]; |
|
| 17 | 19 |
} |
| 18 | 20 |
|
| 19 | 21 |
/** |
... | ... | @@ -26,6 +28,7 @@ |
| 26 | 28 |
export function DecorationItemRowActions({
|
| 27 | 29 |
item, |
| 28 | 30 |
typeTotalCount, |
| 31 |
+ categories, |
|
| 29 | 32 |
}: DecorationItemRowActionsProps) {
|
| 30 | 33 |
const { showAlert, hideAlert, showToast } = useFeedback();
|
| 31 | 34 |
const [isEditOpen, setIsEditOpen] = useState(false); |
... | ... | @@ -84,6 +87,7 @@ |
| 84 | 87 |
<DecorationItemEditModal |
| 85 | 88 |
item={item}
|
| 86 | 89 |
typeTotalCount={typeTotalCount}
|
| 90 |
+ categories={categories}
|
|
| 87 | 91 |
onClose={() => setIsEditOpen(false)}
|
| 88 | 92 |
/> |
| 89 | 93 |
)} |
--- app/(protected)/(basic)/decoration-items/page.tsx
+++ app/(protected)/(basic)/decoration-items/page.tsx
... | ... | @@ -1,6 +1,8 @@ |
| 1 | 1 |
import type { Metadata } from 'next';
|
| 2 | 2 |
import { verifySession } from '@/lib/auth/dal';
|
| 3 |
+import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository';
|
|
| 3 | 4 |
import { fetchDecorationItems } from '@/lib/data/repositories/decoration-item-repository';
|
| 5 |
+import { CODE_GROUP } from '@/lib/domain/common-code';
|
|
| 4 | 6 |
import { parseDecorationItemQuery } from '@/lib/domain/decoration-item-query';
|
| 5 | 7 |
import { DecorationItemList } from './_components/decoration-item-list';
|
| 6 | 8 |
|
... | ... | @@ -27,8 +29,12 @@ |
| 27 | 29 |
await verifySession(); |
| 28 | 30 |
|
| 29 | 31 |
const query = parseDecorationItemQuery(await searchParams); |
| 30 |
- const { items, totalCount, isTotalCountExact, typeTotalCount } =
|
|
| 31 |
- await fetchDecorationItems(query); |
|
| 32 |
+ // 목록과 카테고리는 서로를 기다릴 이유가 없어 함께 띄운다. |
|
| 33 |
+ const [{ items, totalCount, isTotalCountExact, typeTotalCount }, categories] =
|
|
| 34 |
+ await Promise.all([ |
|
| 35 |
+ fetchDecorationItems(query), |
|
| 36 |
+ fetchCommonCodes(CODE_GROUP.decorationItemCategory), |
|
| 37 |
+ ]); |
|
| 32 | 38 |
|
| 33 | 39 |
// 전체 건수가 확정되지 않았다면(백엔드가 count를 주지 않아 하한값만 아는 상태) 다음 페이지를 |
| 34 | 40 |
// 한 칸 열어 둔다 — 열어 두지 않으면 가득 찬 페이지 뒤의 데이터에 접근할 방법이 없어진다 |
... | ... | @@ -48,6 +54,7 @@ |
| 48 | 54 |
totalPages={totalPages}
|
| 49 | 55 |
totalCount={totalCount}
|
| 50 | 56 |
typeTotalCount={typeTotalCount}
|
| 57 |
+ categories={categories}
|
|
| 51 | 58 |
/> |
| 52 | 59 |
); |
| 53 | 60 |
} |
+++ lib/data/repositories/common-code-repository.ts
... | ... | @@ -0,0 +1,71 @@ |
| 1 | +import 'server-only'; | |
| 2 | +import { cache } from 'react'; | |
| 3 | +import { getSessionAccessToken } from '@/lib/auth/dal'; | |
| 4 | +import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch'; | |
| 5 | +import type { CommonCode } from '@/lib/domain/common-code'; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * 공통코드 Repository. | |
| 9 | + * | |
| 10 | + * ``` | |
| 11 | + * GET /api/v1/mngr/code/list/{comCd} 그룹의 상세코드 목록 (ROLE_ADMIN) | |
| 12 | + * ``` | |
| 13 | + * | |
| 14 | + * 백엔드(edupay-backend, develop)의 MngrCodeApiController / MngrCodeMapper.xml을 읽고 확인한 것: | |
| 15 | + * | |
| 16 | + * - 응답은 `TB_SYS_COM_CD_DTL` 행 배열이고, 쓸 값은 **`comDtlCd`(코드)와 `cdNm`(이름)** 둘이다. | |
| 17 | + * - 삭제된 코드(`DEL_YN='Y'`)는 SQL이 이미 걸러 준다. | |
| 18 | + * - **정렬은 백엔드가 준 순서를 그대로 쓴다.** SQL이 `ROW_NUMBER() OVER (ORDER BY FRST_REG_DT | |
| 19 | + * DESC, SORT_SEQ)`를 다시 `ORDER BY RNUM DESC`로 뒤집어, 결과는 등록일 오름차순 + 정렬순서 | |
| 20 | + * 내림차순이다(아이템 목록과 같은 이중 역순 패턴). 화면에서 다시 정렬하면 다른 화면과 순서가 | |
| 21 | + * 어긋나므로 손대지 않는다. | |
| 22 | + * | |
| 23 | + * `cache()`로 감싼 이유 — 한 요청 안에서 화면(선택지 그리기)과 Server Action(입력값 검증)이 | |
| 24 | + * 같은 그룹을 각각 부른다. 요청 단위로 기억해 두면 왕복이 한 번으로 줄고, 그리는 데 쓴 목록과 | |
| 25 | + * 검증에 쓴 목록이 반드시 같아진다. | |
| 26 | + * | |
| 27 | + * 캐시: `no-store` — 코드는 자주 바뀌지 않지만 관리 화면에서 코드를 고치자마자 반영돼야 하고, | |
| 28 | + * 위 `cache()`가 이미 요청 안의 중복 호출을 막는다. | |
| 29 | + */ | |
| 30 | +export const fetchCommonCodes = cache(async function fetchCommonCodes( | |
| 31 | + groupCode: string | |
| 32 | +): Promise<CommonCode[]> { | |
| 33 | + const accessToken = await getSessionAccessToken(); | |
| 34 | + | |
| 35 | + const result = await backendFetch<unknown>(`/api/v1/mngr/code/list/${groupCode}`, { | |
| 36 | + method: 'GET', | |
| 37 | + accessToken: accessToken ?? undefined, | |
| 38 | + cache: 'no-store', | |
| 39 | + }); | |
| 40 | + | |
| 41 | + if (!result.ok) { | |
| 42 | + throw new BackendRequestError(result); | |
| 43 | + } | |
| 44 | + | |
| 45 | + if (!Array.isArray(result.data)) { | |
| 46 | + throw new Error('공통코드 목록 응답의 형식이 올바르지 않습니다.'); | |
| 47 | + } | |
| 48 | + | |
| 49 | + return result.data.flatMap(toCommonCode); | |
| 50 | +}); | |
| 51 | + | |
| 52 | +/** | |
| 53 | + * 응답 1건 → 도메인 타입. 코드나 이름이 없는 행은 선택지로 쓸 수 없으므로 **예외 대신 건너뛴다** | |
| 54 | + * — 코드 한 줄이 깨졌다고 화면 전체를 못 쓰게 만들 이유가 없다(목록 조회의 fail-fast와 다른 | |
| 55 | + * 판단이다. 그쪽은 없으면 화면의 존재 이유가 사라진다). | |
| 56 | + */ | |
| 57 | +function toCommonCode(raw: unknown): CommonCode[] { | |
| 58 | + if (raw === null || typeof raw !== 'object') { | |
| 59 | + return []; | |
| 60 | + } | |
| 61 | + | |
| 62 | + const source = raw as Record<string, unknown>; | |
| 63 | + const code = source.comDtlCd; | |
| 64 | + const label = source.cdNm; | |
| 65 | + | |
| 66 | + if (typeof code !== 'string' || code.length === 0) { | |
| 67 | + return []; | |
| 68 | + } | |
| 69 | + | |
| 70 | + return [{ code, label: typeof label === 'string' && label ? label : code }]; | |
| 71 | +} |
+++ lib/domain/common-code.ts
... | ... | @@ -0,0 +1,34 @@ |
| 1 | +/** | |
| 2 | + * 공통코드 도메인 — 순수 데이터 표현, 외부 의존 없음. | |
| 3 | + * | |
| 4 | + * 백엔드 `TB_SYS_COM_CD_DTL`의 한 그룹(`COM_CD`)에 속한 상세코드 목록이며, | |
| 5 | + * `GET /api/v1/mngr/code/list/{comCd}`(ROLE_ADMIN)로 가져온다. | |
| 6 | + */ | |
| 7 | + | |
| 8 | +export type CommonCode = { | |
| 9 | + /** 백엔드 `comDtlCd` — 저장·전송에 쓰는 코드값. */ | |
| 10 | + code: string; | |
| 11 | + /** 백엔드 `cdNm` — 화면에 보이는 이름. */ | |
| 12 | + label: string; | |
| 13 | +}; | |
| 14 | + | |
| 15 | +/** 코드 그룹 ID — 화면이 문자열을 직접 적지 않도록 여기 모은다. */ | |
| 16 | +export const CODE_GROUP = { | |
| 17 | + /** 꾸미기 아이템 카테고리. */ | |
| 18 | + decorationItemCategory: 'ITEM_CATE_CD', | |
| 19 | +} as const; | |
| 20 | + | |
| 21 | +/** 코드값 → 이름. 목록에 없는 코드는 코드값 자체를 보여준다(이름을 지어내지 않는다). */ | |
| 22 | +export function formatCommonCode( | |
| 23 | + codes: readonly CommonCode[], | |
| 24 | + code: string | |
| 25 | +): string { | |
| 26 | + return codes.find((item) => item.code === code)?.label ?? code; | |
| 27 | +} | |
| 28 | + | |
| 29 | +export function isKnownCommonCode( | |
| 30 | + codes: readonly CommonCode[], | |
| 31 | + code: string | |
| 32 | +): boolean { | |
| 33 | + return codes.some((item) => item.code === code); | |
| 34 | +} |
--- lib/domain/decoration-item-form.ts
+++ lib/domain/decoration-item-form.ts
... | ... | @@ -13,8 +13,8 @@ |
| 13 | 13 |
* **이 파일이 검증의 단일 진실원천이다.** Server Action(`_actions.ts`)이 저장 직전에 여기를 거친다. |
| 14 | 14 |
*/ |
| 15 | 15 |
|
| 16 |
+import { isKnownCommonCode, type CommonCode } from '@/lib/domain/common-code';
|
|
| 16 | 17 |
import {
|
| 17 |
- DECORATION_ITEM_CATEGORIES, |
|
| 18 | 18 |
DECORATION_ITEM_TYPE_OPTIONS, |
| 19 | 19 |
type DecorationItemType, |
| 20 | 20 |
} from '@/lib/domain/decoration-item'; |
... | ... | @@ -69,10 +69,6 @@ |
| 69 | 69 |
return DECORATION_ITEM_TYPE_OPTIONS.some((option) => option.value === value); |
| 70 | 70 |
} |
| 71 | 71 |
|
| 72 |
-function isDecorationItemCategoryCode(value: string): boolean {
|
|
| 73 |
- return DECORATION_ITEM_CATEGORIES.some((category) => category.code === value); |
|
| 74 |
-} |
|
| 75 |
- |
|
| 76 | 72 |
/** |
| 77 | 73 |
* 등록·수정 공통 검증. 오류는 넘겨받은 객체에 채워 넣고, 정규화된 값을 돌려준다. |
| 78 | 74 |
* |
... | ... | @@ -83,6 +79,7 @@ |
| 83 | 79 |
*/ |
| 84 | 80 |
function validateEditableValues( |
| 85 | 81 |
values: DecorationItemEditableValues, |
| 82 |
+ categories: readonly CommonCode[], |
|
| 86 | 83 |
errors: DecorationItemFormErrors |
| 87 | 84 |
): DecorationItemEditableValues {
|
| 88 | 85 |
const name = values.name.trim(); |
... | ... | @@ -98,7 +95,7 @@ |
| 98 | 95 |
errors.name = `아이템명은 ${NAME_MAX_LENGTH}자 이내로 입력해 주세요.`;
|
| 99 | 96 |
} |
| 100 | 97 |
|
| 101 |
- if (!isDecorationItemCategoryCode(values.categoryCode)) {
|
|
| 98 |
+ if (!isKnownCommonCode(categories, values.categoryCode)) {
|
|
| 102 | 99 |
errors.categoryCode = '카테고리를 선택해 주세요.'; |
| 103 | 100 |
} |
| 104 | 101 |
|
... | ... | @@ -123,12 +120,18 @@ |
| 123 | 120 |
return { ...values, name, description };
|
| 124 | 121 |
} |
| 125 | 122 |
|
| 126 |
-/** 시안 ADM_ITM_102_p — 등록 검증. */ |
|
| 123 |
+/** |
|
| 124 |
+ * 시안 ADM_ITM_102_p — 등록 검증. |
|
| 125 |
+ * |
|
| 126 |
+ * 허용 카테고리는 상수가 아니라 **인자로 받는다** — 값이 백엔드 공통코드(`ITEM_CATE_CD`)에서 |
|
| 127 |
+ * 오므로 이 파일이 알 수 없고, 알아서도 안 된다(domain 계층은 통신을 하지 않는다). |
|
| 128 |
+ */ |
|
| 127 | 129 |
export function validateDecorationItemCreate( |
| 128 |
- values: DecorationItemEditableValues |
|
| 130 |
+ values: DecorationItemEditableValues, |
|
| 131 |
+ categories: readonly CommonCode[] |
|
| 129 | 132 |
): ValidationResult<DecorationItemEditableValues> {
|
| 130 | 133 |
const errors: DecorationItemFormErrors = {};
|
| 131 |
- const editable = validateEditableValues(values, errors); |
|
| 134 |
+ const editable = validateEditableValues(values, categories, errors); |
|
| 132 | 135 |
|
| 133 | 136 |
if (Object.keys(errors).length > 0) {
|
| 134 | 137 |
return { ok: false, errors };
|
... | ... | @@ -139,7 +142,8 @@ |
| 139 | 142 |
|
| 140 | 143 |
/** 시안 ADM_ITM_103_p — 수정 검증. 아이템ID는 읽기 전용이라 검증 대상이 아니다. */ |
| 141 | 144 |
export function validateDecorationItemUpdate( |
| 142 |
- values: DecorationItemEditableValues |
|
| 145 |
+ values: DecorationItemEditableValues, |
|
| 146 |
+ categories: readonly CommonCode[] |
|
| 143 | 147 |
): ValidationResult<DecorationItemEditableValues> {
|
| 144 |
- return validateDecorationItemCreate(values); |
|
| 148 |
+ return validateDecorationItemCreate(values, categories); |
|
| 145 | 149 |
} |
--- lib/domain/decoration-item.ts
+++ lib/domain/decoration-item.ts
... | ... | @@ -26,27 +26,6 @@ |
| 26 | 26 |
|
| 27 | 27 |
export const DEFAULT_DECORATION_ITEM_TYPE: DecorationItemType = 'individual'; |
| 28 | 28 |
|
| 29 |
-/** |
|
| 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 |
- * 정해지면 기존 데이터의 코드값 마이그레이션이 필요하다. |
|
| 37 |
- */ |
|
| 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; |
|
| 49 |
- |
|
| 50 | 29 |
export type DecorationItem = {
|
| 51 | 30 |
/** |
| 52 | 31 |
* 백엔드 `itemSn` — 자동증가 PK이자 화면의 "아이템ID"로 그대로 노출하는 값(사용자 확정 사항). |
... | ... | @@ -56,12 +35,9 @@ |
| 56 | 35 |
itemSn: number; |
| 57 | 36 |
itemType: DecorationItemType; |
| 58 | 37 |
name: string; |
| 59 |
- /** 백엔드 `itemCateCd` — 저장·전송에 쓰는 코드값. */ |
|
| 38 |
+ /** 백엔드 `itemCateCd` — 저장·전송에 쓰는 코드값(공통코드 `ITEM_CATE_CD`의 상세코드). */ |
|
| 60 | 39 |
categoryCode: string; |
| 61 |
- /** |
|
| 62 |
- * 백엔드 `itemCateNm` — 공통코드테이블 조인 결과. 코드가 코드테이블에 없으면 null로 온다 |
|
| 63 |
- * (지금은 임시 코드를 쓰므로 대개 null이다 — 화면은 `formatDecorationItemCategory`로 보완한다). |
|
| 64 |
- */ |
|
| 40 |
+ /** 백엔드 `itemCateNm` — 공통코드테이블 조인 결과. 코드가 코드테이블에 없으면 null이다. */ |
|
| 65 | 41 |
categoryName: string | null; |
| 66 | 42 |
/** 백엔드 `itemAmount` — 오픈 가능한 포인트. */ |
| 67 | 43 |
points: number; |
... | ... | @@ -93,19 +69,11 @@ |
| 93 | 69 |
} |
| 94 | 70 |
|
| 95 | 71 |
/** |
| 96 |
- * 카테고리 표기 — 백엔드가 코드테이블에서 찾은 이름을 우선 쓰고, 없으면 프론트 임시 목록에서 |
|
| 97 |
- * 찾고, 그것도 없으면 코드값 자체를 보여준다. 임시 코드 단계에서는 두 번째 경로가 주로 쓰이고, |
|
| 98 |
- * 백엔드 코드가 정비되면 자연스럽게 첫 번째 경로로 넘어간다. |
|
| 72 |
+ * 카테고리 표기 — 목록 SQL이 코드테이블을 조인해 내려 준 이름을 쓴다. 코드가 코드테이블에서 |
|
| 73 |
+ * 지워졌거나 값 자체가 비었을 때만 코드값·`-`로 떨어진다(이름을 지어내지 않는다). |
|
| 99 | 74 |
*/ |
| 100 | 75 |
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; |
|
| 76 |
+ return item.categoryName || item.categoryCode || EMPTY_FIELD_PLACEHOLDER; |
|
| 109 | 77 |
} |
| 110 | 78 |
|
| 111 | 79 |
/** 수정일시 표기 — 백엔드가 이미 `YYYY-MM-DD`로 포맷해 주므로 그대로 쓰고 null만 보완한다. */ |
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?