Merge branch 'hub' into integration/logout-token-guard
@a4b552eba6a50d619744bc4fc25d7ecf2829a69a
+++ app/(protected)/(basic)/admins/_actions.ts
... | ... | @@ -0,0 +1,219 @@ |
| 1 | +'use server'; | |
| 2 | + | |
| 3 | +import { revalidatePath } from 'next/cache'; | |
| 4 | +import { verifySession } from '@/lib/auth/dal'; | |
| 5 | +import { | |
| 6 | + createAdminMember, | |
| 7 | + deleteAdminMember, | |
| 8 | + isAdminLoginIdTaken, | |
| 9 | + updateAdminMember, | |
| 10 | +} from '@/lib/data/repositories/admin-member-repository'; | |
| 11 | +import { ADMIN_MENU_OPTIONS } from '@/lib/domain/admin-member'; | |
| 12 | +import { | |
| 13 | + joinPhoneNumber, | |
| 14 | + validateAdminLoginId, | |
| 15 | + validateAdminMemberCreate, | |
| 16 | + validateAdminMemberUpdate, | |
| 17 | + type AdminMemberEditableValues, | |
| 18 | + type AdminMemberFormErrors, | |
| 19 | +} from '@/lib/domain/admin-member-form'; | |
| 20 | +import { ADMIN_MEMBERS_PATH } from '@/lib/domain/admin-member-query'; | |
| 21 | + | |
| 22 | +/** | |
| 23 | + * 관리자 회원 등록/수정/삭제 Server Action. | |
| 24 | + * | |
| 25 | + * **모든 Action이 `verifySession()`으로 시작한다** — Server Action은 UI를 거치지 않고 직접 | |
| 26 | + * POST될 수 있어 이 확인이 유일한 최종 방어선이다(설계서 §8). | |
| 27 | + * | |
| 28 | + * 검증은 화면이 아니라 여기서 확정한다(`lib/domain/admin-member-form.ts`의 규칙을 호출) — | |
| 29 | + * 화면의 required 속성은 편의일 뿐 신뢰 경계가 아니다. | |
| 30 | + * | |
| 31 | + * 실제 저장은 Repository에 맡긴다. 백엔드에 등록/수정/삭제 API가 아직 없어 Repository가 mock | |
| 32 | + * 저장소로 위임하고 있지만, **이 파일은 그 사실을 알지 못한다** — 백엔드 API가 생겨도 이 파일은 | |
| 33 | + * 바뀌지 않는다. | |
| 34 | + */ | |
| 35 | + | |
| 36 | +export type AdminMemberFormState = | |
| 37 | + | { status: 'idle' } | |
| 38 | + | { status: 'error'; message?: string; errors?: AdminMemberFormErrors } | |
| 39 | + | { status: 'success' }; | |
| 40 | + | |
| 41 | +export const INITIAL_ADMIN_MEMBER_FORM_STATE: AdminMemberFormState = { | |
| 42 | + status: 'idle', | |
| 43 | +}; | |
| 44 | + | |
| 45 | +const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.'; | |
| 46 | +const SELF_DELETE_MESSAGE = '현재 로그인한 본인 계정은 삭제할 수 없습니다.'; | |
| 47 | +const DUPLICATE_LOGIN_ID_MESSAGE = '이미 사용 중인 ID입니다.'; | |
| 48 | + | |
| 49 | +function readString(formData: FormData, key: string): string { | |
| 50 | + const value = formData.get(key); | |
| 51 | + return typeof value === 'string' ? value : ''; | |
| 52 | +} | |
| 53 | + | |
| 54 | +/** 체크박스처럼 같은 이름으로 여러 번 오는 값 — 허용 목록에 있는 것만 남긴다. */ | |
| 55 | +function readMenuCodes(formData: FormData): string[] { | |
| 56 | + return formData | |
| 57 | + .getAll('menuCodes') | |
| 58 | + .filter((value): value is string => typeof value === 'string') | |
| 59 | + .filter((value) => | |
| 60 | + ADMIN_MENU_OPTIONS.some((option) => option.value === value) | |
| 61 | + ); | |
| 62 | +} | |
| 63 | + | |
| 64 | +/** | |
| 65 | + * 등록·수정이 공유하는 입력 항목을 읽는다. 휴대전화번호는 시안대로 3칸으로 나뉘어 오므로 여기서 | |
| 66 | + * 하나의 값으로 합친다 — 형식이 어긋나면 빈 문자열이 되어 검증에서 걸린다. | |
| 67 | + */ | |
| 68 | +function readEditableValues(formData: FormData): AdminMemberEditableValues { | |
| 69 | + const phoneNumber = joinPhoneNumber([ | |
| 70 | + readString(formData, 'phoneNumber1'), | |
| 71 | + readString(formData, 'phoneNumber2'), | |
| 72 | + readString(formData, 'phoneNumber3'), | |
| 73 | + ]); | |
| 74 | + | |
| 75 | + return { | |
| 76 | + password: readString(formData, 'password'), | |
| 77 | + phoneNumber: phoneNumber ?? '', | |
| 78 | + email: readString(formData, 'email'), | |
| 79 | + roleCode: readString(formData, 'roleCode'), | |
| 80 | + menuCodes: readMenuCodes(formData), | |
| 81 | + }; | |
| 82 | +} | |
| 83 | + | |
| 84 | +/** 시안 ADM_ADM_102_p — 관리자 등록. */ | |
| 85 | +export async function createAdminMemberAction( | |
| 86 | + _prevState: AdminMemberFormState, | |
| 87 | + formData: FormData | |
| 88 | +): Promise<AdminMemberFormState> { | |
| 89 | + await verifySession(); | |
| 90 | + | |
| 91 | + const validation = validateAdminMemberCreate({ | |
| 92 | + ...readEditableValues(formData), | |
| 93 | + name: readString(formData, 'name'), | |
| 94 | + loginId: readString(formData, 'loginId'), | |
| 95 | + }); | |
| 96 | + | |
| 97 | + if (!validation.ok) { | |
| 98 | + return { status: 'error', errors: validation.errors }; | |
| 99 | + } | |
| 100 | + | |
| 101 | + const { name, loginId, phoneNumber, email, roleCode, menuCodes } = | |
| 102 | + validation.values; | |
| 103 | + | |
| 104 | + // 화면의 [중복 확인]은 편의 기능일 뿐이라 저장 직전에 다시 확인한다 — 확인을 누르지 않고 | |
| 105 | + // 직접 제출하는 경로가 열려 있고, 확인 후 저장까지의 사이에 선점될 수도 있다. | |
| 106 | + if (await isAdminLoginIdTaken(loginId)) { | |
| 107 | + return { status: 'error', errors: { loginId: DUPLICATE_LOGIN_ID_MESSAGE } }; | |
| 108 | + } | |
| 109 | + | |
| 110 | + // 비밀번호는 형식만 검증하고 값은 넘기지 않는다 — 저장할 백엔드 API가 아직 없고, mock | |
| 111 | + // 저장소에 평문 비밀번호를 보관하지 않기 때문이다(`admin-member-store.ts` 주석 참조). | |
| 112 | + await createAdminMember({ | |
| 113 | + name, | |
| 114 | + loginId, | |
| 115 | + phoneNumber, | |
| 116 | + email, | |
| 117 | + roleCode, | |
| 118 | + menuCodes, | |
| 119 | + }); | |
| 120 | + | |
| 121 | + revalidatePath(ADMIN_MEMBERS_PATH); | |
| 122 | + return { status: 'success' }; | |
| 123 | +} | |
| 124 | + | |
| 125 | +/** 시안 ADM_ADM_103_p — 관리자 수정. 이름·ID는 읽기 전용이라 변경 대상이 아니다. */ | |
| 126 | +export async function updateAdminMemberAction( | |
| 127 | + _prevState: AdminMemberFormState, | |
| 128 | + formData: FormData | |
| 129 | +): Promise<AdminMemberFormState> { | |
| 130 | + await verifySession(); | |
| 131 | + | |
| 132 | + const id = readString(formData, 'id'); | |
| 133 | + if (!id) { | |
| 134 | + return { status: 'error', message: INVALID_REQUEST_MESSAGE }; | |
| 135 | + } | |
| 136 | + | |
| 137 | + const validation = validateAdminMemberUpdate(readEditableValues(formData)); | |
| 138 | + if (!validation.ok) { | |
| 139 | + return { status: 'error', errors: validation.errors }; | |
| 140 | + } | |
| 141 | + | |
| 142 | + const { phoneNumber, email, roleCode, menuCodes } = validation.values; | |
| 143 | + | |
| 144 | + await updateAdminMember(id, { | |
| 145 | + phoneNumber, | |
| 146 | + email, | |
| 147 | + roleCode, | |
| 148 | + menuCodes, | |
| 149 | + // 시안 ③의 "잠김여부"는 활성/비활성으로 표기되고 활성이 곧 잠기지 않은 상태다. | |
| 150 | + isLocked: readString(formData, 'isLocked') === 'true', | |
| 151 | + }); | |
| 152 | + | |
| 153 | + revalidatePath(ADMIN_MEMBERS_PATH); | |
| 154 | + return { status: 'success' }; | |
| 155 | +} | |
| 156 | + | |
| 157 | +/** | |
| 158 | + * 시안 ADM_ADM_101 ⑤ — 삭제. 확인 얼럿은 화면이 띄우고, 여기서는 권한과 자기 계정만 확인한다. | |
| 159 | + * | |
| 160 | + * 폼 제출이 아니라 얼럿의 [삭제] 클릭에 반응하는 단발 호출이라 `useActionState`의 | |
| 161 | + * (prevState, formData) 규약 대신 id를 직접 받는다. | |
| 162 | + */ | |
| 163 | +export async function deleteAdminMemberAction( | |
| 164 | + id: string | |
| 165 | +): Promise<AdminMemberFormState> { | |
| 166 | + const admin = await verifySession(); | |
| 167 | + | |
| 168 | + if (!id) { | |
| 169 | + return { status: 'error', message: INVALID_REQUEST_MESSAGE }; | |
| 170 | + } | |
| 171 | + | |
| 172 | + // 시안의 "최고관리자 본인 계정 삭제 방지". 세션의 adminId는 백엔드 accessToken의 `adminId` | |
| 173 | + // 클레임이고 그 값이 곧 목록의 `admUserId`라(EgovJwtTokenUtil.generateAccessToken) 그대로 | |
| 174 | + // 비교할 수 있다. 역할과 무관하게 "로그인한 본인"을 막는다 — 자기 계정을 지워 스스로 | |
| 175 | + // 로그인 불가 상태가 되는 것은 어떤 역할이든 사고이기 때문이다. | |
| 176 | + if (id === admin.id) { | |
| 177 | + return { status: 'error', message: SELF_DELETE_MESSAGE }; | |
| 178 | + } | |
| 179 | + | |
| 180 | + await deleteAdminMember(id); | |
| 181 | + | |
| 182 | + revalidatePath(ADMIN_MEMBERS_PATH); | |
| 183 | + return { status: 'success' }; | |
| 184 | +} | |
| 185 | + | |
| 186 | +export type LoginIdCheckResult = | |
| 187 | + | { status: 'idle' } | |
| 188 | + | { status: 'available'; loginId: string } | |
| 189 | + | { status: 'unavailable'; message: string }; | |
| 190 | + | |
| 191 | +/** | |
| 192 | + * 시안 ADM_ADM_102_p ① — ID 중복 확인. | |
| 193 | + * | |
| 194 | + * 폼 제출이 아니라 버튼 클릭에 반응하는 단발 호출이라 `useActionState`의 (prevState, formData) | |
| 195 | + * 규약 대신 값을 직접 받는다 — 등록 폼 안에 또 다른 폼을 중첩할 수 없기 때문이다(HTML 제약). | |
| 196 | + * 클라이언트에서 일반 함수처럼 `await` 한다. | |
| 197 | + * | |
| 198 | + * 형식이 맞지 않는 ID는 중복 여부를 물을 필요도 없이 되돌린다. 확인에 성공하면 **검사한 ID를 함께** | |
| 199 | + * 돌려주는데, 화면이 "확인한 ID"와 "지금 입력창의 ID"를 비교해 확인 후 값을 고친 경우를 잡아내기 | |
| 200 | + * 위해서다. | |
| 201 | + */ | |
| 202 | +export async function checkAdminLoginId( | |
| 203 | + loginId: string | |
| 204 | +): Promise<LoginIdCheckResult> { | |
| 205 | + await verifySession(); | |
| 206 | + | |
| 207 | + const normalized = loginId.trim(); | |
| 208 | + | |
| 209 | + const formatError = validateAdminLoginId(normalized); | |
| 210 | + if (formatError) { | |
| 211 | + return { status: 'unavailable', message: formatError }; | |
| 212 | + } | |
| 213 | + | |
| 214 | + if (await isAdminLoginIdTaken(normalized)) { | |
| 215 | + return { status: 'unavailable', message: DUPLICATE_LOGIN_ID_MESSAGE }; | |
| 216 | + } | |
| 217 | + | |
| 218 | + return { status: 'available', loginId: normalized }; | |
| 219 | +} |
+++ app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx
... | ... | @@ -0,0 +1,164 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useActionState, useEffect, useState, useTransition } 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 { | |
| 10 | + ADMIN_LOGIN_ID_HELP_TEXT, | |
| 11 | + ADMIN_PASSWORD_HELP_TEXT, | |
| 12 | +} from '@/lib/domain/admin-member-form'; | |
| 13 | +import { | |
| 14 | + INITIAL_ADMIN_MEMBER_FORM_STATE, | |
| 15 | + checkAdminLoginId, | |
| 16 | + createAdminMemberAction, | |
| 17 | + type LoginIdCheckResult, | |
| 18 | +} from '../_actions'; | |
| 19 | +import { | |
| 20 | + AdminMemberFormFields, | |
| 21 | + FieldError, | |
| 22 | +} from './admin-member-form-fields'; | |
| 23 | + | |
| 24 | +interface AdminMemberCreateModalProps { | |
| 25 | + onClose: () => void; | |
| 26 | +} | |
| 27 | + | |
| 28 | +const FORM_ID = 'admin-member-create-form'; | |
| 29 | + | |
| 30 | +/** | |
| 31 | + * 관리자 등록 팝업(시안 ADM_ADM_102_p). | |
| 32 | + * | |
| 33 | + * **ID는 중복 확인을 통과해야 저장할 수 있다**(시안 ①). 확인은 등록 폼과 별개의 서버 호출인데, | |
| 34 | + * 폼 안에 폼을 중첩할 수 없어 `useActionState` 대신 값을 직접 넘기는 Server Action | |
| 35 | + * (`checkAdminLoginId`)을 `useTransition`으로 호출한다. 확인 후 사용자가 ID를 고칠 수 있으므로 | |
| 36 | + * "확인에 성공한 ID"와 "지금 입력창의 값"이 같을 때만 통과로 본다 — 확인만 받아 두고 다른 ID로 | |
| 37 | + * 바꿔 저장하는 경로를 막기 위해서다. 그래도 최종 방어선은 Server Action의 재확인이다. | |
| 38 | + * | |
| 39 | + * 저장 버튼은 footer 슬롯에서 `form={FORM_ID}` 속성으로 폼과 연결한다 — 네이티브 HTML의 `form` | |
| 40 | + * 속성은 버튼이 실제 DOM상 form의 자손이 아니어도 같은 문서 안에서 id만 일치하면 그 form을 | |
| 41 | + * 제출한다. | |
| 42 | + * | |
| 43 | + * 비밀번호는 형식만 검증하고 저장되지 않는다 — 백엔드에 등록 API가 없어 지금은 mock 저장소가 | |
| 44 | + * 받는데, 평문 비밀번호를 메모리에 들고 있지 않기로 했기 때문이다(Repository·mock 주석 참조). | |
| 45 | + */ | |
| 46 | +export function AdminMemberCreateModal({ | |
| 47 | + onClose, | |
| 48 | +}: AdminMemberCreateModalProps) { | |
| 49 | + const { showToast } = useFeedback(); | |
| 50 | + const [state, formAction, isPending] = useActionState( | |
| 51 | + createAdminMemberAction, | |
| 52 | + INITIAL_ADMIN_MEMBER_FORM_STATE | |
| 53 | + ); | |
| 54 | + | |
| 55 | + const [loginId, setLoginId] = useState(''); | |
| 56 | + const [checkResult, setCheckResult] = useState<LoginIdCheckResult>({ | |
| 57 | + status: 'idle', | |
| 58 | + }); | |
| 59 | + const [isChecking, startChecking] = useTransition(); | |
| 60 | + | |
| 61 | + useEffect(() => { | |
| 62 | + if (state.status === 'success') { | |
| 63 | + showToast({ variant: 'success', message: '관리자를 등록했습니다.' }); | |
| 64 | + onClose(); | |
| 65 | + } | |
| 66 | + }, [state, showToast, onClose]); | |
| 67 | + | |
| 68 | + const isLoginIdVerified = | |
| 69 | + checkResult.status === 'available' && | |
| 70 | + checkResult.loginId === loginId.trim(); | |
| 71 | + | |
| 72 | + function handleCheckLoginId() { | |
| 73 | + startChecking(async () => { | |
| 74 | + setCheckResult(await checkAdminLoginId(loginId)); | |
| 75 | + }); | |
| 76 | + } | |
| 77 | + | |
| 78 | + const errors = state.status === 'error' ? (state.errors ?? {}) : {}; | |
| 79 | + | |
| 80 | + return ( | |
| 81 | + <Modal | |
| 82 | + title="관리자 등록" | |
| 83 | + onClose={onClose} | |
| 84 | + footer={ | |
| 85 | + <> | |
| 86 | + <Button type="button" variant="ghost" onClick={onClose}> | |
| 87 | + 취소 | |
| 88 | + </Button> | |
| 89 | + <Button | |
| 90 | + type="submit" | |
| 91 | + form={FORM_ID} | |
| 92 | + variant="primary" | |
| 93 | + disabled={isPending || !isLoginIdVerified} | |
| 94 | + title={isLoginIdVerified ? undefined : 'ID 중복 확인이 필요합니다.'} | |
| 95 | + > | |
| 96 | + {isPending ? '저장 중...' : '저장'} | |
| 97 | + </Button> | |
| 98 | + </> | |
| 99 | + } | |
| 100 | + > | |
| 101 | + <form id={FORM_ID} action={formAction} className="flex flex-col gap-4"> | |
| 102 | + <p className="text-right text-body-sm text-danger"> | |
| 103 | + * 는 필수 항목입니다. | |
| 104 | + </p> | |
| 105 | + | |
| 106 | + <Field label="이름 *"> | |
| 107 | + <Input type="text" name="name" placeholder="이름을 입력하세요." /> | |
| 108 | + </Field> | |
| 109 | + <FieldError message={errors.name} /> | |
| 110 | + | |
| 111 | + <Field label="ID * (중복확인 필수)"> | |
| 112 | + <div className="flex items-center gap-2"> | |
| 113 | + <Input | |
| 114 | + type="text" | |
| 115 | + name="loginId" | |
| 116 | + value={loginId} | |
| 117 | + onChange={(event) => setLoginId(event.target.value)} | |
| 118 | + placeholder="관리자 ID를 입력하세요." | |
| 119 | + autoComplete="off" | |
| 120 | + /> | |
| 121 | + <Button | |
| 122 | + type="button" | |
| 123 | + variant="secondary" | |
| 124 | + onClick={handleCheckLoginId} | |
| 125 | + disabled={isChecking || loginId.trim().length === 0} | |
| 126 | + className="shrink-0" | |
| 127 | + > | |
| 128 | + {isChecking ? '확인 중...' : '중복 확인'} | |
| 129 | + </Button> | |
| 130 | + </div> | |
| 131 | + </Field> | |
| 132 | + <p className="text-body-sm text-foreground-muted"> | |
| 133 | + {ADMIN_LOGIN_ID_HELP_TEXT} | |
| 134 | + </p> | |
| 135 | + {checkResult.status === 'unavailable' && ( | |
| 136 | + <FieldError message={checkResult.message} /> | |
| 137 | + )} | |
| 138 | + {isLoginIdVerified && ( | |
| 139 | + <p className="text-body-sm text-success">사용할 수 있는 ID입니다.</p> | |
| 140 | + )} | |
| 141 | + <FieldError message={errors.loginId} /> | |
| 142 | + | |
| 143 | + <Field label="비밀번호 *"> | |
| 144 | + <Input | |
| 145 | + type="password" | |
| 146 | + name="password" | |
| 147 | + placeholder="비밀번호를 입력하세요." | |
| 148 | + autoComplete="new-password" | |
| 149 | + /> | |
| 150 | + </Field> | |
| 151 | + <p className="text-body-sm text-foreground-muted"> | |
| 152 | + {ADMIN_PASSWORD_HELP_TEXT} | |
| 153 | + </p> | |
| 154 | + <FieldError message={errors.password} /> | |
| 155 | + | |
| 156 | + <AdminMemberFormFields errors={errors} /> | |
| 157 | + | |
| 158 | + {state.status === 'error' && state.message && ( | |
| 159 | + <p className="text-body-sm text-danger">{state.message}</p> | |
| 160 | + )} | |
| 161 | + </form> | |
| 162 | + </Modal> | |
| 163 | + ); | |
| 164 | +} |
+++ app/(protected)/(basic)/admins/_components/admin-member-edit-modal.tsx
... | ... | @@ -0,0 +1,150 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useActionState, useEffect, useState } 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 { Select } from '@/components/ui/select'; | |
| 9 | +import { useFeedback } from '@/app/_hooks/use-feedback'; | |
| 10 | +import { | |
| 11 | + ADMIN_LOCK_STATUS_OPTIONS, | |
| 12 | + type AdminMember, | |
| 13 | +} from '@/lib/domain/admin-member'; | |
| 14 | +import { ADMIN_PASSWORD_HELP_TEXT } from '@/lib/domain/admin-member-form'; | |
| 15 | +import { | |
| 16 | + INITIAL_ADMIN_MEMBER_FORM_STATE, | |
| 17 | + updateAdminMemberAction, | |
| 18 | +} from '../_actions'; | |
| 19 | +import { | |
| 20 | + AdminMemberFormFields, | |
| 21 | + FieldError, | |
| 22 | +} from './admin-member-form-fields'; | |
| 23 | + | |
| 24 | +interface AdminMemberEditModalProps { | |
| 25 | + member: AdminMember; | |
| 26 | + onClose: () => void; | |
| 27 | +} | |
| 28 | + | |
| 29 | +const FORM_ID = 'admin-member-edit-form'; | |
| 30 | + | |
| 31 | +/** | |
| 32 | + * 관리자 수정 팝업(시안 ADM_ADM_103_p). | |
| 33 | + * | |
| 34 | + * 이름·ID는 읽기 전용이다(시안 ①). 다만 화면의 readOnly는 표시일 뿐이라 Server Action은 두 값을 | |
| 35 | + * 아예 반영하지 않는다 — 직접 POST로 다른 값을 보내도 바뀌지 않는다. | |
| 36 | + * | |
| 37 | + * **비밀번호는 [비밀번호 변경]을 눌러야 입력할 수 있다**(시안 ②). 비워 두면 기존 비밀번호를 | |
| 38 | + * 유지한다는 뜻이라 Server Action도 그때는 형식 검증을 건너뛴다. 지금은 저장할 백엔드 API가 없어 | |
| 39 | + * 입력값이 검증 후 버려진다 — 흐름만 완성해 둔 상태다. | |
| 40 | + * | |
| 41 | + * "잠김여부"는 활성/비활성으로 표기되지만 백엔드 필드는 `acctLockYn`(잠김 여부)이라 의미가 | |
| 42 | + * 뒤집혀 있다. 그 변환은 도메인(`ADMIN_LOCK_STATUS_OPTIONS`)이 한 곳에서 책임진다. | |
| 43 | + */ | |
| 44 | +export function AdminMemberEditModal({ | |
| 45 | + member, | |
| 46 | + onClose, | |
| 47 | +}: AdminMemberEditModalProps) { | |
| 48 | + const { showToast } = useFeedback(); | |
| 49 | + const [state, formAction, isPending] = useActionState( | |
| 50 | + updateAdminMemberAction, | |
| 51 | + INITIAL_ADMIN_MEMBER_FORM_STATE | |
| 52 | + ); | |
| 53 | + | |
| 54 | + const [isPasswordEditable, setIsPasswordEditable] = useState(false); | |
| 55 | + | |
| 56 | + useEffect(() => { | |
| 57 | + if (state.status === 'success') { | |
| 58 | + showToast({ variant: 'success', message: '관리자 정보를 수정했습니다.' }); | |
| 59 | + onClose(); | |
| 60 | + } | |
| 61 | + }, [state, showToast, onClose]); | |
| 62 | + | |
| 63 | + const errors = state.status === 'error' ? (state.errors ?? {}) : {}; | |
| 64 | + | |
| 65 | + return ( | |
| 66 | + <Modal | |
| 67 | + title="관리자 수정" | |
| 68 | + onClose={onClose} | |
| 69 | + footer={ | |
| 70 | + <> | |
| 71 | + <Button type="button" variant="ghost" onClick={onClose}> | |
| 72 | + 취소 | |
| 73 | + </Button> | |
| 74 | + <Button | |
| 75 | + type="submit" | |
| 76 | + form={FORM_ID} | |
| 77 | + variant="primary" | |
| 78 | + disabled={isPending} | |
| 79 | + > | |
| 80 | + {isPending ? '수정 중...' : '수정'} | |
| 81 | + </Button> | |
| 82 | + </> | |
| 83 | + } | |
| 84 | + > | |
| 85 | + <form id={FORM_ID} action={formAction} className="flex flex-col gap-4"> | |
| 86 | + {/* 수정 대상을 가리키는 유일한 입력. 이름·ID는 읽기 전용이라 아예 제출하지 않는다 — | |
| 87 | + Server Action도 그 둘을 읽지 않으므로 위조해 보내도 반영되지 않는다. */} | |
| 88 | + <input type="hidden" name="id" value={member.id} /> | |
| 89 | + | |
| 90 | + <p className="text-right text-body-sm text-danger"> | |
| 91 | + * 는 필수 항목입니다. | |
| 92 | + </p> | |
| 93 | + | |
| 94 | + <Field label="이름"> | |
| 95 | + <Input type="text" value={member.name} readOnly /> | |
| 96 | + </Field> | |
| 97 | + | |
| 98 | + <Field label="ID"> | |
| 99 | + <Input type="text" value={member.loginId} readOnly /> | |
| 100 | + </Field> | |
| 101 | + | |
| 102 | + <Field label="비밀번호"> | |
| 103 | + <div className="flex items-center gap-2"> | |
| 104 | + <Input | |
| 105 | + type="password" | |
| 106 | + name="password" | |
| 107 | + placeholder="비밀번호를 입력하세요." | |
| 108 | + autoComplete="new-password" | |
| 109 | + disabled={!isPasswordEditable} | |
| 110 | + /> | |
| 111 | + <Button | |
| 112 | + type="button" | |
| 113 | + variant="secondary" | |
| 114 | + onClick={() => setIsPasswordEditable(true)} | |
| 115 | + disabled={isPasswordEditable} | |
| 116 | + className="shrink-0" | |
| 117 | + > | |
| 118 | + 비밀번호 변경 | |
| 119 | + </Button> | |
| 120 | + </div> | |
| 121 | + </Field> | |
| 122 | + {isPasswordEditable && ( | |
| 123 | + <p className="text-body-sm text-foreground-muted"> | |
| 124 | + {ADMIN_PASSWORD_HELP_TEXT} | |
| 125 | + </p> | |
| 126 | + )} | |
| 127 | + <FieldError message={errors.password} /> | |
| 128 | + | |
| 129 | + <AdminMemberFormFields member={member} errors={errors} /> | |
| 130 | + | |
| 131 | + <Field label="잠김여부"> | |
| 132 | + <Select | |
| 133 | + name="isLocked" | |
| 134 | + defaultValue={String(member.isLocked ?? false)} | |
| 135 | + > | |
| 136 | + {ADMIN_LOCK_STATUS_OPTIONS.map((option) => ( | |
| 137 | + <option key={option.value} value={option.value}> | |
| 138 | + {option.label} | |
| 139 | + </option> | |
| 140 | + ))} | |
| 141 | + </Select> | |
| 142 | + </Field> | |
| 143 | + | |
| 144 | + {state.status === 'error' && state.message && ( | |
| 145 | + <p className="text-body-sm text-danger">{state.message}</p> | |
| 146 | + )} | |
| 147 | + </form> | |
| 148 | + </Modal> | |
| 149 | + ); | |
| 150 | +} |
+++ app/(protected)/(basic)/admins/_components/admin-member-form-fields.tsx
... | ... | @@ -0,0 +1,110 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { Field } from '@/components/ui/field'; | |
| 4 | +import { Input } from '@/components/ui/input'; | |
| 5 | +import { Select } from '@/components/ui/select'; | |
| 6 | +import { | |
| 7 | + ADMIN_ROLE_OPTIONS, | |
| 8 | + DEFAULT_ADMIN_ROLE_CODE, | |
| 9 | + type AdminMember, | |
| 10 | +} from '@/lib/domain/admin-member'; | |
| 11 | +import { | |
| 12 | + splitPhoneNumber, | |
| 13 | + type AdminMemberFormErrors, | |
| 14 | +} from '@/lib/domain/admin-member-form'; | |
| 15 | +import { AdminMenuPicker } from './admin-menu-picker'; | |
| 16 | + | |
| 17 | +interface AdminMemberFormFieldsProps { | |
| 18 | + /** 수정 팝업의 기존 값. 등록 팝업은 넘기지 않는다(빈 폼). */ | |
| 19 | + member?: AdminMember; | |
| 20 | + errors: AdminMemberFormErrors; | |
| 21 | +} | |
| 22 | + | |
| 23 | +/** | |
| 24 | + * 등록·수정 팝업이 공유하는 입력 항목 — 휴대전화 번호 / 이메일 / 역할 선택 / 메뉴 선택. | |
| 25 | + * 두 시안(ADM_ADM_102_p ②③ / 103_p ②)에서 규칙이 같은 부분만 모았다. 서로 다른 부분 | |
| 26 | + * (이름·ID의 편집 가능 여부, 비밀번호 입력 방식, 잠김여부)은 각 팝업이 직접 그린다. | |
| 27 | + * | |
| 28 | + * 휴대전화 번호는 시안대로 3칸으로 나눠 받고 Server Action이 하나로 합친다 | |
| 29 | + * (`joinPhoneNumber`) — 칸을 나누는 것은 화면 표현이고, 저장 형식은 도메인이 정한다. | |
| 30 | + */ | |
| 31 | +export function AdminMemberFormFields({ | |
| 32 | + member, | |
| 33 | + errors, | |
| 34 | +}: AdminMemberFormFieldsProps) { | |
| 35 | + const phoneParts = splitPhoneNumber(member?.phoneNumber ?? null); | |
| 36 | + | |
| 37 | + return ( | |
| 38 | + <> | |
| 39 | + <Field label="휴대전화 번호 *"> | |
| 40 | + <div className="flex items-center gap-2"> | |
| 41 | + <Input | |
| 42 | + type="tel" | |
| 43 | + name="phoneNumber1" | |
| 44 | + defaultValue={phoneParts[0]} | |
| 45 | + maxLength={3} | |
| 46 | + inputMode="numeric" | |
| 47 | + aria-label="휴대전화 번호 앞자리" | |
| 48 | + placeholder="010" | |
| 49 | + /> | |
| 50 | + <Input | |
| 51 | + type="tel" | |
| 52 | + name="phoneNumber2" | |
| 53 | + defaultValue={phoneParts[1]} | |
| 54 | + maxLength={4} | |
| 55 | + inputMode="numeric" | |
| 56 | + aria-label="휴대전화 번호 가운데자리" | |
| 57 | + placeholder="0000" | |
| 58 | + /> | |
| 59 | + <Input | |
| 60 | + type="tel" | |
| 61 | + name="phoneNumber3" | |
| 62 | + defaultValue={phoneParts[2]} | |
| 63 | + maxLength={4} | |
| 64 | + inputMode="numeric" | |
| 65 | + aria-label="휴대전화 번호 끝자리" | |
| 66 | + placeholder="0000" | |
| 67 | + /> | |
| 68 | + </div> | |
| 69 | + </Field> | |
| 70 | + <FieldError message={errors.phoneNumber} /> | |
| 71 | + | |
| 72 | + <Field label="이메일"> | |
| 73 | + <Input | |
| 74 | + type="email" | |
| 75 | + name="email" | |
| 76 | + defaultValue={member?.email ?? ''} | |
| 77 | + placeholder="이메일을 입력하세요." | |
| 78 | + /> | |
| 79 | + </Field> | |
| 80 | + <FieldError message={errors.email} /> | |
| 81 | + | |
| 82 | + <Field label="역할 선택 *"> | |
| 83 | + <Select | |
| 84 | + name="roleCode" | |
| 85 | + defaultValue={member?.roleCode ?? DEFAULT_ADMIN_ROLE_CODE} | |
| 86 | + > | |
| 87 | + {ADMIN_ROLE_OPTIONS.map((option) => ( | |
| 88 | + <option key={option.value} value={option.value}> | |
| 89 | + {option.label} | |
| 90 | + </option> | |
| 91 | + ))} | |
| 92 | + </Select> | |
| 93 | + </Field> | |
| 94 | + <FieldError message={errors.roleCode} /> | |
| 95 | + | |
| 96 | + <Field label="메뉴 선택 *"> | |
| 97 | + <AdminMenuPicker defaultValue={member?.menuCodes ?? []} /> | |
| 98 | + </Field> | |
| 99 | + <FieldError message={errors.menuCodes} /> | |
| 100 | + </> | |
| 101 | + ); | |
| 102 | +} | |
| 103 | + | |
| 104 | +/** 필드 하단 오류 문구. 값이 없으면 아무것도 그리지 않아 레이아웃을 차지하지 않는다. */ | |
| 105 | +export function FieldError({ message }: { message?: string }) { | |
| 106 | + if (!message) { | |
| 107 | + return null; | |
| 108 | + } | |
| 109 | + return <p className="text-body-sm text-danger">{message}</p>; | |
| 110 | +} |
+++ app/(protected)/(basic)/admins/_components/admin-member-list-toolbar.tsx
... | ... | @@ -0,0 +1,99 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useRouter } from 'next/navigation'; | |
| 4 | +import { useState, type ChangeEvent } from 'react'; | |
| 5 | +import { Button } from '@/components/ui/button'; | |
| 6 | +import { Select } from '@/components/ui/select'; | |
| 7 | +import { | |
| 8 | + ADMIN_MEMBER_PAGE_SIZE_OPTIONS, | |
| 9 | + ADMIN_MEMBER_SORT_OPTIONS, | |
| 10 | + buildAdminMemberHref, | |
| 11 | + type AdminMemberPageSize, | |
| 12 | + type AdminMemberQuery, | |
| 13 | + type AdminMemberSortOption, | |
| 14 | +} from '@/lib/domain/admin-member-query'; | |
| 15 | +import { AdminMemberCreateModal } from './admin-member-create-modal'; | |
| 16 | + | |
| 17 | +interface AdminMemberListToolbarProps { | |
| 18 | + query: AdminMemberQuery; | |
| 19 | +} | |
| 20 | + | |
| 21 | +const EXCEL_UNSUPPORTED_NOTE = '엑셀 다운로드는 추후 제공됩니다.'; | |
| 22 | + | |
| 23 | +/** | |
| 24 | + * 목록 툴바(시안 ADM_ADM_101 ②③) — 정렬/페이지 크기 select는 변경 즉시 URL을 갱신해 서버 | |
| 25 | + * 데이터를 다시 조회하게 한다. 두 값 모두 바뀌면 1페이지로 되돌린다(기존 페이지 번호가 새 정렬· | |
| 26 | + * 크기 기준으로는 의미가 달라지므로). | |
| 27 | + * | |
| 28 | + * **엑셀다운로드는 버튼만 배치하고 동작은 구현하지 않는다**(사용자 지시 — 추후 진행). | |
| 29 | + * disabled로 두어 클릭해도 아무 일도 일어나지 않게 한다. 학생 회원 목록처럼 백엔드에 엑셀 API가 | |
| 30 | + * 있으면 라우트 핸들러로 중계하면 되지만, 관리자 쪽에는 그 API가 아직 없다 | |
| 31 | + * (`/api/v1/mngr/admin/**`에 목록 조회 하나뿐). | |
| 32 | + * | |
| 33 | + * [신규 등록]은 시안대로 등록 팝업을 연다 — 팝업 열림 상태를 이 컴포넌트가 소유한다. | |
| 34 | + */ | |
| 35 | +export function AdminMemberListToolbar({ query }: AdminMemberListToolbarProps) { | |
| 36 | + const router = useRouter(); | |
| 37 | + const [isCreateOpen, setIsCreateOpen] = useState(false); | |
| 38 | + | |
| 39 | + function handleSortChange(event: ChangeEvent<HTMLSelectElement>) { | |
| 40 | + const sort = event.target.value as AdminMemberSortOption; | |
| 41 | + router.replace(buildAdminMemberHref(query, { sort, page: 1 })); | |
| 42 | + } | |
| 43 | + | |
| 44 | + function handlePageSizeChange(event: ChangeEvent<HTMLSelectElement>) { | |
| 45 | + const pageSize = Number(event.target.value) as AdminMemberPageSize; | |
| 46 | + router.replace(buildAdminMemberHref(query, { pageSize, page: 1 })); | |
| 47 | + } | |
| 48 | + | |
| 49 | + return ( | |
| 50 | + <div className="flex flex-wrap items-center justify-end gap-3"> | |
| 51 | + <Button | |
| 52 | + type="button" | |
| 53 | + variant="success" | |
| 54 | + disabled | |
| 55 | + title={EXCEL_UNSUPPORTED_NOTE} | |
| 56 | + > | |
| 57 | + 엑셀다운로드 | |
| 58 | + </Button> | |
| 59 | + | |
| 60 | + <Button | |
| 61 | + type="button" | |
| 62 | + variant="primary" | |
| 63 | + onClick={() => setIsCreateOpen(true)} | |
| 64 | + > | |
| 65 | + 신규 등록 | |
| 66 | + </Button> | |
| 67 | + | |
| 68 | + <Select | |
| 69 | + name="sort" | |
| 70 | + aria-label="정렬" | |
| 71 | + defaultValue={query.sort} | |
| 72 | + onChange={handleSortChange} | |
| 73 | + > | |
| 74 | + {ADMIN_MEMBER_SORT_OPTIONS.map((option) => ( | |
| 75 | + <option key={option.value} value={option.value}> | |
| 76 | + {option.label} | |
| 77 | + </option> | |
| 78 | + ))} | |
| 79 | + </Select> | |
| 80 | + | |
| 81 | + <Select | |
| 82 | + name="pageSize" | |
| 83 | + aria-label="페이지 크기" | |
| 84 | + defaultValue={query.pageSize} | |
| 85 | + onChange={handlePageSizeChange} | |
| 86 | + > | |
| 87 | + {ADMIN_MEMBER_PAGE_SIZE_OPTIONS.map((size) => ( | |
| 88 | + <option key={size} value={size}> | |
| 89 | + {size}개씩 | |
| 90 | + </option> | |
| 91 | + ))} | |
| 92 | + </Select> | |
| 93 | + | |
| 94 | + {isCreateOpen && ( | |
| 95 | + <AdminMemberCreateModal onClose={() => setIsCreateOpen(false)} /> | |
| 96 | + )} | |
| 97 | + </div> | |
| 98 | + ); | |
| 99 | +} |
+++ app/(protected)/(basic)/admins/_components/admin-member-row-actions.tsx
... | ... | @@ -0,0 +1,102 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useState, useTransition } from 'react'; | |
| 4 | +import { Button } from '@/components/ui/button'; | |
| 5 | +import { useFeedback } from '@/app/_hooks/use-feedback'; | |
| 6 | +import type { AdminMember } from '@/lib/domain/admin-member'; | |
| 7 | +import { deleteAdminMemberAction } from '../_actions'; | |
| 8 | +import { AdminMemberEditModal } from './admin-member-edit-modal'; | |
| 9 | + | |
| 10 | +interface AdminMemberRowActionsProps { | |
| 11 | + member: AdminMember; | |
| 12 | + /** 현재 로그인한 본인의 행인가 — 삭제를 막는다. */ | |
| 13 | + isSelf: boolean; | |
| 14 | +} | |
| 15 | + | |
| 16 | +const SELF_DELETE_HINT = '현재 로그인한 본인 계정은 삭제할 수 없습니다.'; | |
| 17 | + | |
| 18 | +/** | |
| 19 | + * 목록 행의 "관리" 셀(시안 ADM_ADM_101 ⑤) — 수정 팝업 열림 상태만 소유하는 최말단 상호작용 | |
| 20 | + * 경계다(테이블 전체를 클라이언트로 내리지 않기 위해 이 셀만 분리했다). | |
| 21 | + * | |
| 22 | + * 삭제는 시안대로 **확인 얼럿을 거친 뒤** 실행한다. 전역 얼럿(FeedbackProvider)의 actions 슬롯에 | |
| 23 | + * 직접 버튼을 넣어 확인/취소를 구성한다. | |
| 24 | + * | |
| 25 | + * **본인 계정 삭제는 막는다**(시안 ⑤). 화면에서 버튼을 비활성으로 두되, 실제 차단은 Server | |
| 26 | + * Action이 세션의 adminId와 대조해 수행한다 — 화면 비활성은 안내일 뿐 신뢰 경계가 아니다. | |
| 27 | + * | |
| 28 | + * 시안은 연필·휴지통 아이콘 버튼을 지정하지만 `components/ui/icons/`에 두 아이콘이 아직 없다 | |
| 29 | + * (신규 아이콘 추가는 공용 컴포넌트 신설이라 design 레인 소관). 로직 우선 단계라 텍스트 | |
| 30 | + * 버튼으로 대체했고, 아이콘 부재는 별도로 보고한다. | |
| 31 | + */ | |
| 32 | +export function AdminMemberRowActions({ | |
| 33 | + member, | |
| 34 | + isSelf, | |
| 35 | +}: AdminMemberRowActionsProps) { | |
| 36 | + const { showAlert, hideAlert, showToast } = useFeedback(); | |
| 37 | + const [isEditOpen, setIsEditOpen] = useState(false); | |
| 38 | + const [isDeleting, startDeleting] = useTransition(); | |
| 39 | + | |
| 40 | + function runDelete() { | |
| 41 | + hideAlert(); | |
| 42 | + startDeleting(async () => { | |
| 43 | + const result = await deleteAdminMemberAction(member.id); | |
| 44 | + if (result.status === 'error') { | |
| 45 | + showToast({ | |
| 46 | + variant: 'danger', | |
| 47 | + message: result.message ?? '삭제하지 못했습니다.', | |
| 48 | + }); | |
| 49 | + return; | |
| 50 | + } | |
| 51 | + showToast({ variant: 'success', message: '관리자를 삭제했습니다.' }); | |
| 52 | + }); | |
| 53 | + } | |
| 54 | + | |
| 55 | + function confirmDelete() { | |
| 56 | + showAlert({ | |
| 57 | + variant: 'danger', | |
| 58 | + title: '관리자를 삭제하시겠습니까?', | |
| 59 | + message: `${member.name}(${member.loginId}) 계정을 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`, | |
| 60 | + actions: ( | |
| 61 | + <> | |
| 62 | + <Button type="button" variant="ghost" onClick={hideAlert}> | |
| 63 | + 취소 | |
| 64 | + </Button> | |
| 65 | + <Button type="button" variant="primary" onClick={runDelete}> | |
| 66 | + 삭제 | |
| 67 | + </Button> | |
| 68 | + </> | |
| 69 | + ), | |
| 70 | + }); | |
| 71 | + } | |
| 72 | + | |
| 73 | + return ( | |
| 74 | + <div className="flex items-center gap-1"> | |
| 75 | + <Button | |
| 76 | + type="button" | |
| 77 | + variant="ghost" | |
| 78 | + size="sm" | |
| 79 | + onClick={() => setIsEditOpen(true)} | |
| 80 | + > | |
| 81 | + 수정 | |
| 82 | + </Button> | |
| 83 | + <Button | |
| 84 | + type="button" | |
| 85 | + variant="ghost" | |
| 86 | + size="sm" | |
| 87 | + onClick={confirmDelete} | |
| 88 | + disabled={isSelf || isDeleting} | |
| 89 | + title={isSelf ? SELF_DELETE_HINT : undefined} | |
| 90 | + > | |
| 91 | + {isDeleting ? '삭제 중...' : '삭제'} | |
| 92 | + </Button> | |
| 93 | + | |
| 94 | + {isEditOpen && ( | |
| 95 | + <AdminMemberEditModal | |
| 96 | + member={member} | |
| 97 | + onClose={() => setIsEditOpen(false)} | |
| 98 | + /> | |
| 99 | + )} | |
| 100 | + </div> | |
| 101 | + ); | |
| 102 | +} |
+++ app/(protected)/(basic)/admins/_components/admin-member-search-bar.tsx
... | ... | @@ -0,0 +1,74 @@ |
| 1 | +import Link from 'next/link'; | |
| 2 | +import { Button } from '@/components/ui/button'; | |
| 3 | +import { Field } from '@/components/ui/field'; | |
| 4 | +import { Input } from '@/components/ui/input'; | |
| 5 | +import { Select } from '@/components/ui/select'; | |
| 6 | +import { | |
| 7 | + ADMIN_MEMBERS_PATH, | |
| 8 | + ADMIN_MEMBER_SEARCH_FIELD_OPTIONS, | |
| 9 | + DEFAULT_ADMIN_MEMBER_SEARCH_FIELD, | |
| 10 | + buildAdminMemberHref, | |
| 11 | + type AdminMemberQuery, | |
| 12 | +} from '@/lib/domain/admin-member-query'; | |
| 13 | + | |
| 14 | +interface AdminMemberSearchBarProps { | |
| 15 | + query: AdminMemberQuery; | |
| 16 | +} | |
| 17 | + | |
| 18 | +/** | |
| 19 | + * 검색 영역(시안 ADM_ADM_101 ①) — 순수 `<form method="get">` GET 제출로 URL을 갱신한다 | |
| 20 | + * (자바스크립트 없이도 동작). 현재 `sort`·`pageSize`는 hidden input으로 함께 제출해 검색 후에도 | |
| 21 | + * 유지되게 하고, 검색 자체는 항상 1페이지로 돌아간다(hidden page 필드를 두지 않아 | |
| 22 | + * `parseAdminMemberQuery`의 기본값 1로 자연스럽게 떨어진다). "초기화"는 폼 제출이 아니라 | |
| 23 | + * 기본값으로 되돌린 링크라 현재 입력값과 무관하게 동작한다. 상호작용이 전부 네이티브 폼 | |
| 24 | + * 제출/링크 내비게이션이라 클라이언트 자바스크립트가 필요 없다 — Server Component로 유지한다. | |
| 25 | + * | |
| 26 | + * 검색 대상이 시안의 셋이 아니라 둘인 이유는 `admin-member-query.ts` 주석에 적어 두었다. | |
| 27 | + */ | |
| 28 | +export function AdminMemberSearchBar({ query }: AdminMemberSearchBarProps) { | |
| 29 | + const resetHref = buildAdminMemberHref(query, { | |
| 30 | + searchField: DEFAULT_ADMIN_MEMBER_SEARCH_FIELD, | |
| 31 | + keyword: '', | |
| 32 | + page: 1, | |
| 33 | + }); | |
| 34 | + | |
| 35 | + return ( | |
| 36 | + <form | |
| 37 | + method="get" | |
| 38 | + action={ADMIN_MEMBERS_PATH} | |
| 39 | + className="flex flex-wrap items-end gap-3" | |
| 40 | + > | |
| 41 | + <input type="hidden" name="sort" value={query.sort} /> | |
| 42 | + <input type="hidden" name="pageSize" value={query.pageSize} /> | |
| 43 | + | |
| 44 | + <Field label="검색 대상"> | |
| 45 | + <Select name="searchField" defaultValue={query.searchField}> | |
| 46 | + {ADMIN_MEMBER_SEARCH_FIELD_OPTIONS.map((option) => ( | |
| 47 | + <option key={option.value} value={option.value}> | |
| 48 | + {option.label} | |
| 49 | + </option> | |
| 50 | + ))} | |
| 51 | + </Select> | |
| 52 | + </Field> | |
| 53 | + | |
| 54 | + <Field label="검색어"> | |
| 55 | + <Input | |
| 56 | + type="text" | |
| 57 | + name="keyword" | |
| 58 | + defaultValue={query.keyword} | |
| 59 | + placeholder="검색어를 입력하세요." | |
| 60 | + /> | |
| 61 | + </Field> | |
| 62 | + | |
| 63 | + <Button type="submit" variant="secondary"> | |
| 64 | + 검색 | |
| 65 | + </Button> | |
| 66 | + <Link | |
| 67 | + href={resetHref} | |
| 68 | + className="text-body-md text-foreground-muted underline-offset-2 hover:underline" | |
| 69 | + > | |
| 70 | + 초기화 | |
| 71 | + </Link> | |
| 72 | + </form> | |
| 73 | + ); | |
| 74 | +} |
+++ app/(protected)/(basic)/admins/_components/admin-member-table.tsx
... | ... | @@ -0,0 +1,90 @@ |
| 1 | +import { | |
| 2 | + Table, | |
| 3 | + TableBody, | |
| 4 | + TableCell, | |
| 5 | + TableHead, | |
| 6 | + TableHeaderCell, | |
| 7 | + TableRow, | |
| 8 | +} from '@/components/ui/table'; | |
| 9 | +import { | |
| 10 | + formatAdminRoleLabel, | |
| 11 | + formatOptionalValue, | |
| 12 | + type AdminMember, | |
| 13 | +} from '@/lib/domain/admin-member'; | |
| 14 | +import { AdminMemberRowActions } from './admin-member-row-actions'; | |
| 15 | + | |
| 16 | +interface AdminMemberTableProps { | |
| 17 | + items: AdminMember[]; | |
| 18 | + page: number; | |
| 19 | + pageSize: number; | |
| 20 | + totalCount: number; | |
| 21 | + /** 현재 로그인한 관리자의 id — 본인 행의 삭제 버튼을 막는 데 쓴다. */ | |
| 22 | + currentAdminId: string; | |
| 23 | +} | |
| 24 | + | |
| 25 | +const COLUMN_HEADERS = [ | |
| 26 | + '번호', | |
| 27 | + '이름', | |
| 28 | + '아이디', | |
| 29 | + '휴대전화번호', | |
| 30 | + '이메일', | |
| 31 | + '역할', | |
| 32 | + '생성일', | |
| 33 | + '관리', | |
| 34 | +]; | |
| 35 | + | |
| 36 | +/** | |
| 37 | + * 관리자 회원 목록 테이블(시안 ADM_ADM_101 ④). 개인정보 마스킹은 시안에 "마스킹 처리 없음"으로 | |
| 38 | + * 명시돼 있어 원본 값을 그대로 노출한다. | |
| 39 | + * | |
| 40 | + * **"번호"는 저장된 값이 아니라 전체 건수에서 거꾸로 세는 표시 순번이다** — 시안이 6·5·4처럼 | |
| 41 | + * 내림차순으로 표기하고, 목록의 기본 정렬이 생성일 최신순이라 "가장 최근에 만들어진 계정이 가장 | |
| 42 | + * 큰 번호"가 된다. 이 계산은 정확한 전체 건수를 전제하는데, Repository가 전체를 받아 세기 때문에 | |
| 43 | + * 그 전제가 성립한다(백엔드 `totalCount`는 신뢰할 수 없다 — Repository 주석 참조). | |
| 44 | + * | |
| 45 | + * 백엔드가 아직 주지 않는 항목(휴대전화번호·이메일·생성일)은 열을 그대로 유지한 채 `-`로 표시한다 | |
| 46 | + * — 백엔드가 필드를 추가하면 Repository 매핑만 늘리면 이 파일은 그대로 값이 채워진다. | |
| 47 | + * | |
| 48 | + * "관리" 열은 행별 수정/삭제 트리거(AdminMemberRowActions)에 위임한다 — 상호작용이 필요한 것은 | |
| 49 | + * 그 셀뿐이라 이 테이블 자체는 Server Component로 유지하고 최말단만 클라이언트 경계로 뗀다. | |
| 50 | + */ | |
| 51 | +export function AdminMemberTable({ | |
| 52 | + items, | |
| 53 | + page, | |
| 54 | + pageSize, | |
| 55 | + totalCount, | |
| 56 | + currentAdminId, | |
| 57 | +}: AdminMemberTableProps) { | |
| 58 | + const offset = (page - 1) * pageSize; | |
| 59 | + | |
| 60 | + return ( | |
| 61 | + <Table> | |
| 62 | + <TableHead> | |
| 63 | + <TableRow> | |
| 64 | + {COLUMN_HEADERS.map((header) => ( | |
| 65 | + <TableHeaderCell key={header}>{header}</TableHeaderCell> | |
| 66 | + ))} | |
| 67 | + </TableRow> | |
| 68 | + </TableHead> | |
| 69 | + <TableBody> | |
| 70 | + {items.map((member, index) => ( | |
| 71 | + <TableRow key={member.id}> | |
| 72 | + <TableCell>{totalCount - offset - index}</TableCell> | |
| 73 | + <TableCell>{member.name}</TableCell> | |
| 74 | + <TableCell>{member.loginId}</TableCell> | |
| 75 | + <TableCell>{formatOptionalValue(member.phoneNumber)}</TableCell> | |
| 76 | + <TableCell>{formatOptionalValue(member.email)}</TableCell> | |
| 77 | + <TableCell>{formatAdminRoleLabel(member.roleCode)}</TableCell> | |
| 78 | + <TableCell>{formatOptionalValue(member.createdAt)}</TableCell> | |
| 79 | + <TableCell> | |
| 80 | + <AdminMemberRowActions | |
| 81 | + member={member} | |
| 82 | + isSelf={member.id === currentAdminId} | |
| 83 | + /> | |
| 84 | + </TableCell> | |
| 85 | + </TableRow> | |
| 86 | + ))} | |
| 87 | + </TableBody> | |
| 88 | + </Table> | |
| 89 | + ); | |
| 90 | +} |
+++ app/(protected)/(basic)/admins/error.tsx
... | ... | @@ -0,0 +1,32 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useEffect } from 'react'; | |
| 4 | + | |
| 5 | +interface ErrorPageProps { | |
| 6 | + error: Error & { digest?: string }; | |
| 7 | + unstable_retry: () => void; | |
| 8 | +} | |
| 9 | + | |
| 10 | +// 원본 메시지·스택은 화면에 내지 않는다 — 개발자 진단용으로만 콘솔에 남기고(프로덕션에서는 | |
| 11 | +// Next.js가 Server Component 에러를 이미 digest 포함 일반화 메시지로 치환해 전달한다), | |
| 12 | +// 사용자에게는 일반화된 문구 + 재시도만 제공한다. Next.js 16.2 문서 권고에 따라 세그먼트 | |
| 13 | +// 재조회까지 수행하는 `unstable_retry()`를 `reset()` 대신 사용한다. | |
| 14 | +export default function ErrorPage({ error, unstable_retry }: ErrorPageProps) { | |
| 15 | + useEffect(() => { | |
| 16 | + console.error(error); | |
| 17 | + }, [error]); | |
| 18 | + | |
| 19 | + return ( | |
| 20 | + <section className="flex flex-1 flex-col items-center justify-center gap-2 p-8"> | |
| 21 | + <h1 className="text-lg text-foreground">문제가 발생했습니다.</h1> | |
| 22 | + <p className="text-sm text-foreground/60">잠시 후 다시 시도해 주세요.</p> | |
| 23 | + <button | |
| 24 | + type="button" | |
| 25 | + onClick={() => unstable_retry()} | |
| 26 | + className="mt-4 bg-foreground px-4 py-2 text-background" | |
| 27 | + > | |
| 28 | + 다시 시도 | |
| 29 | + </button> | |
| 30 | + </section> | |
| 31 | + ); | |
| 32 | +} |
+++ app/(protected)/(basic)/admins/loading.tsx
... | ... | @@ -0,0 +1,12 @@ |
| 1 | +import { Spinner } from '@/components/ui/spinner'; | |
| 2 | + | |
| 3 | +// page.tsx가 스트리밍되는 동안의 폴백 — 같은 section 배치(flex-1 items-center justify-center)를 | |
| 4 | +// 써서 콘텐츠 교체 시 레이아웃이 흔들리지 않게 한다. Server Component로 충분해 'use client'를 | |
| 5 | +// 붙이지 않는다. | |
| 6 | +export default function Loading() { | |
| 7 | + return ( | |
| 8 | + <section className="flex flex-1 flex-col items-center justify-center gap-2 p-8"> | |
| 9 | + <Spinner size="lg" /> | |
| 10 | + </section> | |
| 11 | + ); | |
| 12 | +} |
+++ app/(protected)/(basic)/admins/page.tsx
... | ... | @@ -0,0 +1,87 @@ |
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { Alert } from '@/components/ui/alert'; | |
| 4 | +import { Pagination } from '@/components/ui/pagination'; | |
| 5 | +import { verifySession } from '@/lib/auth/dal'; | |
| 6 | +import { fetchAdminMembers } from '@/lib/data/repositories/admin-member-repository'; | |
| 7 | +import { | |
| 8 | + buildAdminMemberHref, | |
| 9 | + parseAdminMemberQuery, | |
| 10 | +} from '@/lib/domain/admin-member-query'; | |
| 11 | +import { AdminMemberListToolbar } from './_components/admin-member-list-toolbar'; | |
| 12 | +import { AdminMemberSearchBar } from './_components/admin-member-search-bar'; | |
| 13 | +import { AdminMemberTable } from './_components/admin-member-table'; | |
| 14 | + | |
| 15 | +export const metadata: Metadata = { | |
| 16 | + title: '관리자 회원', | |
| 17 | +}; | |
| 18 | + | |
| 19 | +interface PageProps { | |
| 20 | + searchParams: Promise<Record<string, string | string[] | undefined>>; | |
| 21 | +} | |
| 22 | + | |
| 23 | +/** | |
| 24 | + * 관리자 회원 목록(시안 ADM_ADM_101). | |
| 25 | + * | |
| 26 | + * 학생 회원 목록과 달리 전체 건수가 **확정값**이라 페이지 수를 그대로 계산한다 — Repository가 | |
| 27 | + * 백엔드 페이징을 쓰지 않고 전체를 받아 직접 세기 때문이다(그 이유는 Repository 주석 참조). | |
| 28 | + * | |
| 29 | + * `verifySession()`의 반환값을 목록까지 내려보내는 이유는 본인 계정의 삭제 버튼을 막기 위해서다 | |
| 30 | + * (시안 ⑤). 실제 차단은 Server Action이 다시 확인한다. | |
| 31 | + */ | |
| 32 | +export default async function Page({ searchParams }: PageProps) { | |
| 33 | + const admin = await verifySession(); | |
| 34 | + | |
| 35 | + const query = parseAdminMemberQuery(await searchParams); | |
| 36 | + const { items, totalCount } = await fetchAdminMembers(query); | |
| 37 | + | |
| 38 | + const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize)); | |
| 39 | + // 요청 페이지가 범위를 벗어나면(예: 삭제로 마지막 페이지가 사라짐) 마지막 페이지로 맞춘다 — | |
| 40 | + // 표의 순번 계산도 이 값을 기준으로 해야 헤더의 "현재페이지"와 어긋나지 않는다. | |
| 41 | + const currentPage = Math.min(query.page, totalPages); | |
| 42 | + | |
| 43 | + return ( | |
| 44 | + <section className="flex flex-1 flex-col gap-6 p-8"> | |
| 45 | + <h1 className="text-headline-sm text-foreground">관리자 회원 목록</h1> | |
| 46 | + | |
| 47 | + <AdminMemberSearchBar query={query} /> | |
| 48 | + | |
| 49 | + <AdminMemberListToolbar query={query} /> | |
| 50 | + | |
| 51 | + <p className="text-body-md text-foreground-muted"> | |
| 52 | + 총 {totalCount}명 | 현재페이지 {currentPage}/{totalPages} | |
| 53 | + </p> | |
| 54 | + | |
| 55 | + {items.length === 0 ? ( | |
| 56 | + <Alert | |
| 57 | + title="검색 결과가 없습니다." | |
| 58 | + actions={ | |
| 59 | + <Link | |
| 60 | + href={buildAdminMemberHref(query, { keyword: '', page: 1 })} | |
| 61 | + className="bg-foreground px-4 py-2 text-background" | |
| 62 | + > | |
| 63 | + 확인 | |
| 64 | + </Link> | |
| 65 | + } | |
| 66 | + > | |
| 67 | + 다른 검색어로 다시 시도해 주세요. | |
| 68 | + </Alert> | |
| 69 | + ) : ( | |
| 70 | + <> | |
| 71 | + <AdminMemberTable | |
| 72 | + items={items} | |
| 73 | + page={currentPage} | |
| 74 | + pageSize={query.pageSize} | |
| 75 | + totalCount={totalCount} | |
| 76 | + currentAdminId={admin.id} | |
| 77 | + /> | |
| 78 | + <Pagination | |
| 79 | + currentPage={currentPage} | |
| 80 | + totalPages={totalPages} | |
| 81 | + buildHref={(page: number) => buildAdminMemberHref(query, { page })} | |
| 82 | + /> | |
| 83 | + </> | |
| 84 | + )} | |
| 85 | + </section> | |
| 86 | + ); | |
| 87 | +} |
+++ lib/data/mock/admin-member-store.ts
... | ... | @@ -0,0 +1,165 @@ |
| 1 | +import 'server-only'; | |
| 2 | +import type { AdminMember } from '@/lib/domain/admin-member'; | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * 관리자 회원 등록/수정/삭제의 **mock 저장소** — 백엔드에 해당 API가 없어 임시로 두는 계층이다. | |
| 6 | + * | |
| 7 | + * 백엔드(edupay-backend develop b742bb4)의 관리자 도메인에는 목록 조회 | |
| 8 | + * (`GET /api/v1/mngr/admin/pagination`)와 로그인 조회밖에 없다 — 등록·수정·삭제·단건 조회 | |
| 9 | + * 어느 것도 컨트롤러·서비스·매퍼에 존재하지 않는다(패키지 전체를 읽어 확인). 그래서 시안 | |
| 10 | + * (ADM_ADM_101 ③⑤ / 102_p / 103_p)의 쓰기 흐름은 이 파일이 대신 처리한다. | |
| 11 | + * | |
| 12 | + * **동작 방식은 "덮어쓰기 오버레이"다.** 목록의 원본은 여전히 백엔드이고, 이 저장소는 그 위에 | |
| 13 | + * 얹을 변경분(추가된 행 / 필드 패치 / 삭제된 id)만 들고 있다. Repository가 백엔드 응답을 받은 뒤 | |
| 14 | + * `applyMockOverlay`로 합쳐 화면에 넘긴다. | |
| 15 | + * | |
| 16 | + * **한계를 분명히 해 둔다 — 이건 데모용이지 저장소가 아니다.** | |
| 17 | + * - 서버 프로세스 메모리에만 있다. 재시작하면 사라지고, 인스턴스가 여럿이면 공유되지 않는다. | |
| 18 | + * - 비밀번호는 **보관하지 않는다.** mock이라 검증할 곳이 없고, 평문 비밀번호를 메모리에 들고 | |
| 19 | + * 있는 형태를 코드에 남기고 싶지 않다. 등록/수정 시 형식 검증만 하고 값은 버린다. | |
| 20 | + * | |
| 21 | + * 백엔드 API가 생기면 이 파일을 삭제하고 Repository의 쓰기 함수 본문만 실제 호출로 교체한다 — | |
| 22 | + * 화면·Server Action은 그대로다. | |
| 23 | + */ | |
| 24 | + | |
| 25 | +/** 신규 등록 행의 id 접두사 — 백엔드 `admUserId`와 섞이지 않게 구분한다. */ | |
| 26 | +const MOCK_ID_PREFIX = 'mock-admin-'; | |
| 27 | + | |
| 28 | +type AdminMemberPatch = Partial< | |
| 29 | + Pick< | |
| 30 | + AdminMember, | |
| 31 | + 'phoneNumber' | 'email' | 'roleCode' | 'menuCodes' | 'isLocked' | |
| 32 | + > | |
| 33 | +>; | |
| 34 | + | |
| 35 | +type MockStoreState = { | |
| 36 | + created: AdminMember[]; | |
| 37 | + patches: Map<string, AdminMemberPatch>; | |
| 38 | + deletedIds: Set<string>; | |
| 39 | + nextId: number; | |
| 40 | +}; | |
| 41 | + | |
| 42 | +/** | |
| 43 | + * 개발 중 HMR로 모듈이 다시 평가돼도 상태가 초기화되지 않도록 globalThis에 붙인다 — 저장한 | |
| 44 | + * 내용이 코드 수정 때마다 사라지면 mock으로 흐름을 확인하는 의미가 없다. mock 전용 편법이며, | |
| 45 | + * 실제 데이터 계층에는 쓰지 않는다. | |
| 46 | + */ | |
| 47 | +const globalStore = globalThis as typeof globalThis & { | |
| 48 | + __adminMemberMockStore?: MockStoreState; | |
| 49 | +}; | |
| 50 | + | |
| 51 | +function getState(): MockStoreState { | |
| 52 | + globalStore.__adminMemberMockStore ??= { | |
| 53 | + created: [], | |
| 54 | + patches: new Map(), | |
| 55 | + deletedIds: new Set(), | |
| 56 | + nextId: 1, | |
| 57 | + }; | |
| 58 | + return globalStore.__adminMemberMockStore; | |
| 59 | +} | |
| 60 | + | |
| 61 | +/** 오늘 날짜(YYYY-MM-DD) — 신규 등록 행의 생성일. */ | |
| 62 | +function today(): string { | |
| 63 | + return new Date().toISOString().slice(0, 10); | |
| 64 | +} | |
| 65 | + | |
| 66 | +export type CreateAdminMemberInput = { | |
| 67 | + name: string; | |
| 68 | + loginId: string; | |
| 69 | + phoneNumber: string; | |
| 70 | + email: string; | |
| 71 | + roleCode: string; | |
| 72 | + menuCodes: string[]; | |
| 73 | +}; | |
| 74 | + | |
| 75 | +export type UpdateAdminMemberInput = { | |
| 76 | + phoneNumber: string; | |
| 77 | + email: string; | |
| 78 | + roleCode: string; | |
| 79 | + menuCodes: string[]; | |
| 80 | + isLocked: boolean; | |
| 81 | +}; | |
| 82 | + | |
| 83 | +/** | |
| 84 | + * 백엔드 목록에 mock 변경분을 얹는다 — 삭제된 행을 걷어내고, 수정된 행에 패치를 입히고, | |
| 85 | + * 신규 등록 행을 **앞에** 붙인다(기본 정렬이 생성일 최신순이라 방금 만든 행이 맨 위에 온다). | |
| 86 | + * | |
| 87 | + * 정렬·검색·페이징은 이 함수가 하지 않는다 — 합쳐진 전체 목록을 대상으로 Repository가 처리한다. | |
| 88 | + */ | |
| 89 | +export function applyMockOverlay(backendItems: AdminMember[]): AdminMember[] { | |
| 90 | + const { created, patches, deletedIds } = getState(); | |
| 91 | + | |
| 92 | + const merged = [...created, ...backendItems]; | |
| 93 | + | |
| 94 | + return merged | |
| 95 | + .filter((item) => !deletedIds.has(item.id)) | |
| 96 | + .map((item) => { | |
| 97 | + const patch = patches.get(item.id); | |
| 98 | + return patch ? { ...item, ...patch } : item; | |
| 99 | + }); | |
| 100 | +} | |
| 101 | + | |
| 102 | +/** mock으로 등록된 행들의 로그인 ID — 중복 확인이 백엔드 목록과 함께 검사할 수 있게 노출한다. */ | |
| 103 | +export function listMockCreatedLoginIds(): string[] { | |
| 104 | + return getState().created.map((item) => item.loginId); | |
| 105 | +} | |
| 106 | + | |
| 107 | +export function createMockAdminMember( | |
| 108 | + input: CreateAdminMemberInput | |
| 109 | +): AdminMember { | |
| 110 | + const state = getState(); | |
| 111 | + const id = `${MOCK_ID_PREFIX}${state.nextId++}`; | |
| 112 | + | |
| 113 | + const member: AdminMember = { | |
| 114 | + id, | |
| 115 | + name: input.name, | |
| 116 | + loginId: input.loginId, | |
| 117 | + phoneNumber: input.phoneNumber, | |
| 118 | + email: input.email || null, | |
| 119 | + roleCode: input.roleCode, | |
| 120 | + menuCodes: input.menuCodes, | |
| 121 | + createdAt: today(), | |
| 122 | + isLocked: false, | |
| 123 | + isActive: true, | |
| 124 | + loginFailCount: 0, | |
| 125 | + }; | |
| 126 | + | |
| 127 | + state.created.unshift(member); | |
| 128 | + return member; | |
| 129 | +} | |
| 130 | + | |
| 131 | +/** | |
| 132 | + * 수정 — 신규 등록 행은 원본을 직접 고치고, 백엔드에서 온 행은 패치로 기록해 둔다(원본을 우리가 | |
| 133 | + * 소유하지 않으므로 매 조회마다 덧입힌다). | |
| 134 | + */ | |
| 135 | +export function updateMockAdminMember( | |
| 136 | + id: string, | |
| 137 | + input: UpdateAdminMemberInput | |
| 138 | +): void { | |
| 139 | + const state = getState(); | |
| 140 | + | |
| 141 | + const patch: AdminMemberPatch = { | |
| 142 | + phoneNumber: input.phoneNumber, | |
| 143 | + email: input.email || null, | |
| 144 | + roleCode: input.roleCode, | |
| 145 | + menuCodes: input.menuCodes, | |
| 146 | + isLocked: input.isLocked, | |
| 147 | + }; | |
| 148 | + | |
| 149 | + const createdIndex = state.created.findIndex((item) => item.id === id); | |
| 150 | + if (createdIndex >= 0) { | |
| 151 | + state.created[createdIndex] = { ...state.created[createdIndex], ...patch }; | |
| 152 | + return; | |
| 153 | + } | |
| 154 | + | |
| 155 | + state.patches.set(id, { ...state.patches.get(id), ...patch }); | |
| 156 | +} | |
| 157 | + | |
| 158 | +export function deleteMockAdminMember(id: string): void { | |
| 159 | + const state = getState(); | |
| 160 | + | |
| 161 | + state.created = state.created.filter((item) => item.id !== id); | |
| 162 | + state.patches.delete(id); | |
| 163 | + // 백엔드에서 오는 행은 매 조회마다 다시 등장하므로 삭제 사실을 따로 기억해야 한다. | |
| 164 | + state.deletedIds.add(id); | |
| 165 | +} |
+++ lib/data/repositories/admin-member-repository.ts
... | ... | @@ -0,0 +1,267 @@ |
| 1 | +import 'server-only'; | |
| 2 | +import { getSessionAccessToken } from '@/lib/auth/dal'; | |
| 3 | +import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch'; | |
| 4 | +import type { AdminMember } from '@/lib/domain/admin-member'; | |
| 5 | +import type { | |
| 6 | + AdminMemberQuery, | |
| 7 | + AdminMemberSearchField, | |
| 8 | +} from '@/lib/domain/admin-member-query'; | |
| 9 | +import { | |
| 10 | + applyMockOverlay, | |
| 11 | + createMockAdminMember, | |
| 12 | + deleteMockAdminMember, | |
| 13 | + listMockCreatedLoginIds, | |
| 14 | + updateMockAdminMember, | |
| 15 | + type CreateAdminMemberInput, | |
| 16 | + type UpdateAdminMemberInput, | |
| 17 | +} from '@/lib/data/mock/admin-member-store'; | |
| 18 | + | |
| 19 | +/** | |
| 20 | + * 관리자 회원 Repository — 이 도메인을 백엔드에서 "어떻게 조회하는지"만 안다(엔드포인트·파라미터· | |
| 21 | + * 응답 매핑). 백엔드와 말하는 공통 규약(URL·헤더·응답 봉투·실패 정규화)은 | |
| 22 | + * `lib/http/backend-fetch.ts`가, 토큰 보관·검증은 `lib/auth`가 소유하므로 여기에 들어오지 않는다. | |
| 23 | + * | |
| 24 | + * GET /api/v1/mngr/admin/pagination (ROLE_ADMIN 전용) | |
| 25 | + * → data: { list: [{ rnum, admUserId, admNm, loginId, admRoleCd, | |
| 26 | + * loginFailCnt, acctLockYn, useYn }], page, size, totalCount, totalPages } | |
| 27 | + * | |
| 28 | + * 아래 내용은 백엔드 저장소(edupay-backend, develop b742bb4)의 실제 구현을 읽고 확인한 것이다 | |
| 29 | + * — MngrAdminApiController / MngrAdminServiceImpl / MngrAdminMapper.xml / PaginationUtil. | |
| 30 | + * | |
| 31 | + * - **응답에 휴대전화번호·이메일·생성일이 없다.** 조회 SQL의 select 목록 자체에 그 컬럼들이 없고 | |
| 32 | + * `MngrAdminVo`에도 필드가 없다. 그래서 시안의 해당 열은 `null` → `-`다. 백엔드가 컬럼과 VO | |
| 33 | + * 필드를 추가하면 `toAdminMember`의 매핑만 늘리면 되고 화면은 손대지 않는다. | |
| 34 | + * - **정렬 파라미터가 없다** — 목록 SQL의 `ORDER BY RNUM DESC`가 하드코딩돼 있다. rnum이 | |
| 35 | + * `ROW_NUMBER() OVER (ORDER BY frst_reg_dt, adm_nm DESC)`, 즉 최초등록일시 오름차순 순번이라 | |
| 36 | + * 그것을 뒤집은 고정 순서가 곧 **생성일 최신순**이다. 그래서 생성일순 정렬은 "백엔드가 준 순서를 | |
| 37 | + * 그대로 쓴다"는 뜻이고, 이름순만 우리가 정렬한다. | |
| 38 | + * - **`totalCount`가 전체 건수가 아니다.** count 쿼리가 없어 `PaginationUtil.execute`가 | |
| 39 | + * `list.size()`(= 그 페이지의 행 수)를 총건수로 그대로 쓴다. 그래서 이 값은 신뢰하지 않는다. | |
| 40 | + * | |
| 41 | + * 인증: `/api/v1/mngr/**`는 ROLE_ADMIN 전용이다. 세션에 보관된 백엔드 accessToken을 DAL에서 | |
| 42 | + * 꺼내 Bearer로 붙인다. | |
| 43 | + * | |
| 44 | + * 캐시: `no-store` — 개인정보 목록이고 검색 조건이 매 요청 다르다. | |
| 45 | + */ | |
| 46 | + | |
| 47 | +const ADMIN_MEMBER_PAGINATION_PATH = '/api/v1/mngr/admin/pagination'; | |
| 48 | + | |
| 49 | +/** | |
| 50 | + * 한 번에 받아올 최대 행 수. **이 화면은 백엔드 페이징을 쓰지 않고 전체를 받아 여기서 자른다.** | |
| 51 | + * 이유가 세 가지 겹친다: | |
| 52 | + * | |
| 53 | + * 1. **총건수가 없다.** 위에서 적었듯 `totalCount`가 현재 페이지 행 수라, 그 값을 믿으면 | |
| 54 | + * 페이지가 가득 찰 때마다 `totalPages`가 1로 계산돼 2페이지 이후에 영원히 닿을 수 없다. | |
| 55 | + * 시안(ADM_ADM_101)은 "총 N명 | 현재페이지 1/1"과 번호 열(총건수에서 거꾸로 세는 순번)을 | |
| 56 | + * 요구하는데, 둘 다 정확한 전체 건수를 전제한다. | |
| 57 | + * 2. **검색·정렬을 백엔드에 맡길 수 없다.** 이름순 정렬 파라미터가 없고, 휴대전화번호 검색 | |
| 58 | + * 분기는 관리자 테이블에 없는 컬럼을 참조한다(`admin-member-query.ts` 주석 참조). | |
| 59 | + * 3. **등록/수정/삭제가 mock이다.** mock으로 만든 행과 백엔드 행이 같은 검색·정렬·페이징 규칙을 | |
| 60 | + * 따라야 하는데, 페이징이 백엔드에 있으면 두 출처를 일관되게 합칠 방법이 없다. | |
| 61 | + * | |
| 62 | + * 관리자 계정은 본래 수십 건 규모라 전체를 받아도 부담이 없다. 이 전제가 깨질 정도로 늘면 | |
| 63 | + * 백엔드에 count·정렬·검색 파라미터가 필요하다 — 상한 인상은 임시방편일 뿐이다. | |
| 64 | + * (같은 이유로 학생 목록의 이름순 정렬과 엑셀 다운로드도 이미 같은 방식을 쓴다.) | |
| 65 | + */ | |
| 66 | +const ADMIN_MEMBER_FETCH_LIMIT = 10_000; | |
| 67 | + | |
| 68 | +function isRecord(value: unknown): value is Record<string, unknown> { | |
| 69 | + return value !== null && typeof value === 'object'; | |
| 70 | +} | |
| 71 | + | |
| 72 | +function readRequiredString( | |
| 73 | + source: Record<string, unknown>, | |
| 74 | + key: string | |
| 75 | +): string { | |
| 76 | + const value = source[key]; | |
| 77 | + if (typeof value !== 'string' || value.length === 0) { | |
| 78 | + throw new Error(`관리자 회원 응답에 ${key}가 없습니다.`); | |
| 79 | + } | |
| 80 | + return value; | |
| 81 | +} | |
| 82 | + | |
| 83 | +function readOptionalString( | |
| 84 | + source: Record<string, unknown>, | |
| 85 | + key: string | |
| 86 | +): string | null { | |
| 87 | + const value = source[key]; | |
| 88 | + return typeof value === 'string' && value.length > 0 ? value : null; | |
| 89 | +} | |
| 90 | + | |
| 91 | +/** 백엔드의 Y/N 플래그 → boolean. 값이 없거나 Y/N이 아니면 "모름"(null)이다. */ | |
| 92 | +function parseYesNo(value: unknown): boolean | null { | |
| 93 | + if (value === 'Y') return true; | |
| 94 | + if (value === 'N') return false; | |
| 95 | + return null; | |
| 96 | +} | |
| 97 | + | |
| 98 | +/** | |
| 99 | + * 백엔드 응답 1건 → 도메인 타입. 백엔드가 주지 않는 항목은 `null`(목록은 빈 배열)로 둔다. | |
| 100 | + * | |
| 101 | + * `rnum`은 담지 않는다 — 표의 "번호"는 백엔드의 행 번호가 아니라 전체 건수 기준 역순 순번이고 | |
| 102 | + * (시안이 6·5·4처럼 내림차순으로 표기한다), 그 계산은 표 컴포넌트가 한다. | |
| 103 | + * | |
| 104 | + * 식별자·이름·ID 세 필드는 없으면 예외로 끊는다(fail-fast) — 목록의 존재 이유인 값이라 | |
| 105 | + * 빈 화면을 조용히 보여주는 것보다 계약 위반을 즉시 드러내는 편이 낫다. | |
| 106 | + */ | |
| 107 | +function toAdminMember(raw: unknown): AdminMember { | |
| 108 | + if (!isRecord(raw)) { | |
| 109 | + throw new Error('관리자 회원 응답 항목의 형식이 올바르지 않습니다.'); | |
| 110 | + } | |
| 111 | + | |
| 112 | + const loginFailCnt = raw.loginFailCnt; | |
| 113 | + | |
| 114 | + return { | |
| 115 | + id: readRequiredString(raw, 'admUserId'), | |
| 116 | + name: readRequiredString(raw, 'admNm'), | |
| 117 | + loginId: readRequiredString(raw, 'loginId'), | |
| 118 | + phoneNumber: null, | |
| 119 | + email: null, | |
| 120 | + roleCode: readOptionalString(raw, 'admRoleCd') ?? '', | |
| 121 | + menuCodes: [], | |
| 122 | + createdAt: null, | |
| 123 | + isLocked: parseYesNo(raw.acctLockYn), | |
| 124 | + isActive: parseYesNo(raw.useYn), | |
| 125 | + loginFailCount: typeof loginFailCnt === 'number' ? loginFailCnt : null, | |
| 126 | + }; | |
| 127 | +} | |
| 128 | + | |
| 129 | +/** | |
| 130 | + * 백엔드 목록 전체를 한 번에 받아온다. | |
| 131 | + * | |
| 132 | + * 검색 파라미터(`searchCondition`/`searchKeyword`)를 **의도적으로 보내지 않는다** — 검색은 | |
| 133 | + * mock 행까지 포함해 일관되게 걸러야 하므로 아래 `filterByKeyword`가 전담한다. 백엔드에 검색을 | |
| 134 | + * 맡기면 백엔드 행만 걸러지고 mock 행은 그대로 남아 결과가 어긋난다. | |
| 135 | + */ | |
| 136 | +async function fetchAllAdminMembers(): Promise<AdminMember[]> { | |
| 137 | + const accessToken = await getSessionAccessToken(); | |
| 138 | + | |
| 139 | + const result = await backendFetch<unknown>(ADMIN_MEMBER_PAGINATION_PATH, { | |
| 140 | + method: 'GET', | |
| 141 | + // `PaginationUtil`이 offset을 직접 계산해 firstIndex를 덮어쓰므로 실제로 쓰이는 파라미터는 | |
| 142 | + // 이 둘뿐이다 — 나머지(pageUnit/pageSize/firstIndex/lastIndex)는 읽히지 않아 보내지 않는다. | |
| 143 | + query: { pageIndex: 1, recordCountPerPage: ADMIN_MEMBER_FETCH_LIMIT }, | |
| 144 | + accessToken: accessToken ?? undefined, | |
| 145 | + cache: 'no-store', | |
| 146 | + }); | |
| 147 | + | |
| 148 | + if (!result.ok) { | |
| 149 | + throw new BackendRequestError(result); | |
| 150 | + } | |
| 151 | + | |
| 152 | + const data = result.data; | |
| 153 | + if (!isRecord(data) || !Array.isArray(data.list)) { | |
| 154 | + throw new Error('관리자 회원 목록 응답의 형식이 올바르지 않습니다.'); | |
| 155 | + } | |
| 156 | + | |
| 157 | + return data.list.map(toAdminMember); | |
| 158 | +} | |
| 159 | + | |
| 160 | +/** 검색 대상 → 비교할 값. 목록에 실제로 보이는 값으로만 거른다. */ | |
| 161 | +const SEARCH_VALUE_BY_FIELD: Record< | |
| 162 | + AdminMemberSearchField, | |
| 163 | + (member: AdminMember) => string | |
| 164 | +> = { | |
| 165 | + name: (member) => member.name, | |
| 166 | + loginId: (member) => member.loginId, | |
| 167 | +}; | |
| 168 | + | |
| 169 | +function filterByKeyword( | |
| 170 | + items: AdminMember[], | |
| 171 | + query: AdminMemberQuery | |
| 172 | +): AdminMember[] { | |
| 173 | + const keyword = query.keyword.trim().toLowerCase(); | |
| 174 | + if (!keyword) { | |
| 175 | + return items; | |
| 176 | + } | |
| 177 | + | |
| 178 | + const readValue = SEARCH_VALUE_BY_FIELD[query.searchField]; | |
| 179 | + return items.filter((item) => readValue(item).toLowerCase().includes(keyword)); | |
| 180 | +} | |
| 181 | + | |
| 182 | +/** | |
| 183 | + * 정렬. 생성일순은 **정렬하지 않는다** — 백엔드가 이미 생성일 최신순으로 내려주고(고정 | |
| 184 | + * `ORDER BY rnum DESC`), mock 신규 등록 행은 오버레이가 맨 앞에 붙여 두기 때문이다. 응답에 | |
| 185 | + * `createdAt` 값 자체가 없어 우리가 다시 정렬할 수단도 없다. | |
| 186 | + */ | |
| 187 | +function sortItems( | |
| 188 | + items: AdminMember[], | |
| 189 | + query: AdminMemberQuery | |
| 190 | +): AdminMember[] { | |
| 191 | + if (query.sort !== 'name') { | |
| 192 | + return items; | |
| 193 | + } | |
| 194 | + return [...items].sort((a, b) => a.name.localeCompare(b.name, 'ko')); | |
| 195 | +} | |
| 196 | + | |
| 197 | +export type AdminMemberPage = { | |
| 198 | + items: AdminMember[]; | |
| 199 | + /** 검색 조건을 적용한 전체 건수. 전체를 손에 쥐고 세므로 확정값이다. */ | |
| 200 | + totalCount: number; | |
| 201 | +}; | |
| 202 | + | |
| 203 | +/** 검색·정렬·페이징이 적용된 관리자 회원 목록을 조회한다. */ | |
| 204 | +export async function fetchAdminMembers( | |
| 205 | + query: AdminMemberQuery | |
| 206 | +): Promise<AdminMemberPage> { | |
| 207 | + const all = applyMockOverlay(await fetchAllAdminMembers()); | |
| 208 | + const matched = sortItems(filterByKeyword(all, query), query); | |
| 209 | + | |
| 210 | + const offset = (query.page - 1) * query.pageSize; | |
| 211 | + | |
| 212 | + return { | |
| 213 | + items: matched.slice(offset, offset + query.pageSize), | |
| 214 | + totalCount: matched.length, | |
| 215 | + }; | |
| 216 | +} | |
| 217 | + | |
| 218 | +/** 단건 조회 — 수정 팝업이 쓰는 진입점. 백엔드에 단건 API가 없어 목록에서 찾는다. */ | |
| 219 | +export async function findAdminMemberById( | |
| 220 | + id: string | |
| 221 | +): Promise<AdminMember | null> { | |
| 222 | + const all = applyMockOverlay(await fetchAllAdminMembers()); | |
| 223 | + return all.find((item) => item.id === id) ?? null; | |
| 224 | +} | |
| 225 | + | |
| 226 | +/** | |
| 227 | + * ID 중복 확인(시안 ADM_ADM_102_p ①). 백엔드에 중복 확인 API가 없어 목록에 이미 있는 ID인지로 | |
| 228 | + * 판정한다 — 목록은 실제 계정 전부를 담으므로 판정 자체는 맞지만, 확인 시점과 저장 시점 사이에 | |
| 229 | + * 다른 관리자가 같은 ID를 선점하는 경쟁 조건은 막지 못한다. 저장 시 유일성 보장은 결국 백엔드 | |
| 230 | + * (DB 유니크 제약)의 몫이다. | |
| 231 | + */ | |
| 232 | +export async function isAdminLoginIdTaken(loginId: string): Promise<boolean> { | |
| 233 | + const normalized = loginId.trim().toLowerCase(); | |
| 234 | + | |
| 235 | + const backendLoginIds = (await fetchAllAdminMembers()).map( | |
| 236 | + (item) => item.loginId | |
| 237 | + ); | |
| 238 | + | |
| 239 | + return [...backendLoginIds, ...listMockCreatedLoginIds()].some( | |
| 240 | + (existing) => existing.toLowerCase() === normalized | |
| 241 | + ); | |
| 242 | +} | |
| 243 | + | |
| 244 | +/* | |
| 245 | + * ─── 쓰기 경로 ──────────────────────────────────────────────────────────────── | |
| 246 | + * 백엔드에 등록·수정·삭제 API가 없어 세 함수 모두 mock 저장소에 위임한다 | |
| 247 | + * (`lib/data/mock/admin-member-store.ts`의 주석에 한계를 적어 두었다). | |
| 248 | + * 백엔드 API가 생기면 **이 세 함수의 본문만** `backendFetch` 호출로 바꾸면 되고, | |
| 249 | + * Server Action과 화면은 그대로다 — 그러라고 호출부가 이 계층만 보게 두었다. | |
| 250 | + */ | |
| 251 | + | |
| 252 | +export async function createAdminMember( | |
| 253 | + input: CreateAdminMemberInput | |
| 254 | +): Promise<void> { | |
| 255 | + createMockAdminMember(input); | |
| 256 | +} | |
| 257 | + | |
| 258 | +export async function updateAdminMember( | |
| 259 | + id: string, | |
| 260 | + input: UpdateAdminMemberInput | |
| 261 | +): Promise<void> { | |
| 262 | + updateMockAdminMember(id, input); | |
| 263 | +} | |
| 264 | + | |
| 265 | +export async function deleteAdminMember(id: string): Promise<void> { | |
| 266 | + deleteMockAdminMember(id); | |
| 267 | +} |
+++ lib/domain/admin-member-form.ts
... | ... | @@ -0,0 +1,222 @@ |
| 1 | +/** | |
| 2 | + * 관리자 등록/수정 입력 규칙 — 순수 검증 로직만 담는다(외부 의존 없음). | |
| 3 | + * | |
| 4 | + * 규칙은 시안(ADM_ADM_102_p / ADM_ADM_103_p)의 안내 문구를 그대로 옮긴 것이다: | |
| 5 | + * - ID : "영어 소문자, 숫자를 조합하여 입력 후 중복여부를 확인합니다." | |
| 6 | + * - 비밀번호: "영어 소문자, 숫자, 특수문자 중 2종류 이상 조합, 최소 10자리 이상" | |
| 7 | + * | |
| 8 | + * **이 파일이 검증의 단일 진실원천이다.** Server Action(`_actions.ts`)이 저장 직전에 여기를 | |
| 9 | + * 거치고, 화면의 안내 문구도 여기 상수를 그대로 쓴다 — 규칙이 화면과 서버에서 갈라지는 것을 막는다. | |
| 10 | + * 화면 입력 단계의 즉시 피드백이 아니라 **제출 시 서버 검증**이 최종 방어선이다(Server Action은 | |
| 11 | + * UI를 거치지 않고 직접 호출될 수 있다). | |
| 12 | + * | |
| 13 | + * **등록과 수정의 검증을 분리한 이유**: 수정 팝업에서 이름·ID는 읽기 전용이다(시안 103_p ①). | |
| 14 | + * 그 값에까지 등록용 형식 규칙을 다시 적용하면, 규칙이 생기기 전에 만들어졌거나 규칙 밖에서 | |
| 15 | + * 발급된 기존 계정(예: 숫자가 없는 ID)이 **자기 정보를 저장할 수 없게 된다** — 바꾸지도 않는 | |
| 16 | + * 필드 때문에. 그래서 수정은 실제로 바뀔 수 있는 항목만 검증한다. | |
| 17 | + * | |
| 18 | + * 백엔드에 등록/수정 API가 생기면 백엔드 규칙과 대조해 이 파일을 맞춰야 한다 — 지금은 시안이 | |
| 19 | + * 유일한 근거다(백엔드에 관리자 등록 관련 코드 자체가 없다). | |
| 20 | + */ | |
| 21 | + | |
| 22 | +import { | |
| 23 | + ADMIN_MENU_OPTIONS, | |
| 24 | + ADMIN_ROLE_OPTIONS, | |
| 25 | + type AdminRoleCode, | |
| 26 | +} from '@/lib/domain/admin-member'; | |
| 27 | + | |
| 28 | +export const ADMIN_LOGIN_ID_HELP_TEXT = | |
| 29 | + '영어 소문자, 숫자를 조합하여 입력 후 중복여부를 확인합니다.'; | |
| 30 | +export const ADMIN_PASSWORD_HELP_TEXT = | |
| 31 | + '영어 소문자, 숫자, 특수문자 중 2종류 이상 조합, 최소 10자리 이상'; | |
| 32 | + | |
| 33 | +const LOGIN_ID_MIN_LENGTH = 4; | |
| 34 | +const LOGIN_ID_MAX_LENGTH = 20; | |
| 35 | +const PASSWORD_MIN_LENGTH = 10; | |
| 36 | +const NAME_MAX_LENGTH = 50; | |
| 37 | +const EMAIL_MAX_LENGTH = 100; | |
| 38 | + | |
| 39 | +/** 휴대전화번호는 시안처럼 3칸으로 나뉘어 입력된다. 앞자리는 010 등 3자리, 가운데 3~4자리, 끝 4자리. */ | |
| 40 | +const PHONE_PART_PATTERNS = [/^\d{3}$/, /^\d{3,4}$/, /^\d{4}$/] as const; | |
| 41 | + | |
| 42 | +/** 등록·수정 양쪽에서 실제로 바뀔 수 있는 항목. */ | |
| 43 | +export type AdminMemberEditableValues = { | |
| 44 | + password: string; | |
| 45 | + phoneNumber: string; | |
| 46 | + email: string; | |
| 47 | + roleCode: AdminRoleCode; | |
| 48 | + menuCodes: string[]; | |
| 49 | +}; | |
| 50 | + | |
| 51 | +/** 등록은 위 항목에 더해 이름·ID를 입력받는다(수정에서는 읽기 전용). */ | |
| 52 | +export type AdminMemberCreateValues = AdminMemberEditableValues & { | |
| 53 | + name: string; | |
| 54 | + loginId: string; | |
| 55 | +}; | |
| 56 | + | |
| 57 | +/** 필드별 오류 메시지 — 키는 폼 필드 이름과 일치시켜 화면이 그대로 붙여 쓸 수 있게 한다. */ | |
| 58 | +export type AdminMemberFormErrors = Partial< | |
| 59 | + Record<keyof AdminMemberCreateValues, string> | |
| 60 | +>; | |
| 61 | + | |
| 62 | +export type ValidationResult<T> = | |
| 63 | + | { ok: true; values: T } | |
| 64 | + | { ok: false; errors: AdminMemberFormErrors }; | |
| 65 | + | |
| 66 | +/** | |
| 67 | + * 비밀번호 조합 종류 수 — 영문 소문자 / 숫자 / 특수문자 중 몇 종류가 섞였는지 센다. | |
| 68 | + * 시안이 "2종류 이상"만 요구하므로 대문자는 별도 종류로 세지 않는다(있어도 무방하다). | |
| 69 | + */ | |
| 70 | +function countCharacterKinds(password: string): number { | |
| 71 | + const kinds = [/[a-z]/, /\d/, /[^a-zA-Z0-9]/]; | |
| 72 | + return kinds.filter((pattern) => pattern.test(password)).length; | |
| 73 | +} | |
| 74 | + | |
| 75 | +/** | |
| 76 | + * ID 형식 검증 — 문제가 있으면 안내 문구, 없으면 null. | |
| 77 | + * | |
| 78 | + * 중복 확인(`checkAdminLoginId`)도 이 함수를 그대로 쓴다 — 중복을 묻기 전에 형식부터 봐야 하고, | |
| 79 | + * 그 판단 기준이 등록 시점과 달라지면 안 되기 때문이다. | |
| 80 | + */ | |
| 81 | +export function validateAdminLoginId(loginId: string): string | null { | |
| 82 | + const value = loginId.trim(); | |
| 83 | + | |
| 84 | + if (!value) { | |
| 85 | + return 'ID를 입력해 주세요.'; | |
| 86 | + } | |
| 87 | + if (value.length < LOGIN_ID_MIN_LENGTH || value.length > LOGIN_ID_MAX_LENGTH) { | |
| 88 | + return `ID는 ${LOGIN_ID_MIN_LENGTH}~${LOGIN_ID_MAX_LENGTH}자로 입력해 주세요.`; | |
| 89 | + } | |
| 90 | + // "영어 소문자, 숫자를 조합" — 허용 문자를 두 종류로 제한하고, 둘 다 포함되어야 한다. | |
| 91 | + if (!/^[a-z0-9]+$/.test(value) || !/[a-z]/.test(value) || !/\d/.test(value)) { | |
| 92 | + return ADMIN_LOGIN_ID_HELP_TEXT; | |
| 93 | + } | |
| 94 | + return null; | |
| 95 | +} | |
| 96 | + | |
| 97 | +/** | |
| 98 | + * 휴대전화번호 3칸을 하나의 문자열(`010-1234-5678`)로 합친다. 비어 있는 칸이 하나라도 있거나 | |
| 99 | + * 형식이 맞지 않으면 null — 부분적으로 채워진 번호를 저장하지 않기 위해서다. | |
| 100 | + */ | |
| 101 | +export function joinPhoneNumber(parts: string[]): string | null { | |
| 102 | + if (parts.length !== PHONE_PART_PATTERNS.length) { | |
| 103 | + return null; | |
| 104 | + } | |
| 105 | + | |
| 106 | + const trimmed = parts.map((part) => part.trim()); | |
| 107 | + const isValid = trimmed.every((part, index) => | |
| 108 | + PHONE_PART_PATTERNS[index].test(part) | |
| 109 | + ); | |
| 110 | + | |
| 111 | + return isValid ? trimmed.join('-') : null; | |
| 112 | +} | |
| 113 | + | |
| 114 | +/** 저장된 번호를 다시 3칸으로 나눈다(수정 팝업의 초기값). 형식이 다르면 빈 칸들을 돌려준다. */ | |
| 115 | +export function splitPhoneNumber(phoneNumber: string | null): string[] { | |
| 116 | + const parts = (phoneNumber ?? '').split('-'); | |
| 117 | + return parts.length === PHONE_PART_PATTERNS.length ? parts : ['', '', '']; | |
| 118 | +} | |
| 119 | + | |
| 120 | +function isValidEmail(email: string): boolean { | |
| 121 | + // 공백 없는 `로컬부@도메인.최상위` 정도만 본다 — 이메일의 완전한 문법 검증은 정규식으로 | |
| 122 | + // 할 수 없고, 실제 유효성은 발송으로만 확인된다. 오탈자를 걸러내는 것이 목적이다. | |
| 123 | + return ( | |
| 124 | + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) && email.length <= EMAIL_MAX_LENGTH | |
| 125 | + ); | |
| 126 | +} | |
| 127 | + | |
| 128 | +/** | |
| 129 | + * 등록·수정 공통 항목 검증. 오류는 넘겨받은 객체에 채워 넣고, 정규화된 값을 돌려준다. | |
| 130 | + * | |
| 131 | + * `isPasswordRequired`로 등록과 수정을 가른다 — 등록은 비밀번호가 필수지만(시안 102_p ①), | |
| 132 | + * 수정은 [비밀번호 변경]을 눌러 입력한 경우에만 검사한다(비우면 기존 비밀번호 유지). | |
| 133 | + */ | |
| 134 | +function validateEditableValues( | |
| 135 | + values: AdminMemberEditableValues, | |
| 136 | + errors: AdminMemberFormErrors, | |
| 137 | + options: { isPasswordRequired: boolean } | |
| 138 | +): AdminMemberEditableValues { | |
| 139 | + const password = values.password; | |
| 140 | + const phoneNumber = values.phoneNumber.trim(); | |
| 141 | + const email = values.email.trim(); | |
| 142 | + | |
| 143 | + if (options.isPasswordRequired || password) { | |
| 144 | + if ( | |
| 145 | + password.length < PASSWORD_MIN_LENGTH || | |
| 146 | + countCharacterKinds(password) < 2 | |
| 147 | + ) { | |
| 148 | + errors.password = ADMIN_PASSWORD_HELP_TEXT; | |
| 149 | + } | |
| 150 | + } | |
| 151 | + | |
| 152 | + if (!phoneNumber) { | |
| 153 | + errors.phoneNumber = '휴대전화 번호를 정확히 입력해 주세요.'; | |
| 154 | + } | |
| 155 | + | |
| 156 | + // 이메일은 선택 항목이라 비어 있는 것 자체는 오류가 아니다(시안에 필수 표시가 없다). | |
| 157 | + if (email && !isValidEmail(email)) { | |
| 158 | + errors.email = '이메일 형식이 올바르지 않습니다.'; | |
| 159 | + } | |
| 160 | + | |
| 161 | + if (!ADMIN_ROLE_OPTIONS.some((option) => option.value === values.roleCode)) { | |
| 162 | + errors.roleCode = '역할을 선택해 주세요.'; | |
| 163 | + } | |
| 164 | + | |
| 165 | + const menuCodes = values.menuCodes.filter((code) => | |
| 166 | + ADMIN_MENU_OPTIONS.some((option) => option.value === code) | |
| 167 | + ); | |
| 168 | + if (menuCodes.length === 0) { | |
| 169 | + errors.menuCodes = '메뉴를 1개 이상 선택해 주세요.'; | |
| 170 | + } | |
| 171 | + | |
| 172 | + return { password, phoneNumber, email, roleCode: values.roleCode, menuCodes }; | |
| 173 | +} | |
| 174 | + | |
| 175 | +/** 시안 ADM_ADM_102_p — 등록 검증(이름·ID 포함). */ | |
| 176 | +export function validateAdminMemberCreate( | |
| 177 | + values: AdminMemberCreateValues | |
| 178 | +): ValidationResult<AdminMemberCreateValues> { | |
| 179 | + const errors: AdminMemberFormErrors = {}; | |
| 180 | + | |
| 181 | + const name = values.name.trim(); | |
| 182 | + if (!name) { | |
| 183 | + errors.name = '이름을 입력해 주세요.'; | |
| 184 | + } else if (name.length > NAME_MAX_LENGTH) { | |
| 185 | + errors.name = `이름은 ${NAME_MAX_LENGTH}자 이내로 입력해 주세요.`; | |
| 186 | + } | |
| 187 | + | |
| 188 | + const loginIdError = validateAdminLoginId(values.loginId); | |
| 189 | + if (loginIdError) { | |
| 190 | + errors.loginId = loginIdError; | |
| 191 | + } | |
| 192 | + | |
| 193 | + const editable = validateEditableValues(values, errors, { | |
| 194 | + isPasswordRequired: true, | |
| 195 | + }); | |
| 196 | + | |
| 197 | + if (Object.keys(errors).length > 0) { | |
| 198 | + return { ok: false, errors }; | |
| 199 | + } | |
| 200 | + | |
| 201 | + return { | |
| 202 | + ok: true, | |
| 203 | + values: { ...editable, name, loginId: values.loginId.trim() }, | |
| 204 | + }; | |
| 205 | +} | |
| 206 | + | |
| 207 | +/** 시안 ADM_ADM_103_p — 수정 검증. 이름·ID는 읽기 전용이라 검증 대상이 아니다(파일 상단 주석). */ | |
| 208 | +export function validateAdminMemberUpdate( | |
| 209 | + values: AdminMemberEditableValues | |
| 210 | +): ValidationResult<AdminMemberEditableValues> { | |
| 211 | + const errors: AdminMemberFormErrors = {}; | |
| 212 | + | |
| 213 | + const editable = validateEditableValues(values, errors, { | |
| 214 | + isPasswordRequired: false, | |
| 215 | + }); | |
| 216 | + | |
| 217 | + if (Object.keys(errors).length > 0) { | |
| 218 | + return { ok: false, errors }; | |
| 219 | + } | |
| 220 | + | |
| 221 | + return { ok: true, values: editable }; | |
| 222 | +} |
+++ lib/domain/admin-member-query.ts
... | ... | @@ -0,0 +1,167 @@ |
| 1 | +/** | |
| 2 | + * 관리자 회원 목록의 검색·정렬·페이징 조건 — 순수 규칙(허용 값·기본값·URL 직렬화)만 담는다. | |
| 3 | + * next/react 의존이 없다(`URLSearchParams`는 서버·브라우저 양쪽에서 쓸 수 있는 표준 Web API). | |
| 4 | + * | |
| 5 | + * `app/(protected)/(basic)/admins/page.tsx`가 `searchParams`를 `parseAdminMemberQuery`로 | |
| 6 | + * 정규화하는 지점이자 단일 진실원천이며, 검색바·툴바·페이지네이션은 모두 `buildAdminMemberHref`로 | |
| 7 | + * 같은 규칙에 따라 URL을 만들어 파라미터 이름·기본값이 여러 파일에 흩어져 드리프트하는 것을 막는다. | |
| 8 | + * | |
| 9 | + * 학생 회원 목록(`student-member-query.ts`)과 구조가 같지만 파일을 합치지 않았다 — 두 화면의 | |
| 10 | + * 검색 대상·정렬 기준·기본값이 각자의 기획(ADM_MEM_101 / ADM_ADM_101)을 따라 서로 다르게 | |
| 11 | + * 움직이고, 한쪽 기획 변경이 다른 화면을 건드리게 되는 결합이 공통화의 이득보다 크다. | |
| 12 | + */ | |
| 13 | + | |
| 14 | +/** 라우트 경로 — 이 파일 안에서만 하드코딩하고 나머지는 이 상수를 참조한다. */ | |
| 15 | +export const ADMIN_MEMBERS_PATH = '/admins'; | |
| 16 | + | |
| 17 | +/** | |
| 18 | + * 검색 대상 — 시안(ADM_ADM_101 ①)은 회원명/ID/휴대전화번호 셋을 요구하지만 **휴대전화번호는 뺐다.** | |
| 19 | + * | |
| 20 | + * 두 가지 이유가 겹친다: | |
| 21 | + * 1. 목록 응답에 휴대전화번호가 없다(`MngrAdminMapper.xml`의 select 목록에도, `MngrAdminVo`에도 | |
| 22 | + * 없다). 화면에 보이지도 않는 값으로 걸러낸 결과는 사용자가 검증할 수 없다. | |
| 23 | + * 2. 백엔드의 휴대전화번호 검색 분기(`searchCondition == "3"`)는 `USER_TELNO` 컬럼을 참조하는데, | |
| 24 | + * 이는 학생 목록 쿼리(TB_COM_USER 대상)에서 그대로 복사된 것으로 보인다 — 관리자 테이블 | |
| 25 | + * (TB_ADM_USER)의 select 목록에는 그런 컬럼이 없다. 컬럼이 실제로 없다면 이 검색은 SQL 오류로 | |
| 26 | + * 떨어져 화면 전체가 에러 경계로 넘어간다. | |
| 27 | + * | |
| 28 | + * 백엔드가 휴대전화번호를 응답에 포함하면 여기와 `admin-member-repository.ts`의 필터에 함께 | |
| 29 | + * 추가한다. | |
| 30 | + */ | |
| 31 | +export type AdminMemberSearchField = 'name' | 'loginId'; | |
| 32 | + | |
| 33 | +export const ADMIN_MEMBER_SEARCH_FIELD_OPTIONS: ReadonlyArray<{ | |
| 34 | + value: AdminMemberSearchField; | |
| 35 | + label: string; | |
| 36 | +}> = [ | |
| 37 | + { value: 'name', label: '회원명' }, | |
| 38 | + { value: 'loginId', label: 'ID' }, | |
| 39 | +]; | |
| 40 | + | |
| 41 | +/** | |
| 42 | + * 정렬 기준 — 시안의 select는 "가입일순"이지만 관리자 회원의 해당 값은 생성일이라 이름을 맞췄다. | |
| 43 | + * | |
| 44 | + * `createdAt`은 백엔드가 응답에 담아 주지 않는데도 정렬이 가능한 이유는, 목록 SQL의 고정 | |
| 45 | + * `ORDER BY rnum DESC`가 곧 생성일 최신순이기 때문이다(rnum은 `frst_reg_dt` 오름차순 행번호). | |
| 46 | + * 즉 이 정렬은 "백엔드가 준 순서를 그대로 쓴다"는 뜻이다 — 자세한 근거는 Repository 주석 참조. | |
| 47 | + */ | |
| 48 | +export type AdminMemberSortOption = 'createdAt' | 'name'; | |
| 49 | + | |
| 50 | +export const ADMIN_MEMBER_SORT_OPTIONS: ReadonlyArray<{ | |
| 51 | + value: AdminMemberSortOption; | |
| 52 | + label: string; | |
| 53 | +}> = [ | |
| 54 | + { value: 'createdAt', label: '생성일순' }, | |
| 55 | + { value: 'name', label: '이름순' }, | |
| 56 | +]; | |
| 57 | + | |
| 58 | +export const ADMIN_MEMBER_PAGE_SIZE_OPTIONS = [10, 30, 50] as const; | |
| 59 | +export type AdminMemberPageSize = | |
| 60 | + (typeof ADMIN_MEMBER_PAGE_SIZE_OPTIONS)[number]; | |
| 61 | + | |
| 62 | +export const DEFAULT_ADMIN_MEMBER_SEARCH_FIELD: AdminMemberSearchField = 'name'; | |
| 63 | +export const DEFAULT_ADMIN_MEMBER_SORT: AdminMemberSortOption = 'createdAt'; | |
| 64 | +export const DEFAULT_ADMIN_MEMBER_PAGE_SIZE: AdminMemberPageSize = 10; | |
| 65 | +const DEFAULT_PAGE = 1; | |
| 66 | +const MAX_KEYWORD_LENGTH = 100; | |
| 67 | + | |
| 68 | +export type AdminMemberQuery = { | |
| 69 | + searchField: AdminMemberSearchField; | |
| 70 | + keyword: string; | |
| 71 | + sort: AdminMemberSortOption; | |
| 72 | + page: number; | |
| 73 | + pageSize: AdminMemberPageSize; | |
| 74 | +}; | |
| 75 | + | |
| 76 | +/** Next.js `page.tsx`의 `searchParams`가 리졸브하는 값 형태를 그대로 옮긴 구조 타입 — | |
| 77 | + * next 패키지를 import하지 않고도 같은 shape을 표현해 domain 계층의 무의존 규칙을 지킨다. */ | |
| 78 | +type RawSearchParams = Record<string, string | string[] | undefined>; | |
| 79 | + | |
| 80 | +function readParam(params: RawSearchParams, key: string): string | undefined { | |
| 81 | + const value = params[key]; | |
| 82 | + return Array.isArray(value) ? value[0] : value; | |
| 83 | +} | |
| 84 | + | |
| 85 | +function isAdminMemberSearchField( | |
| 86 | + value: string | undefined | |
| 87 | +): value is AdminMemberSearchField { | |
| 88 | + return ( | |
| 89 | + value !== undefined && | |
| 90 | + ADMIN_MEMBER_SEARCH_FIELD_OPTIONS.some((option) => option.value === value) | |
| 91 | + ); | |
| 92 | +} | |
| 93 | + | |
| 94 | +function isAdminMemberSortOption( | |
| 95 | + value: string | undefined | |
| 96 | +): value is AdminMemberSortOption { | |
| 97 | + return ( | |
| 98 | + value !== undefined && | |
| 99 | + ADMIN_MEMBER_SORT_OPTIONS.some((option) => option.value === value) | |
| 100 | + ); | |
| 101 | +} | |
| 102 | + | |
| 103 | +function isAdminMemberPageSize(value: number): value is AdminMemberPageSize { | |
| 104 | + return (ADMIN_MEMBER_PAGE_SIZE_OPTIONS as readonly number[]).includes(value); | |
| 105 | +} | |
| 106 | + | |
| 107 | +/** | |
| 108 | + * URL의 searchParams를 검증된 `AdminMemberQuery`로 정규화한다. 값이 없거나 허용 목록을 | |
| 109 | + * 벗어나면 기본값으로 fallback한다 — searchParams는 사용자가 임의로 조작 가능한 값이라 | |
| 110 | + * 신뢰하지 않는다. | |
| 111 | + */ | |
| 112 | +export function parseAdminMemberQuery( | |
| 113 | + searchParams: RawSearchParams | |
| 114 | +): AdminMemberQuery { | |
| 115 | + const searchFieldRaw = readParam(searchParams, 'searchField'); | |
| 116 | + const sortRaw = readParam(searchParams, 'sort'); | |
| 117 | + const keywordRaw = readParam(searchParams, 'keyword'); | |
| 118 | + const pageRaw = Number(readParam(searchParams, 'page')); | |
| 119 | + const pageSizeRaw = Number(readParam(searchParams, 'pageSize')); | |
| 120 | + | |
| 121 | + return { | |
| 122 | + searchField: isAdminMemberSearchField(searchFieldRaw) | |
| 123 | + ? searchFieldRaw | |
| 124 | + : DEFAULT_ADMIN_MEMBER_SEARCH_FIELD, | |
| 125 | + keyword: (keywordRaw ?? '').trim().slice(0, MAX_KEYWORD_LENGTH), | |
| 126 | + sort: isAdminMemberSortOption(sortRaw) | |
| 127 | + ? sortRaw | |
| 128 | + : DEFAULT_ADMIN_MEMBER_SORT, | |
| 129 | + page: Number.isInteger(pageRaw) && pageRaw > 0 ? pageRaw : DEFAULT_PAGE, | |
| 130 | + pageSize: isAdminMemberPageSize(pageSizeRaw) | |
| 131 | + ? pageSizeRaw | |
| 132 | + : DEFAULT_ADMIN_MEMBER_PAGE_SIZE, | |
| 133 | + }; | |
| 134 | +} | |
| 135 | + | |
| 136 | +/** | |
| 137 | + * `AdminMemberQuery`(+ 부분 override)를 `/admins` 링크로 직렬화한다. `parseAdminMemberQuery`의 | |
| 138 | + * 역연산이며, 기본값과 같은 필드는 URL에서 생략해 링크를 짧게 유지한다. | |
| 139 | + */ | |
| 140 | +export function buildAdminMemberHref( | |
| 141 | + query: AdminMemberQuery, | |
| 142 | + overrides: Partial<AdminMemberQuery> = {} | |
| 143 | +): string { | |
| 144 | + const merged = { ...query, ...overrides }; | |
| 145 | + const params = new URLSearchParams(); | |
| 146 | + | |
| 147 | + if (merged.searchField !== DEFAULT_ADMIN_MEMBER_SEARCH_FIELD) { | |
| 148 | + params.set('searchField', merged.searchField); | |
| 149 | + } | |
| 150 | + if (merged.keyword) { | |
| 151 | + params.set('keyword', merged.keyword); | |
| 152 | + } | |
| 153 | + if (merged.sort !== DEFAULT_ADMIN_MEMBER_SORT) { | |
| 154 | + params.set('sort', merged.sort); | |
| 155 | + } | |
| 156 | + if (merged.pageSize !== DEFAULT_ADMIN_MEMBER_PAGE_SIZE) { | |
| 157 | + params.set('pageSize', String(merged.pageSize)); | |
| 158 | + } | |
| 159 | + if (merged.page !== DEFAULT_PAGE) { | |
| 160 | + params.set('page', String(merged.page)); | |
| 161 | + } | |
| 162 | + | |
| 163 | + const queryString = params.toString(); | |
| 164 | + return queryString | |
| 165 | + ? `${ADMIN_MEMBERS_PATH}?${queryString}` | |
| 166 | + : ADMIN_MEMBERS_PATH; | |
| 167 | +} |
+++ lib/domain/admin-member.ts
... | ... | @@ -0,0 +1,141 @@ |
| 1 | +/** | |
| 2 | + * 관리자 회원 도메인 타입 — 순수 데이터 표현, 외부 의존 없음. | |
| 3 | + * | |
| 4 | + * 데이터 출처는 백엔드(edupay-backend)의 `GET /api/v1/mngr/admin/pagination`이며, | |
| 5 | + * `lib/data/repositories/admin-member-repository.ts`가 응답을 이 타입으로 매핑한다. | |
| 6 | + * | |
| 7 | + * **`null`의 의미는 "백엔드가 아직 주지 않는 항목"이다.** 목록 응답 VO(MngrAdminVo)가 담는 값은 | |
| 8 | + * 식별자·이름·역할·계정상태 계열뿐이라 시안(ADM_ADM_101)의 휴대전화번호·이메일·생성일은 전부 | |
| 9 | + * `null`로 채워지고 화면에서 `-`로 표시된다. 화면 컬럼은 그대로 유지한다 — 백엔드가 필드를 | |
| 10 | + * 추가하면 Repository의 매핑만 늘리면 값이 그대로 채워진다. | |
| 11 | + * | |
| 12 | + * 등록/수정/삭제 API가 아직 없어 그 경로는 mock으로 동작한다(`lib/data/mock/admin-member-store.ts`). | |
| 13 | + * mock이 만들어 낸 행만 휴대전화번호·이메일·메뉴 권한 값을 실제로 갖는다. | |
| 14 | + */ | |
| 15 | + | |
| 16 | +/** 역할 코드 — 백엔드 `admRoleCd`. 코드 체계를 백엔드가 확정하지 않아 문자열로 다룬다. */ | |
| 17 | +export type AdminRoleCode = string; | |
| 18 | + | |
| 19 | +export type AdminMember = { | |
| 20 | + /** 내부 식별자 — 백엔드 `admUserId`. 목록 행의 key이자 수정/삭제의 입력값이다. */ | |
| 21 | + id: string; | |
| 22 | + /** 백엔드 `admNm`. */ | |
| 23 | + name: string; | |
| 24 | + /** 백엔드 `loginId`. 등록 후에는 변경할 수 없다(시안 ADM_ADM_103_p ①). */ | |
| 25 | + loginId: string; | |
| 26 | + /** 백엔드 목록 응답에 없다 — mock으로 등록한 행에만 값이 있다. */ | |
| 27 | + phoneNumber: string | null; | |
| 28 | + /** 백엔드 목록 응답에 없다 — mock으로 등록한 행에만 값이 있다. */ | |
| 29 | + email: string | null; | |
| 30 | + /** 백엔드 `admRoleCd`. 표기용 이름은 `formatAdminRoleLabel`이 만든다. */ | |
| 31 | + roleCode: AdminRoleCode; | |
| 32 | + /** | |
| 33 | + * 접근 가능 메뉴 코드 목록(시안 ADM_ADM_102_p ③ "메뉴 선택", 다중 선택). | |
| 34 | + * 백엔드 목록 응답에 없어 mock으로 등록/수정한 행에만 값이 있다 — 빈 배열은 "미지정"이다. | |
| 35 | + */ | |
| 36 | + menuCodes: string[]; | |
| 37 | + /** ISO 형식(YYYY-MM-DD) 문자열. 백엔드 목록 응답에 없다(정렬에만 쓰이고 select되지 않는다). */ | |
| 38 | + createdAt: string | null; | |
| 39 | + /** 백엔드 `acctLockYn` — 계정 잠김 여부. 시안의 "잠김여부"는 이 값의 반대(활성/비활성)다. */ | |
| 40 | + isLocked: boolean | null; | |
| 41 | + /** 백엔드 `useYn` — 사용 여부. */ | |
| 42 | + isActive: boolean | null; | |
| 43 | + /** 백엔드 `loginFailCnt` — 로그인 연속 실패 횟수. 잠김 사유 판단용이라 목록에는 노출하지 않는다. */ | |
| 44 | + loginFailCount: number | null; | |
| 45 | +}; | |
| 46 | + | |
| 47 | +/** 값이 없는 항목의 화면 표기. 표·팝업이 같은 문자를 쓰도록 여기 한 곳에 둔다. */ | |
| 48 | +export const EMPTY_FIELD_PLACEHOLDER = '-'; | |
| 49 | + | |
| 50 | +/** 값이 없으면 `-`, 있으면 문자열로 표기한다. */ | |
| 51 | +export function formatOptionalValue(value: string | number | null): string { | |
| 52 | + return value === null ? EMPTY_FIELD_PLACEHOLDER : String(value); | |
| 53 | +} | |
| 54 | + | |
| 55 | +/** | |
| 56 | + * 역할 코드 → 화면 표기 이름. | |
| 57 | + * | |
| 58 | + * **백엔드에 역할 코드 목록을 주는 API도, 코드 상수도 없다** — `TB_ADM_USER.ADM_ROLE_CD`를 | |
| 59 | + * 그대로 내려줄 뿐이고 코드값의 정의는 어디에도 없다(백엔드 저장소 전체 검색으로 확인). | |
| 60 | + * 시안(ADM_ADM_101)이 요구하는 표기는 "최고관리자/일반관리자" 둘이고, 실제로 관측된 코드는 | |
| 61 | + * 로그인 토큰의 `admRoleCd` 클레임에서 본 `ROLE_SYSTEM` 하나뿐이다(`lib/domain/admin-user.ts`). | |
| 62 | + * | |
| 63 | + * 그래서 아래 표는 **잠정 매핑**이며, 표에 없는 코드는 임의의 이름을 지어내지 않고 코드를 그대로 | |
| 64 | + * 노출한다 — 잘못된 역할 이름을 보여주는 것보다 낯선 코드를 보여주는 편이 낫다(권한 화면이라 | |
| 65 | + * 표기 오류의 대가가 크다). 시안의 "역할 선택" 목록은 원래 [시스템관리 > 역할관리]가 등록한 | |
| 66 | + * 역할을 가져와야 하는데 그 화면도 API도 아직 없어, 지금은 이 표가 곧 선택지다. | |
| 67 | + * | |
| 68 | + * 백엔드가 역할 코드 API를 내면 이 표를 지우고 그 응답으로 대체한다. | |
| 69 | + */ | |
| 70 | +const ADMIN_ROLE_LABEL_BY_CODE: Record<string, string> = { | |
| 71 | + ROLE_SYSTEM: '최고관리자', | |
| 72 | + ROLE_ADMIN: '일반관리자', | |
| 73 | +}; | |
| 74 | + | |
| 75 | +export const ADMIN_ROLE_OPTIONS: ReadonlyArray<{ | |
| 76 | + value: AdminRoleCode; | |
| 77 | + label: string; | |
| 78 | +}> = Object.entries(ADMIN_ROLE_LABEL_BY_CODE).map(([value, label]) => ({ | |
| 79 | + value, | |
| 80 | + label, | |
| 81 | +})); | |
| 82 | + | |
| 83 | +/** 시안의 "역할 선택" 기본값 — 잠정 매핑의 첫 항목(최고관리자). */ | |
| 84 | +export const DEFAULT_ADMIN_ROLE_CODE: AdminRoleCode = 'ROLE_SYSTEM'; | |
| 85 | + | |
| 86 | +/** 역할 코드를 화면 표기로 바꾼다. 모르는 코드는 코드 그대로 노출한다(위 주석 참조). */ | |
| 87 | +export function formatAdminRoleLabel(roleCode: AdminRoleCode): string { | |
| 88 | + return ADMIN_ROLE_LABEL_BY_CODE[roleCode] ?? roleCode; | |
| 89 | +} | |
| 90 | + | |
| 91 | +/** | |
| 92 | + * 시안(ADM_ADM_103_p ③)의 "잠김여부" 표기. 항목은 활성/비활성이고, **잠기지 않은 계정이 활성**이다 | |
| 93 | + * — 백엔드 필드가 `acctLockYn`(잠김 여부)이라 의미가 뒤집혀 있어 이 변환을 한 곳에 모아 둔다. | |
| 94 | + */ | |
| 95 | +export const ADMIN_LOCK_STATUS_OPTIONS: ReadonlyArray<{ | |
| 96 | + value: string; | |
| 97 | + label: string; | |
| 98 | +}> = [ | |
| 99 | + { value: 'false', label: '활성' }, | |
| 100 | + { value: 'true', label: '비활성' }, | |
| 101 | +]; | |
| 102 | + | |
| 103 | +export function formatAdminLockStatusLabel(isLocked: boolean | null): string { | |
| 104 | + if (isLocked === null) { | |
| 105 | + return EMPTY_FIELD_PLACEHOLDER; | |
| 106 | + } | |
| 107 | + return isLocked ? '비활성' : '활성'; | |
| 108 | +} | |
| 109 | + | |
| 110 | +/** | |
| 111 | + * 접근 가능 메뉴 목록(시안 ADM_ADM_102_p ③ "메뉴 선택"). | |
| 112 | + * | |
| 113 | + * **백엔드에 관리자 메뉴 API가 없어 mock 카탈로그다.** 값은 시안 사이드바의 대메뉴 구성을 그대로 | |
| 114 | + * 옮겼다 — 실제 메뉴 API가 생기면 이 상수를 지우고 그 응답으로 대체한다. | |
| 115 | + */ | |
| 116 | +export const ADMIN_MENU_OPTIONS: ReadonlyArray<{ | |
| 117 | + value: string; | |
| 118 | + label: string; | |
| 119 | +}> = [ | |
| 120 | + { value: 'MEMBER', label: '회원정보관리' }, | |
| 121 | + { value: 'ADMIN', label: '관리자정보관리' }, | |
| 122 | + { value: 'DECO_ITEM', label: '꾸미기아이템관리' }, | |
| 123 | + { value: 'BOARD', label: '게시판관리(고객센터)' }, | |
| 124 | + { value: 'POINT', label: '포인트관리' }, | |
| 125 | + { value: 'CONTENTS', label: '콘텐츠관리' }, | |
| 126 | + { value: 'SYSTEM', label: '시스템관리' }, | |
| 127 | +]; | |
| 128 | + | |
| 129 | +/** 선택된 메뉴 코드를 화면 표기로 바꾼다. 하나도 없으면 `-`. */ | |
| 130 | +export function formatAdminMenuLabels(menuCodes: string[]): string { | |
| 131 | + if (menuCodes.length === 0) { | |
| 132 | + return EMPTY_FIELD_PLACEHOLDER; | |
| 133 | + } | |
| 134 | + | |
| 135 | + return menuCodes | |
| 136 | + .map( | |
| 137 | + (code) => | |
| 138 | + ADMIN_MENU_OPTIONS.find((option) => option.value === code)?.label ?? code | |
| 139 | + ) | |
| 140 | + .join(', '); | |
| 141 | +} |
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?