feat: 꾸미기 아이템 등록·수정 기능 추가
Next.js 16의 'use server' 파일은 async 함수 외의 값을 export할 수 없어(런타임 전용 제약), 폼 상태 초깃값(INITIAL_DECORATION_ITEM_FORM_STATE)을 domain 계층으로 옮겼다. Co-Authored-By: Claude Opus 5
@6dd882c947b82ccb121774687814a0e66ac28dd7
+++ app/(protected)/(basic)/decoration-items/_actions.ts
... | ... | @@ -0,0 +1,152 @@ |
| 1 | +'use server'; | |
| 2 | + | |
| 3 | +import { revalidatePath } from 'next/cache'; | |
| 4 | +import { verifySession } from '@/lib/auth/dal'; | |
| 5 | +import { | |
| 6 | + createDecorationItem, | |
| 7 | + deleteDecorationItem, | |
| 8 | + isDecorationItemIdTaken, | |
| 9 | + updateDecorationItem, | |
| 10 | +} from '@/lib/data/repositories/decoration-item-repository'; | |
| 11 | +import type { | |
| 12 | + DecorationItemCategory, | |
| 13 | + DecorationItemType, | |
| 14 | +} from '@/lib/domain/decoration-item'; | |
| 15 | +import { | |
| 16 | + validateDecorationItemCreate, | |
| 17 | + validateDecorationItemUpdate, | |
| 18 | + type DecorationItemEditableValues, | |
| 19 | + type DecorationItemFormState, | |
| 20 | +} from '@/lib/domain/decoration-item-form'; | |
| 21 | +import { DECORATION_ITEMS_PATH } from '@/lib/domain/decoration-item-query'; | |
| 22 | + | |
| 23 | +/** | |
| 24 | + * 꾸미기 아이템 등록/수정/삭제 Server Action. | |
| 25 | + * | |
| 26 | + * **모든 Action이 `verifySession()`으로 시작한다** — Server Action은 UI를 거치지 않고 직접 | |
| 27 | + * POST될 수 있어 이 확인이 유일한 최종 방어선이다(설계서 §3 SRP 체크). | |
| 28 | + * | |
| 29 | + * 검증은 화면이 아니라 여기서 확정한다(`lib/domain/decoration-item-form.ts`의 규칙을 호출) — | |
| 30 | + * 화면의 required 속성·기본값 채움은 편의일 뿐 신뢰 경계가 아니다. | |
| 31 | + * | |
| 32 | + * 실제 저장은 Repository에 맡긴다. 백엔드에 이 도메인의 API가 전혀 없어(조회조차 없음, 사용자 | |
| 33 | + * 확정 사항) Repository가 mock 저장소로 위임하고 있지만, **이 파일은 그 사실을 알지 못한다** — | |
| 34 | + * 백엔드 API가 생겨도 이 파일은 바뀌지 않는다. | |
| 35 | + * | |
| 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로 남지 않는다. | |
| 41 | + */ | |
| 42 | + | |
| 43 | +const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.'; | |
| 44 | +const DUPLICATE_ITEM_ID_MESSAGE = '이미 사용 중인 아이템 ID입니다.'; | |
| 45 | + | |
| 46 | +function readString(formData: FormData, key: string): string { | |
| 47 | + const value = formData.get(key); | |
| 48 | + return typeof value === 'string' ? value : ''; | |
| 49 | +} | |
| 50 | + | |
| 51 | +/** | |
| 52 | + * 폼 숫자 입력을 파싱한다. `Number('')`이 조용히 `0`이 되는 JS 함정을 피하려고 빈 문자열은 | |
| 53 | + * 명시적으로 `NaN`으로 취급한다 — 그래야 `Number.isInteger` 검증이 "값이 비었음"을 정상적으로 | |
| 54 | + * 잡아낸다(포인트 0은 유효한 값이라 "비어서 0"과 "실제로 0"을 구분해야 한다). | |
| 55 | + */ | |
| 56 | +function parseFormNumber(formData: FormData, key: string): number { | |
| 57 | + const trimmed = readString(formData, key).trim(); | |
| 58 | + return trimmed === '' ? NaN : Number(trimmed); | |
| 59 | +} | |
| 60 | + | |
| 61 | +/** | |
| 62 | + * 등록·수정이 공유하는 입력 항목을 읽는다. 유형·카테고리는 select/radio 값을 그대로 읽고, | |
| 63 | + * 허용 목록을 벗어난 값(위조된 요청 포함)은 뒤이은 `validateEditableValues`가 걸러낸다 — 여기서 | |
| 64 | + * 하는 캐스팅은 타입을 맞추는 것일 뿐 신뢰를 부여하지 않는다. | |
| 65 | + */ | |
| 66 | +function readEditableValues(formData: FormData): DecorationItemEditableValues { | |
| 67 | + return { | |
| 68 | + itemType: readString(formData, 'itemType') as DecorationItemType, | |
| 69 | + name: readString(formData, 'name'), | |
| 70 | + category: readString(formData, 'category') as DecorationItemCategory, | |
| 71 | + points: parseFormNumber(formData, 'points'), | |
| 72 | + description: readString(formData, 'description'), | |
| 73 | + isActive: readString(formData, 'isActive') === 'true', | |
| 74 | + sortOrder: parseFormNumber(formData, 'sortOrder'), | |
| 75 | + }; | |
| 76 | +} | |
| 77 | + | |
| 78 | +/** 시안 ADM_ITM_102_p — 꾸미기 아이템 등록. */ | |
| 79 | +export async function createDecorationItemAction( | |
| 80 | + _prevState: DecorationItemFormState, | |
| 81 | + formData: FormData | |
| 82 | +): Promise<DecorationItemFormState> { | |
| 83 | + await verifySession(); | |
| 84 | + | |
| 85 | + const validation = validateDecorationItemCreate({ | |
| 86 | + ...readEditableValues(formData), | |
| 87 | + itemId: readString(formData, 'itemId'), | |
| 88 | + }); | |
| 89 | + | |
| 90 | + if (!validation.ok) { | |
| 91 | + return { status: 'error', errors: validation.errors }; | |
| 92 | + } | |
| 93 | + | |
| 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 } }; | |
| 100 | + } | |
| 101 | + | |
| 102 | + await createDecorationItem({ itemId, ...editable }); | |
| 103 | + | |
| 104 | + revalidatePath(DECORATION_ITEMS_PATH); | |
| 105 | + return { status: 'success' }; | |
| 106 | +} | |
| 107 | + | |
| 108 | +/** 시안 ADM_ITM_103_p — 꾸미기 아이템 수정. 아이템ID는 읽기 전용이라 변경 대상이 아니다. */ | |
| 109 | +export async function updateDecorationItemAction( | |
| 110 | + _prevState: DecorationItemFormState, | |
| 111 | + formData: FormData | |
| 112 | +): Promise<DecorationItemFormState> { | |
| 113 | + await verifySession(); | |
| 114 | + | |
| 115 | + const itemId = readString(formData, 'itemId'); | |
| 116 | + if (!itemId) { | |
| 117 | + return { status: 'error', message: INVALID_REQUEST_MESSAGE }; | |
| 118 | + } | |
| 119 | + | |
| 120 | + const validation = validateDecorationItemUpdate(readEditableValues(formData)); | |
| 121 | + if (!validation.ok) { | |
| 122 | + return { status: 'error', errors: validation.errors }; | |
| 123 | + } | |
| 124 | + | |
| 125 | + await updateDecorationItem(itemId, validation.values); | |
| 126 | + | |
| 127 | + revalidatePath(DECORATION_ITEMS_PATH); | |
| 128 | + return { status: 'success' }; | |
| 129 | +} | |
| 130 | + | |
| 131 | +/** | |
| 132 | + * 시안 ADM_ITM_101 — 삭제. 확인 얼럿은 화면(`decoration-item-row-actions.tsx`)이 띄우고, | |
| 133 | + * 여기서는 인증과 입력만 확인한다. | |
| 134 | + * | |
| 135 | + * 폼 제출이 아니라 얼럿의 [삭제] 클릭에 반응하는 단발 호출이라 `useActionState`의 | |
| 136 | + * (prevState, formData) 규약 대신 id를 직접 받는다(admins의 `deleteAdminMemberAction`과 동일한 | |
| 137 | + * 이유). | |
| 138 | + */ | |
| 139 | +export async function deleteDecorationItemAction( | |
| 140 | + itemId: string | |
| 141 | +): Promise<DecorationItemFormState> { | |
| 142 | + await verifySession(); | |
| 143 | + | |
| 144 | + if (!itemId) { | |
| 145 | + return { status: 'error', message: INVALID_REQUEST_MESSAGE }; | |
| 146 | + } | |
| 147 | + | |
| 148 | + await deleteDecorationItem(itemId); | |
| 149 | + | |
| 150 | + revalidatePath(DECORATION_ITEMS_PATH); | |
| 151 | + return { status: 'success' }; | |
| 152 | +} |
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-create-modal.tsx
... | ... | @@ -0,0 +1,110 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useActionState, useEffect } from 'react'; | |
| 4 | +import { Button } from '@/components/ui/button'; | |
| 5 | +import { Field } from '@/components/ui/field'; | |
| 6 | +import { Input } from '@/components/ui/input'; | |
| 7 | +import { Modal } from '@/components/ui/modal'; | |
| 8 | +import { useFeedback } from '@/app/_hooks/use-feedback'; | |
| 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'; | |
| 14 | +import { createDecorationItemAction } from '../_actions'; | |
| 15 | +import { | |
| 16 | + DecorationItemFormFields, | |
| 17 | + FieldError, | |
| 18 | +} from './decoration-item-form-fields'; | |
| 19 | + | |
| 20 | +interface DecorationItemCreateModalProps { | |
| 21 | + /** 등록 버튼을 누른 시점의 활성 탭(유형) — 폼의 유형 기본값으로 쓴다. */ | |
| 22 | + defaultItemType: DecorationItemType; | |
| 23 | + typeTotalCount: number; | |
| 24 | + onClose: () => void; | |
| 25 | +} | |
| 26 | + | |
| 27 | +const FORM_ID = 'decoration-item-create-form'; | |
| 28 | + | |
| 29 | +/** | |
| 30 | + * 꾸미기 아이템 등록 팝업(시안 ADM_ITM_102_p). | |
| 31 | + * | |
| 32 | + * 아이템ID는 시안에 [중복확인] 버튼이 없지만 유일 식별자이자 수정/삭제 대상 키라 Server | |
| 33 | + * Action이 저장 직전에 중복을 확인한다(`_actions.ts` 주석 참조) — 화면에는 그 확인 과정이 | |
| 34 | + * 보이지 않고 실패 시 필드 오류로만 나타난다. | |
| 35 | + * | |
| 36 | + * 저장 버튼은 footer 슬롯에서 `form={FORM_ID}` 속성으로 폼과 연결한다(admins의 등록 팝업과 | |
| 37 | + * 동일한 패턴) — 버튼이 실제 DOM상 form의 자손이 아니어도 같은 문서 안에서 id만 일치하면 그 | |
| 38 | + * form을 제출한다. | |
| 39 | + */ | |
| 40 | +export function DecorationItemCreateModal({ | |
| 41 | + defaultItemType, | |
| 42 | + typeTotalCount, | |
| 43 | + onClose, | |
| 44 | +}: DecorationItemCreateModalProps) { | |
| 45 | + const { showToast } = useFeedback(); | |
| 46 | + const [state, formAction, isPending] = useActionState( | |
| 47 | + createDecorationItemAction, | |
| 48 | + INITIAL_DECORATION_ITEM_FORM_STATE | |
| 49 | + ); | |
| 50 | + | |
| 51 | + useEffect(() => { | |
| 52 | + if (state.status === 'success') { | |
| 53 | + showToast({ variant: 'success', message: '아이템을 등록했습니다.' }); | |
| 54 | + onClose(); | |
| 55 | + } | |
| 56 | + }, [state, showToast, onClose]); | |
| 57 | + | |
| 58 | + const errors = state.status === 'error' ? (state.errors ?? {}) : {}; | |
| 59 | + | |
| 60 | + return ( | |
| 61 | + <Modal | |
| 62 | + title="꾸미기 아이템 등록" | |
| 63 | + onClose={onClose} | |
| 64 | + footer={ | |
| 65 | + <> | |
| 66 | + <Button type="button" variant="ghost" onClick={onClose}> | |
| 67 | + 취소 | |
| 68 | + </Button> | |
| 69 | + <Button | |
| 70 | + type="submit" | |
| 71 | + form={FORM_ID} | |
| 72 | + variant="primary" | |
| 73 | + disabled={isPending} | |
| 74 | + > | |
| 75 | + {isPending ? '등록 중...' : '등록'} | |
| 76 | + </Button> | |
| 77 | + </> | |
| 78 | + } | |
| 79 | + > | |
| 80 | + <form id={FORM_ID} action={formAction} className="flex flex-col gap-4"> | |
| 81 | + <p className="text-right text-body-sm text-danger"> | |
| 82 | + * 는 필수 항목입니다. | |
| 83 | + </p> | |
| 84 | + | |
| 85 | + <Field label="아이템 ID *"> | |
| 86 | + <Input | |
| 87 | + type="text" | |
| 88 | + name="itemId" | |
| 89 | + placeholder="예: ITEM-HAT-002" | |
| 90 | + autoComplete="off" | |
| 91 | + /> | |
| 92 | + </Field> | |
| 93 | + <p className="text-body-sm text-foreground-muted"> | |
| 94 | + {DECORATION_ITEM_ID_HELP_TEXT} | |
| 95 | + </p> | |
| 96 | + <FieldError message={errors.itemId} /> | |
| 97 | + | |
| 98 | + <DecorationItemFormFields | |
| 99 | + defaultItemType={defaultItemType} | |
| 100 | + typeTotalCount={typeTotalCount} | |
| 101 | + errors={errors} | |
| 102 | + /> | |
| 103 | + | |
| 104 | + {state.status === 'error' && state.message && ( | |
| 105 | + <p className="text-body-sm text-danger">{state.message}</p> | |
| 106 | + )} | |
| 107 | + </form> | |
| 108 | + </Modal> | |
| 109 | + ); | |
| 110 | +} |
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-edit-modal.tsx
... | ... | @@ -0,0 +1,95 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useActionState, useEffect } from 'react'; | |
| 4 | +import { Button } from '@/components/ui/button'; | |
| 5 | +import { Field } from '@/components/ui/field'; | |
| 6 | +import { Input } from '@/components/ui/input'; | |
| 7 | +import { Modal } from '@/components/ui/modal'; | |
| 8 | +import { useFeedback } from '@/app/_hooks/use-feedback'; | |
| 9 | +import type { DecorationItem } from '@/lib/domain/decoration-item'; | |
| 10 | +import { INITIAL_DECORATION_ITEM_FORM_STATE } from '@/lib/domain/decoration-item-form'; | |
| 11 | +import { updateDecorationItemAction } from '../_actions'; | |
| 12 | +import { DecorationItemFormFields } from './decoration-item-form-fields'; | |
| 13 | + | |
| 14 | +interface DecorationItemEditModalProps { | |
| 15 | + item: DecorationItem; | |
| 16 | + typeTotalCount: number; | |
| 17 | + onClose: () => void; | |
| 18 | +} | |
| 19 | + | |
| 20 | +const FORM_ID = 'decoration-item-edit-form'; | |
| 21 | + | |
| 22 | +/** | |
| 23 | + * 꾸미기 아이템 수정 팝업(시안 ADM_ITM_103_p) — 등록과 동일 항목이되 **아이템ID는 읽기 | |
| 24 | + * 전용**이다. | |
| 25 | + * | |
| 26 | + * readOnly로 보여주는 아이템ID `Input`에는 `name`을 주지 않아 제출 대상에서 아예 빠지게 하고, | |
| 27 | + * 실제 수정 대상은 별도 hidden input(`itemId`)으로 넘긴다 — Server Action도 hidden 값만 | |
| 28 | + * 읽으므로 readOnly 필드를 위조해서 보내도 수정 대상이 바뀌지 않는다(admins의 이름/ID readOnly | |
| 29 | + * 처리와 동일한 방어 방식). | |
| 30 | + */ | |
| 31 | +export function DecorationItemEditModal({ | |
| 32 | + item, | |
| 33 | + typeTotalCount, | |
| 34 | + onClose, | |
| 35 | +}: DecorationItemEditModalProps) { | |
| 36 | + const { showToast } = useFeedback(); | |
| 37 | + const [state, formAction, isPending] = useActionState( | |
| 38 | + updateDecorationItemAction, | |
| 39 | + INITIAL_DECORATION_ITEM_FORM_STATE | |
| 40 | + ); | |
| 41 | + | |
| 42 | + useEffect(() => { | |
| 43 | + if (state.status === 'success') { | |
| 44 | + showToast({ variant: 'success', message: '아이템 정보를 수정했습니다.' }); | |
| 45 | + onClose(); | |
| 46 | + } | |
| 47 | + }, [state, showToast, onClose]); | |
| 48 | + | |
| 49 | + const errors = state.status === 'error' ? (state.errors ?? {}) : {}; | |
| 50 | + | |
| 51 | + return ( | |
| 52 | + <Modal | |
| 53 | + title="꾸미기 아이템 수정" | |
| 54 | + onClose={onClose} | |
| 55 | + footer={ | |
| 56 | + <> | |
| 57 | + <Button type="button" variant="ghost" onClick={onClose}> | |
| 58 | + 취소 | |
| 59 | + </Button> | |
| 60 | + <Button | |
| 61 | + type="submit" | |
| 62 | + form={FORM_ID} | |
| 63 | + variant="primary" | |
| 64 | + disabled={isPending} | |
| 65 | + > | |
| 66 | + {isPending ? '수정 중...' : '수정'} | |
| 67 | + </Button> | |
| 68 | + </> | |
| 69 | + } | |
| 70 | + > | |
| 71 | + <form id={FORM_ID} action={formAction} className="flex flex-col gap-4"> | |
| 72 | + <input type="hidden" name="itemId" value={item.itemId} /> | |
| 73 | + | |
| 74 | + <p className="text-right text-body-sm text-danger"> | |
| 75 | + * 는 필수 항목입니다. | |
| 76 | + </p> | |
| 77 | + | |
| 78 | + <Field label="아이템 ID"> | |
| 79 | + <Input type="text" value={item.itemId} readOnly /> | |
| 80 | + </Field> | |
| 81 | + | |
| 82 | + <DecorationItemFormFields | |
| 83 | + item={item} | |
| 84 | + defaultItemType={item.itemType} | |
| 85 | + typeTotalCount={typeTotalCount} | |
| 86 | + errors={errors} | |
| 87 | + /> | |
| 88 | + | |
| 89 | + {state.status === 'error' && state.message && ( | |
| 90 | + <p className="text-body-sm text-danger">{state.message}</p> | |
| 91 | + )} | |
| 92 | + </form> | |
| 93 | + </Modal> | |
| 94 | + ); | |
| 95 | +} |
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-form-fields.tsx
... | ... | @@ -0,0 +1,144 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { Field } from '@/components/ui/field'; | |
| 4 | +import { Input } from '@/components/ui/input'; | |
| 5 | +import { RadioGroup } from '@/components/ui/radio-group'; | |
| 6 | +import { Select } from '@/components/ui/select'; | |
| 7 | +import { | |
| 8 | + DECORATION_ITEM_CATEGORIES, | |
| 9 | + DECORATION_ITEM_TYPE_OPTIONS, | |
| 10 | + type DecorationItem, | |
| 11 | + type DecorationItemType, | |
| 12 | +} from '@/lib/domain/decoration-item'; | |
| 13 | +import type { DecorationItemFormErrors } from '@/lib/domain/decoration-item-form'; | |
| 14 | + | |
| 15 | +const ACTIVE_STATUS_OPTIONS = [ | |
| 16 | + { value: 'true', label: '사용' }, | |
| 17 | + { value: 'false', label: '미사용' }, | |
| 18 | +]; | |
| 19 | + | |
| 20 | +interface DecorationItemFormFieldsProps { | |
| 21 | + /** 수정 팝업의 기존 값. 등록 팝업은 넘기지 않는다(빈 폼 + 기본값). */ | |
| 22 | + item?: DecorationItem; | |
| 23 | + /** 등록 팝업을 연 시점의 활성 탭(유형) — 수정 팝업에서는 `item.itemType`이 대신 쓰인다. */ | |
| 24 | + defaultItemType: DecorationItemType; | |
| 25 | + /** "정렬순서" 라벨의 "(총 등록 N개)" 힌트와 등록 시 기본값 계산에 쓰는, 현재 유형의 전체 | |
| 26 | + * 등록 건수(검색어 무관). */ | |
| 27 | + typeTotalCount: number; | |
| 28 | + errors: DecorationItemFormErrors; | |
| 29 | +} | |
| 30 | + | |
| 31 | +/** | |
| 32 | + * 등록·수정 팝업이 공유하는 입력 항목 — 유형/아이템명/카테고리/포인트/설명/사용여부/정렬순서 | |
| 33 | + * (시안 ADM_ITM_102_p / 103_p). 아이템ID와 그 읽기전용 여부만 각 팝업이 따로 그린다. | |
| 34 | + * | |
| 35 | + * **유형(개별/셋트) 전환 시 폼 구성이 바뀔 수 있다는 시안 설명이 있지만, 두 유형의 입력 항목이 | |
| 36 | + * 완전히 같아 조건부 렌더링을 두지 않았다** — 유형 값 자체만 폼에 실어 저장한다(사용자 확정 | |
| 37 | + * 사항). 목록의 유형 탭과 시각적으로 구분하기 위해(탭은 "지금 보고 있는 목록", 이 라디오는 | |
| 38 | + * "저장할 값") 별도 UI가 필요했는데, 전용 탭 컴포넌트가 없어(§10.3 — 신설은 design 레인 소관) | |
| 39 | + * 이미 있는 `RadioGroup`을 재사용했다(students의 사용여부 라디오와 동일한 재사용 패턴). | |
| 40 | + * | |
| 41 | + * **썸네일 이미지 업로드 필드는 의도적으로 없다** — 시안은 업로드를 요구하지만, 사용자가 | |
| 42 | + * "썸네일을 따로 등록하지 않고 추후 백엔드가 자동생성해주는 형식"이라고 확정했다(시안과 | |
| 43 | + * 다르게 가는 지점). 목록의 썸네일 컬럼 자체는 유지한다 — `decoration-item.ts`의 | |
| 44 | + * `getDecorationItemThumbnailLabel` 참조. | |
| 45 | + * | |
| 46 | + * 설명은 여러 줄 입력이 자연스럽지만 공용 textarea 컴포넌트가 없어(신설은 design 레인 소관, | |
| 47 | + * §10.4) 로직 우선 단계에서는 단일행 `Input`으로 대체했다 — design 레인에 필요 컴포넌트로 | |
| 48 | + * 보고한다. | |
| 49 | + */ | |
| 50 | +export function DecorationItemFormFields({ | |
| 51 | + item, | |
| 52 | + defaultItemType, | |
| 53 | + typeTotalCount, | |
| 54 | + errors, | |
| 55 | +}: DecorationItemFormFieldsProps) { | |
| 56 | + return ( | |
| 57 | + <> | |
| 58 | + <Field label="유형 *"> | |
| 59 | + <RadioGroup | |
| 60 | + name="itemType" | |
| 61 | + // RadioGroup의 options는 mutable 배열을 요구해 도메인의 ReadonlyArray를 그대로 넘길 | |
| 62 | + // 수 없다(components/ui는 design 레인 소관이라 시그니처를 바꾸지 않는다) — 얕은 | |
| 63 | + // 복사로 새 mutable 배열을 만들어 넘긴다. | |
| 64 | + options={[...DECORATION_ITEM_TYPE_OPTIONS]} | |
| 65 | + defaultValue={item?.itemType ?? defaultItemType} | |
| 66 | + /> | |
| 67 | + </Field> | |
| 68 | + <FieldError message={errors.itemType} /> | |
| 69 | + | |
| 70 | + <Field label="아이템명 *"> | |
| 71 | + <Input | |
| 72 | + type="text" | |
| 73 | + name="name" | |
| 74 | + defaultValue={item?.name ?? ''} | |
| 75 | + placeholder="아이템명을 입력하세요." | |
| 76 | + /> | |
| 77 | + </Field> | |
| 78 | + <FieldError message={errors.name} /> | |
| 79 | + | |
| 80 | + <Field label="카테고리 *"> | |
| 81 | + <Select | |
| 82 | + name="category" | |
| 83 | + defaultValue={item?.category ?? DECORATION_ITEM_CATEGORIES[0]} | |
| 84 | + > | |
| 85 | + {DECORATION_ITEM_CATEGORIES.map((category) => ( | |
| 86 | + <option key={category} value={category}> | |
| 87 | + {category} | |
| 88 | + </option> | |
| 89 | + ))} | |
| 90 | + </Select> | |
| 91 | + </Field> | |
| 92 | + <FieldError message={errors.category} /> | |
| 93 | + | |
| 94 | + <Field label="포인트 *"> | |
| 95 | + <Input | |
| 96 | + type="number" | |
| 97 | + name="points" | |
| 98 | + min={0} | |
| 99 | + step={1} | |
| 100 | + defaultValue={item?.points} | |
| 101 | + placeholder="오픈 가능한 포인트" | |
| 102 | + /> | |
| 103 | + </Field> | |
| 104 | + <FieldError message={errors.points} /> | |
| 105 | + | |
| 106 | + <Field label="설명"> | |
| 107 | + <Input | |
| 108 | + type="text" | |
| 109 | + name="description" | |
| 110 | + defaultValue={item?.description ?? ''} | |
| 111 | + placeholder="설명을 입력하세요." | |
| 112 | + /> | |
| 113 | + </Field> | |
| 114 | + <FieldError message={errors.description} /> | |
| 115 | + | |
| 116 | + <Field label="사용여부"> | |
| 117 | + <RadioGroup | |
| 118 | + name="isActive" | |
| 119 | + options={ACTIVE_STATUS_OPTIONS} | |
| 120 | + defaultValue={String(item?.isActive ?? true)} | |
| 121 | + /> | |
| 122 | + </Field> | |
| 123 | + | |
| 124 | + <Field label={`정렬순서 (총 등록 ${typeTotalCount}개)`}> | |
| 125 | + <Input | |
| 126 | + type="number" | |
| 127 | + name="sortOrder" | |
| 128 | + min={1} | |
| 129 | + step={1} | |
| 130 | + defaultValue={item?.sortOrder ?? typeTotalCount + 1} | |
| 131 | + /> | |
| 132 | + </Field> | |
| 133 | + <FieldError message={errors.sortOrder} /> | |
| 134 | + </> | |
| 135 | + ); | |
| 136 | +} | |
| 137 | + | |
| 138 | +/** 필드 하단 오류 문구. 값이 없으면 아무것도 그리지 않아 레이아웃을 차지하지 않는다. */ | |
| 139 | +export function FieldError({ message }: { message?: string }) { | |
| 140 | + if (!message) { | |
| 141 | + return null; | |
| 142 | + } | |
| 143 | + return <p className="text-body-sm text-danger">{message}</p>; | |
| 144 | +} |
--- lib/domain/decoration-item-form.ts
+++ lib/domain/decoration-item-form.ts
... | ... | @@ -59,6 +59,24 @@ |
| 59 | 59 |
| { ok: true; values: T }
|
| 60 | 60 |
| { ok: false; errors: DecorationItemFormErrors };
|
| 61 | 61 |
|
| 62 |
+/** |
|
| 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에 제약이 없다). |
|
| 70 |
+ */ |
|
| 71 |
+export type DecorationItemFormState = |
|
| 72 |
+ | { status: 'idle' }
|
|
| 73 |
+ | { status: 'error'; message?: string; errors?: DecorationItemFormErrors }
|
|
| 74 |
+ | { status: 'success' };
|
|
| 75 |
+ |
|
| 76 |
+export const INITIAL_DECORATION_ITEM_FORM_STATE: DecorationItemFormState = {
|
|
| 77 |
+ status: 'idle', |
|
| 78 |
+}; |
|
| 79 |
+ |
|
| 62 | 80 |
function isDecorationItemType(value: string): value is DecorationItemType {
|
| 63 | 81 |
return DECORATION_ITEM_TYPE_OPTIONS.some((option) => option.value === value); |
| 64 | 82 |
} |
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?