--- app/(protected)/(basic)/points/standards/_actions.ts
+++ app/(protected)/(basic)/points/standards/_actions.ts
... | ... | @@ -2,10 +2,13 @@ |
| 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 {
|
| 7 |
+ createPointStandard, |
|
| 6 | 8 |
deletePointStandard, |
| 7 | 9 |
updatePointStandard, |
| 8 | 10 |
} from '@/lib/data/repositories/point-standard-repository'; |
| 11 |
+import { CODE_GROUP } from '@/lib/domain/common-code';
|
|
| 9 | 12 |
import {
|
| 10 | 13 |
validatePointStandard, |
| 11 | 14 |
type PointStandardFormState, |
... | ... | @@ -25,30 +28,62 @@ |
| 25 | 28 |
return trimmed === '' ? NaN : Number(trimmed); |
| 26 | 29 |
} |
| 27 | 30 |
|
| 28 |
-/** 기획 PTS10100002_p — 이벤트는 읽기 전용이라 대상 id는 hidden 필드에서 읽는다. */ |
|
| 29 |
-export async function updatePointStandardAction( |
|
| 30 |
- _prevState: PointStandardFormState, |
|
| 31 |
- formData: FormData |
|
| 32 |
-): Promise<PointStandardFormState> {
|
|
| 33 |
- await verifySession(); |
|
| 34 |
- |
|
| 35 |
- const validation = validatePointStandard({
|
|
| 31 |
+function readFormValues(formData: FormData) {
|
|
| 32 |
+ return {
|
|
| 36 | 33 |
id: readString(formData, 'id'), |
| 34 |
+ eventCode: readString(formData, 'eventCode'), |
|
| 37 | 35 |
point: parseFormNumber(formData, 'point'), |
| 38 | 36 |
limitPoint: parseFormNumber(formData, 'limitPoint'), |
| 39 | 37 |
isDuplicable: readString(formData, 'isDuplicable') === 'Y', |
| 40 | 38 |
description: readString(formData, 'description'), |
| 41 | 39 |
isActive: readString(formData, 'isActive') === 'Y', |
| 40 |
+ }; |
|
| 41 |
+} |
|
| 42 |
+ |
|
| 43 |
+/** 기획 PTS10100002_p(등록) — 이벤트는 등록된 코드 중에서 고른다. */ |
|
| 44 |
+export async function createPointStandardAction( |
|
| 45 |
+ _prevState: PointStandardFormState, |
|
| 46 |
+ formData: FormData |
|
| 47 |
+): Promise<PointStandardFormState> {
|
|
| 48 |
+ await verifySession(); |
|
| 49 |
+ |
|
| 50 |
+ const validation = validatePointStandard(readFormValues(formData), {
|
|
| 51 |
+ isCreate: true, |
|
| 52 |
+ // 화면이 보낸 코드를 그대로 믿지 않는다 — 저장 직전에 코드표를 다시 조회해 대조한다. |
|
| 53 |
+ allowedEventCodes: ( |
|
| 54 |
+ await fetchCommonCodes(CODE_GROUP.pointEvent) |
|
| 55 |
+ ).map((code) => code.code), |
|
| 42 | 56 |
}); |
| 43 | 57 |
if (!validation.ok) {
|
| 44 | 58 |
return { status: 'error', errors: validation.errors };
|
| 45 | 59 |
} |
| 46 | 60 |
|
| 47 | 61 |
try {
|
| 48 |
- await updatePointStandard( |
|
| 49 |
- validation.values, |
|
| 50 |
- readString(formData, 'eventCode') |
|
| 51 |
- ); |
|
| 62 |
+ await createPointStandard(validation.values); |
|
| 63 |
+ } catch {
|
|
| 64 |
+ return { status: 'error', message: SAVE_FAILED_MESSAGE };
|
|
| 65 |
+ } |
|
| 66 |
+ |
|
| 67 |
+ revalidatePath(POINT_STANDARDS_PATH); |
|
| 68 |
+ return { status: 'success' };
|
|
| 69 |
+} |
|
| 70 |
+ |
|
| 71 |
+/** 기획 PTS10100002_p(수정) — 이벤트는 읽기 전용이라 hidden으로 실려 온다. */ |
|
| 72 |
+export async function updatePointStandardAction( |
|
| 73 |
+ _prevState: PointStandardFormState, |
|
| 74 |
+ formData: FormData |
|
| 75 |
+): Promise<PointStandardFormState> {
|
|
| 76 |
+ await verifySession(); |
|
| 77 |
+ |
|
| 78 |
+ const validation = validatePointStandard(readFormValues(formData), {
|
|
| 79 |
+ isCreate: false, |
|
| 80 |
+ }); |
|
| 81 |
+ if (!validation.ok) {
|
|
| 82 |
+ return { status: 'error', errors: validation.errors };
|
|
| 83 |
+ } |
|
| 84 |
+ |
|
| 85 |
+ try {
|
|
| 86 |
+ await updatePointStandard(validation.values); |
|
| 52 | 87 |
} catch {
|
| 53 | 88 |
return { status: 'error', message: SAVE_FAILED_MESSAGE };
|
| 54 | 89 |
} |
--- app/(protected)/(basic)/points/standards/_components/point-standard-form-modal.tsx
+++ app/(protected)/(basic)/points/standards/_components/point-standard-form-modal.tsx
... | ... | @@ -8,6 +8,7 @@ |
| 8 | 8 |
import { FoxModal } from '@fox/core/components/fox-modal';
|
| 9 | 9 |
import { FoxRadio } from '@fox/core/components/fox-radio';
|
| 10 | 10 |
import { FoxRadioGroup } from '@fox/core/components/fox-radio-group';
|
| 11 |
+import { FoxSelect } from '@fox/core/components/fox-select';
|
|
| 11 | 12 |
import { FoxTextArea } from '@fox/core/components/fox-text-area';
|
| 12 | 13 |
import { submitFormAction } from '@/app/_hooks/submit-form-action';
|
| 13 | 14 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
... | ... | @@ -16,34 +17,51 @@ |
| 16 | 17 |
INITIAL_POINT_STANDARD_FORM_STATE, |
| 17 | 18 |
} from '@/lib/domain/point-standard-form'; |
| 18 | 19 |
import type { PointStandard } from '@/lib/domain/point-standard';
|
| 19 |
-import { updatePointStandardAction } from '../_actions';
|
|
| 20 |
+import type { CommonCode } from '@/lib/domain/common-code';
|
|
| 21 |
+import {
|
|
| 22 |
+ createPointStandardAction, |
|
| 23 |
+ updatePointStandardAction, |
|
| 24 |
+} from '../_actions'; |
|
| 20 | 25 |
import styles from './point-standard-form.module.scss'; |
| 21 | 26 |
|
| 22 | 27 |
interface PointStandardFormModalProps {
|
| 23 |
- standard: PointStandard; |
|
| 28 |
+ /** 수정 대상. 없으면 등록이다. */ |
|
| 29 |
+ standard?: PointStandard; |
|
| 30 |
+ /** 공통코드 `CODE_GROUP.pointEvent` — 등록의 이벤트 선택지(기획 ① "등록된 코드중에서 선택"). */ |
|
| 31 |
+ eventCodes: readonly CommonCode[]; |
|
| 24 | 32 |
onClose: () => void; |
| 25 | 33 |
} |
| 26 | 34 |
|
| 27 | 35 |
export function PointStandardFormModal({
|
| 28 | 36 |
standard, |
| 37 |
+ eventCodes, |
|
| 29 | 38 |
onClose, |
| 30 | 39 |
}: PointStandardFormModalProps) {
|
| 40 |
+ const isCreate = standard === undefined; |
|
| 31 | 41 |
const { showToast } = useFeedback();
|
| 32 | 42 |
const formRef = useRef<HTMLFormElement>(null); |
| 33 | 43 |
const [state, formAction, isPending] = useActionState( |
| 34 |
- updatePointStandardAction, |
|
| 44 |
+ isCreate ? createPointStandardAction : updatePointStandardAction, |
|
| 35 | 45 |
INITIAL_POINT_STANDARD_FORM_STATE |
| 36 | 46 |
); |
| 37 | 47 |
|
| 38 |
- const [isDuplicable, setIsDuplicable] = useState(standard.isDuplicable); |
|
| 39 |
- const [isActive, setIsActive] = useState(standard.isActive); |
|
| 48 |
+ const [eventCode, setEventCode] = useState( |
|
| 49 |
+ standard?.eventCode ?? eventCodes[0]?.code ?? '' |
|
| 50 |
+ ); |
|
| 51 |
+ const [isDuplicable, setIsDuplicable] = useState(standard?.isDuplicable ?? true); |
|
| 52 |
+ const [isActive, setIsActive] = useState(standard?.isActive ?? true); |
|
| 40 | 53 |
|
| 41 | 54 |
useEffect(() => {
|
| 42 | 55 |
if (state.status === 'success') {
|
| 43 |
- showToast({ variant: 'success', message: '포인트 기준을 수정했습니다.' });
|
|
| 56 |
+ showToast({
|
|
| 57 |
+ variant: 'success', |
|
| 58 |
+ message: isCreate |
|
| 59 |
+ ? '포인트 기준을 등록했습니다.' |
|
| 60 |
+ : '포인트 기준을 수정했습니다.', |
|
| 61 |
+ }); |
|
| 44 | 62 |
onClose(); |
| 45 | 63 |
} |
| 46 |
- }, [state, showToast, onClose]); |
|
| 64 |
+ }, [state, showToast, onClose, isCreate]); |
|
| 47 | 65 |
|
| 48 | 66 |
const errors = state.status === 'error' ? (state.errors ?? {}) : {};
|
| 49 | 67 |
|
... | ... | @@ -51,7 +69,7 @@ |
| 51 | 69 |
<FoxModal |
| 52 | 70 |
open |
| 53 | 71 |
size="md" |
| 54 |
- title="수정" |
|
| 72 |
+ title={isCreate ? '등록' : '수정'}
|
|
| 55 | 73 |
onClose={onClose}
|
| 56 | 74 |
actions={
|
| 57 | 75 |
<> |
... | ... | @@ -59,7 +77,7 @@ |
| 59 | 77 |
<FoxButton |
| 60 | 78 |
type="primary" |
| 61 | 79 |
size="md" |
| 62 |
- label={isPending ? '저장 중...' : '수정'}
|
|
| 80 |
+ label={isPending ? '저장 중...' : isCreate ? '등록' : '수정'}
|
|
| 63 | 81 |
disabled={isPending}
|
| 64 | 82 |
onAction={() => formRef.current?.requestSubmit()}
|
| 65 | 83 |
/> |
... | ... | @@ -73,20 +91,38 @@ |
| 73 | 91 |
> |
| 74 | 92 |
<p className={styles.required}>* 는 필수 항목입니다.</p>
|
| 75 | 93 |
|
| 76 |
- <input type="hidden" name="id" value={standard.id} />
|
|
| 77 |
- {/* 화면에서는 읽기 전용이지만 백엔드 UPDATE가 EVENT_CD를 덮어써서 그대로 돌려보낸다. */}
|
|
| 78 |
- <input type="hidden" name="eventCode" value={standard.eventCode} />
|
|
| 94 |
+ <input type="hidden" name="id" value={standard?.id ?? ''} />
|
|
| 95 |
+ {/* 수정에서는 화면이 읽기 전용이지만 백엔드 UPDATE가 EVENT_CD를 덮어써서 그대로 돌려보낸다. */}
|
|
| 96 |
+ <input type="hidden" name="eventCode" value={eventCode} />
|
|
| 79 | 97 |
|
| 80 | 98 |
<div className={styles.field}>
|
| 81 |
- {/* 기획 ① 이벤트행은 수정불가(Read Only). 값은 hidden id가 갖는다. */}
|
|
| 82 |
- <FoxInput |
|
| 83 |
- size="md" |
|
| 84 |
- label="이벤트" |
|
| 85 |
- requirement="required" |
|
| 86 |
- value={standard.eventCode}
|
|
| 87 |
- readOnly |
|
| 88 |
- state="view" |
|
| 89 |
- /> |
|
| 99 |
+ {isCreate ? (
|
|
| 100 |
+ // 기획 ① 등록된 코드중에서 선택. |
|
| 101 |
+ <FoxSelect |
|
| 102 |
+ size="md" |
|
| 103 |
+ label="이벤트" |
|
| 104 |
+ requirement="required" |
|
| 105 |
+ options={eventCodes.map((code) => ({
|
|
| 106 |
+ value: code.code, |
|
| 107 |
+ label: code.label, |
|
| 108 |
+ }))} |
|
| 109 |
+ placeholder="이벤트를 선택하세요." |
|
| 110 |
+ value={eventCode}
|
|
| 111 |
+ onValueChange={setEventCode}
|
|
| 112 |
+ error={Boolean(errors.eventCode)}
|
|
| 113 |
+ hint={errors.eventCode}
|
|
| 114 |
+ /> |
|
| 115 |
+ ) : ( |
|
| 116 |
+ // 기획 ① 이벤트행은 수정불가(Read Only). |
|
| 117 |
+ <FoxInput |
|
| 118 |
+ size="md" |
|
| 119 |
+ label="이벤트" |
|
| 120 |
+ requirement="required" |
|
| 121 |
+ value={eventCode}
|
|
| 122 |
+ readOnly |
|
| 123 |
+ state="view" |
|
| 124 |
+ /> |
|
| 125 |
+ )} |
|
| 90 | 126 |
</div> |
| 91 | 127 |
|
| 92 | 128 |
<div className={styles.field}>
|
... | ... | @@ -102,7 +138,7 @@ |
| 102 | 138 |
inputMode="numeric" |
| 103 | 139 |
min={0}
|
| 104 | 140 |
aria-label="포인트" |
| 105 |
- defaultValue={String(standard.point)}
|
|
| 141 |
+ defaultValue={String(standard?.point ?? 0)}
|
|
| 106 | 142 |
invalid={Boolean(errors.point)}
|
| 107 | 143 |
/> |
| 108 | 144 |
</div> |
... | ... | @@ -124,7 +160,7 @@ |
| 124 | 160 |
inputMode="numeric" |
| 125 | 161 |
min={0}
|
| 126 | 162 |
aria-label="제한포인트" |
| 127 |
- defaultValue={String(standard.limitPoint)}
|
|
| 163 |
+ defaultValue={String(standard?.limitPoint ?? 0)}
|
|
| 128 | 164 |
invalid={Boolean(errors.limitPoint)}
|
| 129 | 165 |
/> |
| 130 | 166 |
</div> |
... | ... | @@ -160,7 +196,7 @@ |
| 160 | 196 |
name="description" |
| 161 | 197 |
label="이벤트 설명" |
| 162 | 198 |
requirement="required" |
| 163 |
- defaultValue={standard.description}
|
|
| 199 |
+ defaultValue={standard?.description ?? ''}
|
|
| 164 | 200 |
placeholder="이벤트 설명을 입력하세요." |
| 165 | 201 |
rows={4}
|
| 166 | 202 |
counter |
--- app/(protected)/(basic)/points/standards/_components/point-standard-list.tsx
+++ app/(protected)/(basic)/points/standards/_components/point-standard-list.tsx
... | ... | @@ -1,7 +1,10 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 |
+import { useState } from 'react';
|
|
| 4 |
+ |
|
| 3 | 5 |
import { useRouter } from 'next/navigation';
|
| 4 | 6 |
import { FoxButton } from '@fox/core/components/fox-button';
|
| 7 |
+import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
|
|
| 5 | 8 |
import {
|
| 6 | 9 |
FoxListContainer, |
| 7 | 10 |
type FoxListColumn, |
... | ... | @@ -16,6 +19,7 @@ |
| 16 | 19 |
formatPoint, |
| 17 | 20 |
type PointStandard, |
| 18 | 21 |
} from '@/lib/domain/point-standard'; |
| 22 |
+import type { CommonCode } from '@/lib/domain/common-code';
|
|
| 19 | 23 |
import {
|
| 20 | 24 |
POINT_STANDARDS_PATH, |
| 21 | 25 |
POINT_STANDARD_PAGE_SIZE_OPTIONS, |
... | ... | @@ -23,6 +27,7 @@ |
| 23 | 27 |
type PointStandardPageSize, |
| 24 | 28 |
type PointStandardQuery, |
| 25 | 29 |
} from '@/lib/domain/point-standard-query'; |
| 30 |
+import { PointStandardFormModal } from './point-standard-form-modal';
|
|
| 26 | 31 |
import { PointStandardRowActions } from './point-standard-row-actions';
|
| 27 | 32 |
|
| 28 | 33 |
interface PointStandardListProps {
|
... | ... | @@ -31,6 +36,8 @@ |
| 31 | 36 |
currentPage: number; |
| 32 | 37 |
totalPages: number; |
| 33 | 38 |
totalCount: number; |
| 39 |
+ /** 공통코드 `CODE_GROUP.pointEvent` — 등록 팝업의 이벤트 선택지. */ |
|
| 40 |
+ eventCodes: readonly CommonCode[]; |
|
| 34 | 41 |
} |
| 35 | 42 |
|
| 36 | 43 |
export function PointStandardList({
|
... | ... | @@ -39,8 +46,10 @@ |
| 39 | 46 |
currentPage, |
| 40 | 47 |
totalPages, |
| 41 | 48 |
totalCount, |
| 49 |
+ eventCodes, |
|
| 42 | 50 |
}: PointStandardListProps) {
|
| 43 | 51 |
const router = useRouter(); |
| 52 |
+ const [isCreateOpen, setIsCreateOpen] = useState(false); |
|
| 44 | 53 |
|
| 45 | 54 |
function go(patch: Partial<PointStandardQuery>) {
|
| 46 | 55 |
router.replace(buildPointStandardHref(query, { ...patch, page: 1 }));
|
... | ... | @@ -79,12 +88,6 @@ |
| 79 | 88 |
/> |
| 80 | 89 |
), |
| 81 | 90 |
}, |
| 82 |
- {
|
|
| 83 |
- key: 'siteCode', |
|
| 84 |
- header: '사이트', |
|
| 85 |
- width: 140, |
|
| 86 |
- render: (row) => formatOptionalValue(row.siteCode), |
|
| 87 |
- }, |
|
| 88 | 91 |
{ key: 'description', header: '설명', width: 320 },
|
| 89 | 92 |
{
|
| 90 | 93 |
key: 'isActive', |
... | ... | @@ -107,12 +110,13 @@ |
| 107 | 110 |
key: 'actions', |
| 108 | 111 |
header: '관리', |
| 109 | 112 |
width: 120, |
| 110 |
- render: (row) => <PointStandardRowActions standard={row} />,
|
|
| 113 |
+ render: (row) => <PointStandardRowActions standard={row} eventCodes={eventCodes} />,
|
|
| 111 | 114 |
}, |
| 112 | 115 |
]; |
| 113 | 116 |
|
| 114 | 117 |
return ( |
| 115 |
- <FoxListContainer<PointStandard> |
|
| 118 |
+ <> |
|
| 119 |
+ <FoxListContainer<PointStandard> |
|
| 116 | 120 |
title="포인트 기준관리 목록" |
| 117 | 121 |
breadcrumb={[
|
| 118 | 122 |
{ label: '홈', href: '/' },
|
... | ... | @@ -143,12 +147,21 @@ |
| 143 | 147 |
keyword={query.keyword}
|
| 144 | 148 |
searchPlaceholder="검색어를 입력하세요." |
| 145 | 149 |
toolbarActions={
|
| 146 |
- <FoxButton |
|
| 147 |
- type="default" |
|
| 148 |
- size="md" |
|
| 149 |
- label="초기화" |
|
| 150 |
- onAction={() => router.replace(POINT_STANDARDS_PATH)}
|
|
| 151 |
- /> |
|
| 150 |
+ <FoxButtonGroup size="md"> |
|
| 151 |
+ <FoxButton |
|
| 152 |
+ type="default" |
|
| 153 |
+ size="md" |
|
| 154 |
+ label="초기화" |
|
| 155 |
+ onAction={() => router.replace(POINT_STANDARDS_PATH)}
|
|
| 156 |
+ /> |
|
| 157 |
+ <FoxButton |
|
| 158 |
+ type="primary" |
|
| 159 |
+ size="md" |
|
| 160 |
+ leadingIcon={<i className="fox-ico fox-ico-Plus" aria-hidden="true" />}
|
|
| 161 |
+ label="신규 등록" |
|
| 162 |
+ onAction={() => setIsCreateOpen(true)}
|
|
| 163 |
+ /> |
|
| 164 |
+ </FoxButtonGroup> |
|
| 152 | 165 |
} |
| 153 | 166 |
page={currentPage}
|
| 154 | 167 |
totalPages={totalPages}
|
... | ... | @@ -161,5 +174,13 @@ |
| 161 | 174 |
} |
| 162 | 175 |
}} |
| 163 | 176 |
/> |
| 177 |
+ |
|
| 178 |
+ {isCreateOpen && (
|
|
| 179 |
+ <PointStandardFormModal |
|
| 180 |
+ eventCodes={eventCodes}
|
|
| 181 |
+ onClose={() => setIsCreateOpen(false)}
|
|
| 182 |
+ /> |
|
| 183 |
+ )} |
|
| 184 |
+ </> |
|
| 164 | 185 |
); |
| 165 | 186 |
} |
--- app/(protected)/(basic)/points/standards/_components/point-standard-row-actions.tsx
+++ app/(protected)/(basic)/points/standards/_components/point-standard-row-actions.tsx
... | ... | @@ -6,15 +6,18 @@ |
| 6 | 6 |
import { FoxIconButton } from '@fox/core/components/fox-icon-button';
|
| 7 | 7 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 8 | 8 |
import type { PointStandard } from '@/lib/domain/point-standard';
|
| 9 |
+import type { CommonCode } from '@/lib/domain/common-code';
|
|
| 9 | 10 |
import { deletePointStandardAction } from '../_actions';
|
| 10 | 11 |
import { PointStandardFormModal } from './point-standard-form-modal';
|
| 11 | 12 |
|
| 12 | 13 |
interface PointStandardRowActionsProps {
|
| 13 | 14 |
standard: PointStandard; |
| 15 |
+ eventCodes: readonly CommonCode[]; |
|
| 14 | 16 |
} |
| 15 | 17 |
|
| 16 | 18 |
export function PointStandardRowActions({
|
| 17 | 19 |
standard, |
| 20 |
+ eventCodes, |
|
| 18 | 21 |
}: PointStandardRowActionsProps) {
|
| 19 | 22 |
const { showAlert, hideAlert, showToast } = useFeedback();
|
| 20 | 23 |
const [isEditOpen, setIsEditOpen] = useState(false); |
... | ... | @@ -67,6 +70,7 @@ |
| 67 | 70 |
{isEditOpen && (
|
| 68 | 71 |
<PointStandardFormModal |
| 69 | 72 |
standard={standard}
|
| 73 |
+ eventCodes={eventCodes}
|
|
| 70 | 74 |
onClose={() => setIsEditOpen(false)}
|
| 71 | 75 |
/> |
| 72 | 76 |
)} |
--- app/(protected)/(basic)/points/standards/page.tsx
+++ app/(protected)/(basic)/points/standards/page.tsx
... | ... | @@ -1,4 +1,6 @@ |
| 1 | 1 |
import { verifySession } from '@/lib/auth/dal';
|
| 2 |
+import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository';
|
|
| 3 |
+import { CODE_GROUP } from '@/lib/domain/common-code';
|
|
| 2 | 4 |
import { fetchPointStandards } from '@/lib/data/repositories/point-standard-repository';
|
| 3 | 5 |
import { parsePointStandardQuery } from '@/lib/domain/point-standard-query';
|
| 4 | 6 |
import { PointStandardList } from './_components/point-standard-list';
|
... | ... | @@ -14,6 +16,7 @@ |
| 14 | 16 |
|
| 15 | 17 |
const query = parsePointStandardQuery(await searchParams); |
| 16 | 18 |
const { items, totalCount } = await fetchPointStandards(query);
|
| 19 |
+ const eventCodes = await fetchCommonCodes(CODE_GROUP.pointEvent); |
|
| 17 | 20 |
|
| 18 | 21 |
const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize)); |
| 19 | 22 |
const currentPage = Math.min(query.page, totalPages); |
... | ... | @@ -25,6 +28,7 @@ |
| 25 | 28 |
currentPage={currentPage}
|
| 26 | 29 |
totalPages={totalPages}
|
| 27 | 30 |
totalCount={totalCount}
|
| 31 |
+ eventCodes={eventCodes}
|
|
| 28 | 32 |
/> |
| 29 | 33 |
); |
| 30 | 34 |
} |
--- lib/data/repositories/point-standard-repository.ts
+++ lib/data/repositories/point-standard-repository.ts
... | ... | @@ -10,12 +10,12 @@ |
| 10 | 10 |
* |
| 11 | 11 |
* ``` |
| 12 | 12 |
* GET /api/v1/mngr/point/pagination 목록 |
| 13 |
+ * POST /api/v1/mngr/point 어노테이션 없음 → form |
|
| 13 | 14 |
* PUT /api/v1/mngr/point/{eventPtSn} @RequestBody(JSON)
|
| 14 | 15 |
* DELETE /api/v1/mngr/point/{eventPtSn} soft delete(DEL_YN)
|
| 15 | 16 |
* ``` |
| 16 | 17 |
* |
| 17 |
- * `POST /api/v1/mngr/point`(등록)도 있지만 쓰지 않는다 — 기획상 이벤트는 읽기 전용이라 화면에 |
|
| 18 |
- * 신규 등록이 없다. 등록 화면이 생기면 콘텐츠·퀴즈와 같은 form 규약으로 붙이면 된다. |
|
| 18 |
+ * 등록은 form, 수정은 JSON으로 갈린다 — 콘텐츠·퀴즈와 같은 규약이다. |
|
| 19 | 19 |
* |
| 20 | 20 |
* ⚠️ 백엔드 결함(보고함, 미수정) |
| 21 | 21 |
* 1. **`MngrPointPolicyVo`에 `eventPtSn` 말고는 필드가 없다.** 목록 SQL은 EVENT_CD· |
... | ... | @@ -69,8 +69,6 @@ |
| 69 | 69 |
point: readNumber(raw, 'grantPointAmt'), |
| 70 | 70 |
limitPoint: readNumber(raw, 'dayLmtPointAmt'), |
| 71 | 71 |
isDuplicable: readString(raw, 'duplYn') !== 'N', |
| 72 |
- // 이슈: 사이트 구분 컬럼이 백엔드에 없다. 화면은 기획대로 열을 두고 값은 비워 둔다. |
|
| 73 |
- siteCode: '', |
|
| 74 | 72 |
description: readString(raw, 'eventExpln'), |
| 75 | 73 |
isActive: readString(raw, 'useYn') !== 'N', |
| 76 | 74 |
updatedAt: |
... | ... | @@ -154,12 +152,9 @@ |
| 154 | 152 |
useYn: string; |
| 155 | 153 |
}; |
| 156 | 154 |
|
| 157 |
-function buildPayload( |
|
| 158 |
- values: PointStandardValues, |
|
| 159 |
- eventCode: string |
|
| 160 |
-): PointStandardPayload {
|
|
| 155 |
+function buildPayload(values: PointStandardValues): PointStandardPayload {
|
|
| 161 | 156 |
return {
|
| 162 |
- eventCd: eventCode, |
|
| 157 |
+ eventCd: values.eventCode, |
|
| 163 | 158 |
grantPointAmt: String(values.point), |
| 164 | 159 |
dayLmtPointAmt: String(values.limitPoint), |
| 165 | 160 |
duplYn: values.isDuplicable ? 'Y' : 'N', |
... | ... | @@ -170,14 +165,16 @@ |
| 170 | 165 |
|
| 171 | 166 |
async function sendWrite( |
| 172 | 167 |
path: string, |
| 173 |
- method: 'PUT' | 'DELETE', |
|
| 174 |
- body?: unknown |
|
| 168 |
+ method: 'POST' | 'PUT' | 'DELETE', |
|
| 169 |
+ body?: unknown, |
|
| 170 |
+ form?: Record<string, string | number | undefined> |
|
| 175 | 171 |
): Promise<void> {
|
| 176 | 172 |
const accessToken = await getSessionAccessToken(); |
| 177 | 173 |
|
| 178 | 174 |
const result = await backendFetch<unknown>(path, {
|
| 179 | 175 |
method, |
| 180 | 176 |
...(body === undefined ? {} : { body }),
|
| 177 |
+ ...(form === undefined ? {} : { form }),
|
|
| 181 | 178 |
accessToken: accessToken ?? undefined, |
| 182 | 179 |
cache: 'no-store', |
| 183 | 180 |
canHaveNullData: true, |
... | ... | @@ -189,14 +186,25 @@ |
| 189 | 186 |
} |
| 190 | 187 |
} |
| 191 | 188 |
|
| 189 |
+/** |
|
| 190 |
+ * 등록은 `@RequestBody`가 없어 form으로 간다. |
|
| 191 |
+ * |
|
| 192 |
+ * TODO: 백엔드가 `insert`에 `@RequestBody`를 붙이면 수정과 같은 JSON으로 통일한다. 반영 여부는 |
|
| 193 |
+ * `/v3/api-docs`에서 이 경로의 POST에 `requestBody`가 생겼는지로 확인한다. |
|
| 194 |
+ */ |
|
| 195 |
+export async function createPointStandard( |
|
| 196 |
+ values: PointStandardValues |
|
| 197 |
+): Promise<void> {
|
|
| 198 |
+ await sendWrite(POINT_PATH, 'POST', undefined, { ...buildPayload(values) });
|
|
| 199 |
+} |
|
| 200 |
+ |
|
| 192 | 201 |
export async function updatePointStandard( |
| 193 |
- values: PointStandardValues, |
|
| 194 |
- eventCode: string |
|
| 202 |
+ values: PointStandardValues |
|
| 195 | 203 |
): Promise<void> {
|
| 196 | 204 |
await sendWrite( |
| 197 | 205 |
`${POINT_PATH}/${encodeURIComponent(values.id)}`,
|
| 198 | 206 |
'PUT', |
| 199 |
- buildPayload(values, eventCode) |
|
| 207 |
+ buildPayload(values) |
|
| 200 | 208 |
); |
| 201 | 209 |
} |
| 202 | 210 |
|
--- lib/domain/common-code.ts
+++ lib/domain/common-code.ts
... | ... | @@ -25,6 +25,9 @@ |
| 25 | 25 |
inquiryAnswerStatus: 'BBS_ANS_CD', |
| 26 | 26 |
/** FAQ 유형. */ |
| 27 | 27 |
faqType: 'BBS_FAQ_CD', |
| 28 |
+ // TODO: 포인트 지급기준의 「이벤트」 — 기획이 "등록된 코드중에서 선택"이라 코드관리와 엮인다. |
|
| 29 |
+ // 실제 그룹ID를 받지 못해 잠정값이다. 모르는 그룹은 빈 목록으로 와서 선택지가 비어 보인다. |
|
| 30 |
+ pointEvent: 'EVENT_CD', |
|
| 28 | 31 |
/** 학교(학교급). */ |
| 29 | 32 |
school: 'COM_SCHUL_CD', |
| 30 | 33 |
/** 학년. 학교마다 다르므로 `atrbNm`에 학교 코드를 넣어 조회한다. */ |
--- lib/domain/point-standard-form.ts
+++ lib/domain/point-standard-form.ts
... | ... | @@ -2,7 +2,10 @@ |
| 2 | 2 |
export const DESCRIPTION_MAX_LENGTH = 200; |
| 3 | 3 |
|
| 4 | 4 |
export interface PointStandardValues {
|
| 5 |
+ /** 수정은 기존 값, 등록은 비어 있다(백엔드가 채번). */ |
|
| 5 | 6 |
id: string; |
| 7 |
+ /** 공통코드 `CODE_GROUP.pointEvent`. 등록은 선택, 수정은 읽기 전용으로 그대로 돌려보낸다. */ |
|
| 8 |
+ eventCode: string; |
|
| 6 | 9 |
point: number; |
| 7 | 10 |
limitPoint: number; |
| 8 | 11 |
isDuplicable: boolean; |
... | ... | @@ -11,7 +14,7 @@ |
| 11 | 14 |
} |
| 12 | 15 |
|
| 13 | 16 |
export type PointStandardFormErrors = Partial< |
| 14 |
- Record<'id' | 'point' | 'limitPoint' | 'description', string> |
|
| 17 |
+ Record<'id' | 'eventCode' | 'point' | 'limitPoint' | 'description', string> |
|
| 15 | 18 |
>; |
| 16 | 19 |
|
| 17 | 20 |
export type PointStandardFormState = |
... | ... | @@ -45,13 +48,32 @@ |
| 45 | 48 |
return {};
|
| 46 | 49 |
} |
| 47 | 50 |
|
| 51 |
+/** |
|
| 52 |
+ * 등록·수정 공통 검증. |
|
| 53 |
+ * |
|
| 54 |
+ * `isCreate`로 갈린다 — 등록은 대상 id가 없고(백엔드가 채번) 이벤트를 골라야 하며, 수정은 그 |
|
| 55 |
+ * 반대다(이벤트는 읽기 전용이라 화면이 그대로 돌려보낸다). |
|
| 56 |
+ */ |
|
| 48 | 57 |
export function validatePointStandard( |
| 49 |
- values: PointStandardValues |
|
| 58 |
+ values: PointStandardValues, |
|
| 59 |
+ options: { isCreate: boolean; allowedEventCodes?: readonly string[] }
|
|
| 50 | 60 |
): ValidationResult {
|
| 51 | 61 |
const errors: PointStandardFormErrors = {};
|
| 52 | 62 |
|
| 53 |
- if (!values.id.trim()) {
|
|
| 63 |
+ if (!options.isCreate && !values.id.trim()) {
|
|
| 54 | 64 |
errors.id = '수정할 대상을 찾을 수 없습니다.'; |
| 65 |
+ } |
|
| 66 |
+ |
|
| 67 |
+ const eventCode = values.eventCode.trim(); |
|
| 68 |
+ if (!eventCode) {
|
|
| 69 |
+ errors.eventCode = '이벤트를 선택해 주세요.'; |
|
| 70 |
+ } else if ( |
|
| 71 |
+ options.isCreate && |
|
| 72 |
+ options.allowedEventCodes && |
|
| 73 |
+ options.allowedEventCodes.length > 0 && |
|
| 74 |
+ !options.allowedEventCodes.includes(eventCode) |
|
| 75 |
+ ) {
|
|
| 76 |
+ errors.eventCode = '이벤트를 다시 선택해 주세요.'; |
|
| 55 | 77 |
} |
| 56 | 78 |
|
| 57 | 79 |
const point = validatePointNumber(values.point, '포인트'); |
... | ... | @@ -79,6 +101,7 @@ |
| 79 | 101 |
ok: true, |
| 80 | 102 |
values: {
|
| 81 | 103 |
id: values.id.trim(), |
| 104 |
+ eventCode, |
|
| 82 | 105 |
point: values.point, |
| 83 | 106 |
limitPoint: values.limitPoint, |
| 84 | 107 |
isDuplicable: values.isDuplicable, |
--- lib/domain/point-standard.ts
+++ lib/domain/point-standard.ts
... | ... | @@ -4,7 +4,6 @@ |
| 4 | 4 |
point: number; |
| 5 | 5 |
limitPoint: number; |
| 6 | 6 |
isDuplicable: boolean; |
| 7 |
- siteCode: string; |
|
| 8 | 7 |
description: string; |
| 9 | 8 |
isActive: boolean; |
| 10 | 9 |
updatedAt: string; |
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?