feat: 관리자 회원 관리를 최신 백엔드에 맞춰 완성 + 등록·수정 팝업 시안 구현
## 백엔드 최신화 반영 (edupay-backend develop 4d98756)
- **수정이 동작하지 않던 것을 고쳤다.** `PUT /mngr/admin`에 form으로 보내고 있었으나 백엔드는
`PUT /mngr/admin/{admUserId}` + `@RequestBody`(JSON)다. 등록만 `@ParameterObject`(form)라
같은 도메인인데 본문 형식이 갈린다 — 두 함수 주석에 그 이유를 적어 두었다.
- 삭제를 mock 오버레이에서 실 API(`DELETE /{admUserId}`, soft delete)로 옮기고
`lib/data/mock/admin-member-store.ts`를 지웠다.
- 엑셀 다운로드 API가 생겨 `/admins/excel` 라우트 핸들러로 중계하고 버튼을 열었다.
- 목록 응답에 `admTelNo`·`admEmlAddr`·`frstRegDtStr`이 추가돼 생성일 매핑을 넣었다 —
휴대전화번호·이메일·생성일 세 열의 `-`가 실값으로 바뀐다.
- 검색 분기가 `USER_TELNO`→`ADM_TEL_NO`로 고쳐져 휴대전화번호 검색을 되살렸다(시안 ①의 셋).
- 수정 시 비밀번호 필수 제약을 없앴다 — UPDATE의 `LOGIN_PW`가 빈 값이면 SET 절에서 빠지도록
바뀌어 "바꾸지 않음"을 표현할 수 있게 됐다.
- `totalCount`는 여전히 `list.size()`라 전체를 받아 서버에서 세는 방식은 유지한다.
## 등록·수정 팝업 (시안 5227:2035 / 5227:3351)
@fox로 다시 그렸다 — modal·input·btn·form-label·form-phonenumber·select·chip-area·toggle이
Figma 인스턴스 이름과 그대로 대응한다. 확인한 값: 모달 400, 입력 40, 칩 28, 푸터 버튼 80×40,
필드 간격 32.
저장 버튼은 모달 actions 슬롯이라 폼 밖에 그려진다. FoxButton에 네이티브 `form` 속성이 없어
폼을 ref로 잡아 `requestSubmit()`을 부른다. @fox 제어 위젯(전화번호·역할·메뉴)은 값이
FormData에 실리지 않아 hidden input으로 함께 낸다.
이메일은 두 시안이 다르다 — 등록은 `[아이디] @ [도메인]`, 수정은 단일 입력이라 그대로 따랐다.
등록 쪽은 `FoxEmail`을 쓰지 않는다: 잠긴 '직접입력' 상자를 하나 더 그리고 칸마다 240px 최소폭이
박혀 320px 안에서 세 줄로 쌓인다(사용자 확정).
메뉴 선택은 시안대로 그리되 **필수 검증을 걸지 않는다**(사용자 확정) — 백엔드에 관리자별 메뉴
권한이 테이블·VO·SQL 어디에도 없어 고른 값이 저장되지 않는다.
삭제 확인 얼럿의 버튼도 무스타일 `components/ui/button`에서 FoxButton으로 옮겼다.
Co-Authored-By: Claude Opus 5
@7e217e95b8a5c55627bf015f986593de2689a734
--- app/(protected)/(basic)/admins/_actions.ts
+++ app/(protected)/(basic)/admins/_actions.ts
... | ... | @@ -11,7 +11,7 @@ |
| 11 | 11 |
} from '@/lib/data/repositories/admin-member-repository'; |
| 12 | 12 |
import { ADMIN_MENU_OPTIONS } from '@/lib/domain/admin-member';
|
| 13 | 13 |
import {
|
| 14 |
- joinPhoneNumber, |
|
| 14 |
+ formatPhoneNumber, |
|
| 15 | 15 |
validateAdminLoginId, |
| 16 | 16 |
validateAdminMemberCreate, |
| 17 | 17 |
validateAdminMemberUpdate, |
... | ... | @@ -58,15 +58,11 @@ |
| 58 | 58 |
} |
| 59 | 59 |
|
| 60 | 60 |
/** |
| 61 |
- * 등록·수정이 공유하는 입력 항목을 읽는다. 휴대전화번호는 시안대로 3칸으로 나뉘어 오므로 여기서 |
|
| 62 |
- * 하나의 값으로 합친다 — 형식이 어긋나면 빈 문자열이 되어 검증에서 걸린다. |
|
| 61 |
+ * 등록·수정이 공유하는 입력 항목을 읽는다. 휴대전화번호는 숫자만 담겨 오므로 여기서 저장 형식으로 |
|
| 62 |
+ * 바꾼다 — 자릿수가 어긋나면 빈 문자열이 되어 검증에서 걸린다. |
|
| 63 | 63 |
*/ |
| 64 | 64 |
function readEditableValues(formData: FormData): AdminMemberEditableValues {
|
| 65 |
- const phoneNumber = joinPhoneNumber([ |
|
| 66 |
- readString(formData, 'phoneNumber1'), |
|
| 67 |
- readString(formData, 'phoneNumber2'), |
|
| 68 |
- readString(formData, 'phoneNumber3'), |
|
| 69 |
- ]); |
|
| 65 |
+ const phoneNumber = formatPhoneNumber(readString(formData, 'phoneNumber')); |
|
| 70 | 66 |
|
| 71 | 67 |
return {
|
| 72 | 68 |
password: readString(formData, 'password'), |
--- app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx
... | ... | @@ -1,11 +1,21 @@ |
| 1 | 1 |
'use client'; |
| 2 | 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';
|
|
| 3 |
+import {
|
|
| 4 |
+ useActionState, |
|
| 5 |
+ useEffect, |
|
| 6 |
+ useRef, |
|
| 7 |
+ useState, |
|
| 8 |
+ useTransition, |
|
| 9 |
+} from 'react'; |
|
| 10 |
+import { FoxButton } from '@fox/core/components/fox-button';
|
|
| 11 |
+import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
|
|
| 12 |
+import { FoxFormLabel } from '@fox/core/components/fox-form-label';
|
|
| 13 |
+import { FoxInput } from '@fox/core/components/fox-input';
|
|
| 14 |
+import { FoxSelect } from '@fox/core/components/fox-select';
|
|
| 15 |
+import { FoxModal } from '@fox/core/components/fox-modal';
|
|
| 16 |
+import { FoxChatCenteredDotsIcon } from '@fox/core/icons';
|
|
| 8 | 17 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 18 |
+import { DEFAULT_ADMIN_ROLE_CODE } from '@/lib/domain/admin-member';
|
|
| 9 | 19 |
import {
|
| 10 | 20 |
ADMIN_LOGIN_ID_HELP_TEXT, |
| 11 | 21 |
ADMIN_PASSWORD_HELP_TEXT, |
... | ... | @@ -16,19 +26,27 @@ |
| 16 | 26 |
createAdminMemberAction, |
| 17 | 27 |
type LoginIdCheckResult, |
| 18 | 28 |
} from '../_actions'; |
| 19 |
-import {
|
|
| 20 |
- AdminMemberFormFields, |
|
| 21 |
- FieldError, |
|
| 22 |
-} from './admin-member-form-fields'; |
|
| 29 |
+import { AdminMemberFormFields } from './admin-member-form-fields';
|
|
| 30 |
+import styles from './admin-member-modal.module.scss'; |
|
| 23 | 31 |
|
| 24 | 32 |
interface AdminMemberCreateModalProps {
|
| 25 | 33 |
onClose: () => void; |
| 26 | 34 |
} |
| 27 | 35 |
|
| 28 |
-const FORM_ID = 'admin-member-create-form'; |
|
| 36 |
+/** 목록에 없는 주소를 위해 도메인 칸을 입력으로 바꾸는 값. 실제 도메인과 겹치지 않게 잡았다. */ |
|
| 37 |
+const CUSTOM_EMAIL_DOMAIN = '__custom__'; |
|
| 38 |
+ |
|
| 39 |
+/** 시안의 도메인 목록. 맨 아래 "직접입력"은 같은 칸을 입력으로 바꾼다(상자를 늘리지 않는다). */ |
|
| 40 |
+const EMAIL_DOMAIN_OPTIONS = [ |
|
| 41 |
+ 'naver.com', |
|
| 42 |
+ 'gmail.com', |
|
| 43 |
+ 'daum.net', |
|
| 44 |
+ 'hanmail.net', |
|
| 45 |
+ 'nate.com', |
|
| 46 |
+]; |
|
| 29 | 47 |
|
| 30 | 48 |
/** |
| 31 |
- * 관리자 등록 팝업(시안 ADM_ADM_102_p). |
|
| 49 |
+ * 관리자 등록 팝업 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) ADM_ADM_102_p (5227:2035) |
|
| 32 | 50 |
* |
| 33 | 51 |
* **ID는 중복 확인을 통과해야 저장할 수 있다**(시안 ①). 확인은 등록 폼과 별개의 서버 호출인데, |
| 34 | 52 |
* 폼 안에 폼을 중첩할 수 없어 `useActionState` 대신 값을 직접 넘기는 Server Action |
... | ... | @@ -36,12 +54,14 @@ |
| 36 | 54 |
* "확인에 성공한 ID"와 "지금 입력창의 값"이 같을 때만 통과로 본다 — 확인만 받아 두고 다른 ID로 |
| 37 | 55 |
* 바꿔 저장하는 경로를 막기 위해서다. 그래도 최종 방어선은 Server Action의 재확인이다. |
| 38 | 56 |
* |
| 39 |
- * 저장 버튼은 footer 슬롯에서 `form={FORM_ID}` 속성으로 폼과 연결한다 — 네이티브 HTML의 `form`
|
|
| 40 |
- * 속성은 버튼이 실제 DOM상 form의 자손이 아니어도 같은 문서 안에서 id만 일치하면 그 form을 |
|
| 41 |
- * 제출한다. |
|
| 57 |
+ * 저장 버튼은 `FoxModal`의 actions 슬롯에 있어 폼 **바깥**에 그려진다. `FoxButton`에는 네이티브 |
|
| 58 |
+ * `form` 속성이 없으므로 폼을 ref로 잡아 `requestSubmit()`을 부른다 — `useActionState`의 폼 |
|
| 59 |
+ * 액션이 그대로 타는 정식 제출이라 검증·상태 흐름이 동일하다. |
|
| 42 | 60 |
* |
| 43 |
- * 비밀번호는 형식만 검증하고 저장되지 않는다 — 백엔드에 등록 API가 없어 지금은 mock 저장소가 |
|
| 44 |
- * 받는데, 평문 비밀번호를 메모리에 들고 있지 않기로 했기 때문이다(Repository·mock 주석 참조). |
|
| 61 |
+ * @fox의 제어 위젯(전화번호·이메일·역할·메뉴)은 값이 FormData에 실리지 않아 이 컴포넌트가 값을 |
|
| 62 |
+ * 들고 hidden input으로 제출한다(`AdminMemberFormFields` 주석 참조). |
|
| 63 |
+ * |
|
| 64 |
+ * 비밀번호는 형식만 검증하고 백엔드가 SHA-256으로 암호화해 저장한다(`MngrAdminServiceImpl`). |
|
| 45 | 65 |
*/ |
| 46 | 66 |
export function AdminMemberCreateModal({
|
| 47 | 67 |
onClose, |
... | ... | @@ -51,12 +71,20 @@ |
| 51 | 71 |
createAdminMemberAction, |
| 52 | 72 |
INITIAL_ADMIN_MEMBER_FORM_STATE |
| 53 | 73 |
); |
| 74 |
+ const formRef = useRef<HTMLFormElement>(null); |
|
| 54 | 75 |
|
| 55 | 76 |
const [loginId, setLoginId] = useState('');
|
| 56 | 77 |
const [checkResult, setCheckResult] = useState<LoginIdCheckResult>({
|
| 57 | 78 |
status: 'idle', |
| 58 | 79 |
}); |
| 59 | 80 |
const [isChecking, startChecking] = useTransition(); |
| 81 |
+ |
|
| 82 |
+ const [phoneNumber, setPhoneNumber] = useState('');
|
|
| 83 |
+ const [emailLocal, setEmailLocal] = useState('');
|
|
| 84 |
+ const [emailDomain, setEmailDomain] = useState(EMAIL_DOMAIN_OPTIONS[0]); |
|
| 85 |
+ const [isCustomDomain, setIsCustomDomain] = useState(false); |
|
| 86 |
+ const [roleCode, setRoleCode] = useState<string>(DEFAULT_ADMIN_ROLE_CODE); |
|
| 87 |
+ const [menuCodes, setMenuCodes] = useState<string[]>([]); |
|
| 60 | 88 |
|
| 61 | 89 |
useEffect(() => {
|
| 62 | 90 |
if (state.status === 'success') {
|
... | ... | @@ -66,99 +94,166 @@ |
| 66 | 94 |
}, [state, showToast, onClose]); |
| 67 | 95 |
|
| 68 | 96 |
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 |
- } |
|
| 97 |
+ checkResult.status === 'available' && checkResult.loginId === loginId.trim(); |
|
| 77 | 98 |
|
| 78 | 99 |
const errors = state.status === 'error' ? (state.errors ?? {}) : {};
|
| 79 | 100 |
|
| 101 |
+ const email = emailLocal || emailDomain ? `${emailLocal}@${emailDomain}` : '';
|
|
| 102 |
+ |
|
| 103 |
+ // 중복 확인 결과는 서버 검증 오류보다 먼저 보여준다 — 방금 누른 버튼의 답이기 때문이다. |
|
| 104 |
+ const loginIdMessage = |
|
| 105 |
+ checkResult.status === 'unavailable' |
|
| 106 |
+ ? checkResult.message |
|
| 107 |
+ : isLoginIdVerified |
|
| 108 |
+ ? '사용할 수 있는 ID입니다.' |
|
| 109 |
+ : (errors.loginId ?? ADMIN_LOGIN_ID_HELP_TEXT); |
|
| 110 |
+ |
|
| 80 | 111 |
return ( |
| 81 |
- <Modal |
|
| 82 |
- title="관리자 등록" |
|
| 112 |
+ <FoxModal |
|
| 113 |
+ open |
|
| 83 | 114 |
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 |
- </> |
|
| 115 |
+ size="sm" |
|
| 116 |
+ title="관리자 등록" |
|
| 117 |
+ actions={
|
|
| 118 |
+ <FoxButtonGroup size="md"> |
|
| 119 |
+ <FoxButton type="default" size="md" label="취소" onAction={onClose} />
|
|
| 120 |
+ <FoxButton |
|
| 121 |
+ type="primary" |
|
| 122 |
+ size="md" |
|
| 123 |
+ label="저장" |
|
| 124 |
+ loading={isPending}
|
|
| 125 |
+ disabled={!isLoginIdVerified}
|
|
| 126 |
+ onAction={() => formRef.current?.requestSubmit()}
|
|
| 127 |
+ /> |
|
| 128 |
+ </FoxButtonGroup> |
|
| 99 | 129 |
} |
| 100 | 130 |
> |
| 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> |
|
| 131 |
+ <form ref={formRef} action={formAction} className={styles.formList}>
|
|
| 132 |
+ <FoxInput |
|
| 133 |
+ size="md" |
|
| 134 |
+ name="name" |
|
| 135 |
+ label="이름" |
|
| 136 |
+ requirement="required" |
|
| 137 |
+ placeholder="이름을 입력하세요." |
|
| 138 |
+ message={errors.name}
|
|
| 139 |
+ invalid={Boolean(errors.name)}
|
|
| 140 |
+ /> |
|
| 105 | 141 |
|
| 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" |
|
| 142 |
+ <div className={styles.fieldWithAction}>
|
|
| 143 |
+ <FoxInput |
|
| 144 |
+ size="md" |
|
| 145 |
+ name="loginId" |
|
| 146 |
+ label="ID" |
|
| 147 |
+ requirement="required" |
|
| 148 |
+ placeholder="관리자 ID를 입력하세요." |
|
| 149 |
+ autoComplete="off" |
|
| 150 |
+ value={loginId}
|
|
| 151 |
+ onChange={setLoginId}
|
|
| 152 |
+ message={loginIdMessage}
|
|
| 153 |
+ messageIcon={<FoxChatCenteredDotsIcon />}
|
|
| 154 |
+ invalid={
|
|
| 155 |
+ checkResult.status === 'unavailable' || Boolean(errors.loginId) |
|
| 156 |
+ } |
|
| 149 | 157 |
/> |
| 150 |
- </Field> |
|
| 151 |
- <p className="text-body-sm text-foreground-muted"> |
|
| 152 |
- {ADMIN_PASSWORD_HELP_TEXT}
|
|
| 153 |
- </p> |
|
| 154 |
- <FieldError message={errors.password} />
|
|
| 158 |
+ <FoxButton |
|
| 159 |
+ type="secondary" |
|
| 160 |
+ size="md" |
|
| 161 |
+ fullWidth |
|
| 162 |
+ label="중복 확인" |
|
| 163 |
+ loading={isChecking}
|
|
| 164 |
+ disabled={loginId.trim().length === 0}
|
|
| 165 |
+ onAction={() =>
|
|
| 166 |
+ startChecking(async () => {
|
|
| 167 |
+ setCheckResult(await checkAdminLoginId(loginId)); |
|
| 168 |
+ }) |
|
| 169 |
+ } |
|
| 170 |
+ /> |
|
| 171 |
+ </div> |
|
| 155 | 172 |
|
| 156 |
- <AdminMemberFormFields errors={errors} />
|
|
| 173 |
+ <FoxInput |
|
| 174 |
+ size="md" |
|
| 175 |
+ type="password" |
|
| 176 |
+ name="password" |
|
| 177 |
+ label="비밀번호" |
|
| 178 |
+ requirement="required" |
|
| 179 |
+ placeholder="비밀번호를 입력하세요." |
|
| 180 |
+ autoComplete="new-password" |
|
| 181 |
+ message={errors.password ?? ADMIN_PASSWORD_HELP_TEXT}
|
|
| 182 |
+ messageIcon={<FoxChatCenteredDotsIcon />}
|
|
| 183 |
+ invalid={Boolean(errors.password)}
|
|
| 184 |
+ /> |
|
| 185 |
+ |
|
| 186 |
+ <AdminMemberFormFields |
|
| 187 |
+ phoneNumber={phoneNumber}
|
|
| 188 |
+ onPhoneNumberChange={setPhoneNumber}
|
|
| 189 |
+ roleCode={roleCode}
|
|
| 190 |
+ onRoleCodeChange={setRoleCode}
|
|
| 191 |
+ menuCodes={menuCodes}
|
|
| 192 |
+ onMenuCodesChange={setMenuCodes}
|
|
| 193 |
+ errors={errors}
|
|
| 194 |
+ emailField={
|
|
| 195 |
+ <> |
|
| 196 |
+ <div className={styles.field}>
|
|
| 197 |
+ <FoxFormLabel as="span" requirement="required"> |
|
| 198 |
+ 이메일 |
|
| 199 |
+ </FoxFormLabel> |
|
| 200 |
+ <div className={styles.emailRow}>
|
|
| 201 |
+ <span className={styles.emailPart}>
|
|
| 202 |
+ <FoxInput |
|
| 203 |
+ size="md" |
|
| 204 |
+ aria-label="이메일 아이디" |
|
| 205 |
+ placeholder="1234-5678" |
|
| 206 |
+ value={emailLocal}
|
|
| 207 |
+ onChange={setEmailLocal}
|
|
| 208 |
+ invalid={Boolean(errors.email)}
|
|
| 209 |
+ /> |
|
| 210 |
+ </span> |
|
| 211 |
+ <span aria-hidden="true">@</span> |
|
| 212 |
+ <span className={styles.emailPart}>
|
|
| 213 |
+ {isCustomDomain ? (
|
|
| 214 |
+ <FoxInput |
|
| 215 |
+ size="md" |
|
| 216 |
+ aria-label="이메일 도메인" |
|
| 217 |
+ placeholder="직접입력" |
|
| 218 |
+ value={emailDomain}
|
|
| 219 |
+ onChange={setEmailDomain}
|
|
| 220 |
+ invalid={Boolean(errors.email)}
|
|
| 221 |
+ /> |
|
| 222 |
+ ) : ( |
|
| 223 |
+ <FoxSelect |
|
| 224 |
+ size="md" |
|
| 225 |
+ options={[
|
|
| 226 |
+ ...EMAIL_DOMAIN_OPTIONS.map((domain) => ({
|
|
| 227 |
+ value: domain, |
|
| 228 |
+ label: domain, |
|
| 229 |
+ })), |
|
| 230 |
+ { value: CUSTOM_EMAIL_DOMAIN, label: '직접입력' },
|
|
| 231 |
+ ]} |
|
| 232 |
+ value={emailDomain}
|
|
| 233 |
+ onValueChange={(next) => {
|
|
| 234 |
+ if (next === CUSTOM_EMAIL_DOMAIN) {
|
|
| 235 |
+ setIsCustomDomain(true); |
|
| 236 |
+ setEmailDomain('');
|
|
| 237 |
+ return; |
|
| 238 |
+ } |
|
| 239 |
+ setEmailDomain(next); |
|
| 240 |
+ }} |
|
| 241 |
+ error={Boolean(errors.email)}
|
|
| 242 |
+ /> |
|
| 243 |
+ )} |
|
| 244 |
+ </span> |
|
| 245 |
+ </div> |
|
| 246 |
+ {errors.email && <p role="alert">{errors.email}</p>}
|
|
| 247 |
+ </div> |
|
| 248 |
+ <input type="hidden" name="email" value={email} />
|
|
| 249 |
+ </> |
|
| 250 |
+ } |
|
| 251 |
+ /> |
|
| 157 | 252 |
|
| 158 | 253 |
{state.status === 'error' && state.message && (
|
| 159 |
- <p className="text-body-sm text-danger">{state.message}</p>
|
|
| 254 |
+ <p role="alert">{state.message}</p>
|
|
| 160 | 255 |
)} |
| 161 | 256 |
</form> |
| 162 |
- </Modal> |
|
| 257 |
+ </FoxModal> |
|
| 163 | 258 |
); |
| 164 | 259 |
} |
--- app/(protected)/(basic)/admins/_components/admin-member-edit-modal.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-edit-modal.tsx
... | ... | @@ -1,49 +1,49 @@ |
| 1 | 1 |
'use client'; |
| 2 | 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';
|
|
| 3 |
+import { useActionState, useEffect, useRef, useState } from 'react';
|
|
| 4 |
+import { FoxButton } from '@fox/core/components/fox-button';
|
|
| 5 |
+import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
|
|
| 6 |
+import { FoxFormLabel } from '@fox/core/components/fox-form-label';
|
|
| 7 |
+import { FoxInput } from '@fox/core/components/fox-input';
|
|
| 8 |
+import { FoxModal } from '@fox/core/components/fox-modal';
|
|
| 9 |
+import { FoxToggleSwitch } from '@fox/core/components/fox-toggle-switch';
|
|
| 10 |
+import { FoxChatCenteredDotsIcon } from '@fox/core/icons';
|
|
| 9 | 11 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 10 |
-import {
|
|
| 11 |
- ADMIN_LOCK_STATUS_OPTIONS, |
|
| 12 |
- type AdminMember, |
|
| 13 |
-} from '@/lib/domain/admin-member'; |
|
| 12 |
+import { DEFAULT_ADMIN_ROLE_CODE, type AdminMember } from '@/lib/domain/admin-member';
|
|
| 14 | 13 |
import {
|
| 15 | 14 |
ADMIN_PASSWORD_HELP_TEXT, |
| 16 | 15 |
INITIAL_ADMIN_MEMBER_FORM_STATE, |
| 16 |
+ toPhoneDigits, |
|
| 17 | 17 |
} from '@/lib/domain/admin-member-form'; |
| 18 | 18 |
import { updateAdminMemberAction } from '../_actions';
|
| 19 |
-import {
|
|
| 20 |
- AdminMemberFormFields, |
|
| 21 |
- FieldError, |
|
| 22 |
-} from './admin-member-form-fields'; |
|
| 19 |
+import { AdminMemberFormFields } from './admin-member-form-fields';
|
|
| 20 |
+import styles from './admin-member-modal.module.scss'; |
|
| 23 | 21 |
|
| 24 | 22 |
interface AdminMemberEditModalProps {
|
| 25 | 23 |
member: AdminMember; |
| 26 | 24 |
onClose: () => void; |
| 27 | 25 |
} |
| 28 | 26 |
|
| 29 |
-const FORM_ID = 'admin-member-edit-form'; |
|
| 30 |
- |
|
| 31 | 27 |
/** |
| 32 |
- * 관리자 수정 팝업(시안 ADM_ADM_103_p). |
|
| 28 |
+ * 관리자 수정 팝업 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) ADM_ADM_103_p (5227:3351) |
|
| 33 | 29 |
* |
| 34 |
- * 이름·ID는 읽기 전용이다(시안 ①). 다만 화면의 readOnly는 표시일 뿐이라 Server Action은 두 값을 |
|
| 35 |
- * 아예 반영하지 않는다 — 직접 POST로 다른 값을 보내도 바뀌지 않는다. |
|
| 30 |
+ * 이름·ID는 읽기 전용이다(시안 ①). 화면의 readOnly는 표시일 뿐이지만 Server Action도 두 값을 아예 |
|
| 31 |
+ * 읽지 않고, 백엔드 수정 API 역시 받지 않는다(`MngrAdminUpdateRequestVo`에 필드가 없다) — 세 겹이 |
|
| 32 |
+ * 같은 말을 한다. |
|
| 36 | 33 |
* |
| 37 |
- * **비밀번호는 [비밀번호 변경]을 눌러야 입력할 수 있다**(시안 ②). 다만 비워 둔 채로는 수정이 |
|
| 38 |
- * 저장되지 않는다 — 백엔드 UPDATE가 비밀번호를 조건 없이 덮어써서 빈 값을 보내면 그 계정이 로그인 |
|
| 39 |
- * 불가가 되기 때문이다(`ADMIN_PASSWORD_REQUIRED_ON_UPDATE_TEXT` 주석). 비활성 input은 FormData에 |
|
| 40 |
- * 실리지 않으므로 버튼을 누르지 않고 제출하면 그 안내가 그대로 뜬다. |
|
| 34 |
+ * **비밀번호는 [비밀번호 변경]을 눌러야 입력할 수 있고, 비워 두면 바꾸지 않는다**(시안 ②). |
|
| 35 |
+ * 백엔드 UPDATE의 `LOGIN_PW`가 `<if test='loginPw != null and loginPw != ""'>`로 감싸여 있어 |
|
| 36 |
+ * 빈 값은 SET 절에서 빠진다. (예전에는 조건 없이 덮어써서 빈 값을 보내면 그 계정이 로그인 불가가 |
|
| 37 |
+ * 됐고, 그래서 수정 시 비밀번호를 필수로 막아 두었다 — 그 제약은 이제 없다.) |
|
| 41 | 38 |
* |
| 42 |
- * 휴대전화번호·이메일은 항상 빈 칸으로 시작한다 — 백엔드가 두 값을 되돌려주지 않아 기존 값을 |
|
| 43 |
- * 채울 수단이 없다(Repository 주석). 입력한 값으로 덮어쓰는 동작이다. |
|
| 39 |
+ * 이메일은 시안이 **단일 입력**이다(등록 팝업의 도메인 셀렉트가 없다). 두 시안의 차이를 그대로 |
|
| 40 |
+ * 따른다 — 수정은 이미 있는 주소를 고치는 자리라 도메인만 고르는 형태가 맞지 않는다. |
|
| 44 | 41 |
* |
| 45 |
- * "잠김여부"는 활성/비활성으로 표기되지만 백엔드 필드는 `acctLockYn`(잠김 여부)이라 의미가 |
|
| 46 |
- * 뒤집혀 있다. 그 변환은 도메인(`ADMIN_LOCK_STATUS_OPTIONS`)이 한 곳에서 책임진다. |
|
| 42 |
+ * 휴대전화번호·이메일·역할은 이제 목록 응답에 담겨 오므로 **기존 값으로 채운다**(백엔드가 |
|
| 43 |
+ * `ADM_TEL_NO`·`ADM_EML_ADDR`을 select 목록에 넣었다). |
|
| 44 |
+ * |
|
| 45 |
+ * "잠김여부"는 시안이 활성/비활성 토글이고 백엔드 필드는 `acctLockYn`(잠김 여부)이라 의미가 |
|
| 46 |
+ * 뒤집혀 있다 — 켜짐이 곧 "잠기지 않음"이다. |
|
| 47 | 47 |
*/ |
| 48 | 48 |
export function AdminMemberEditModal({
|
| 49 | 49 |
member, |
... | ... | @@ -54,8 +54,18 @@ |
| 54 | 54 |
updateAdminMemberAction, |
| 55 | 55 |
INITIAL_ADMIN_MEMBER_FORM_STATE |
| 56 | 56 |
); |
| 57 |
+ const formRef = useRef<HTMLFormElement>(null); |
|
| 57 | 58 |
|
| 58 | 59 |
const [isPasswordEditable, setIsPasswordEditable] = useState(false); |
| 60 |
+ const [phoneNumber, setPhoneNumber] = useState( |
|
| 61 |
+ toPhoneDigits(member.phoneNumber) |
|
| 62 |
+ ); |
|
| 63 |
+ const [email, setEmail] = useState(member.email ?? ''); |
|
| 64 |
+ const [roleCode, setRoleCode] = useState( |
|
| 65 |
+ member.roleCode || DEFAULT_ADMIN_ROLE_CODE |
|
| 66 |
+ ); |
|
| 67 |
+ const [menuCodes, setMenuCodes] = useState<string[]>(member.menuCodes); |
|
| 68 |
+ const [isActive, setIsActive] = useState(!(member.isLocked ?? false)); |
|
| 59 | 69 |
|
| 60 | 70 |
useEffect(() => {
|
| 61 | 71 |
if (state.status === 'success') {
|
... | ... | @@ -67,88 +77,112 @@ |
| 67 | 77 |
const errors = state.status === 'error' ? (state.errors ?? {}) : {};
|
| 68 | 78 |
|
| 69 | 79 |
return ( |
| 70 |
- <Modal |
|
| 71 |
- title="관리자 수정" |
|
| 80 |
+ <FoxModal |
|
| 81 |
+ open |
|
| 72 | 82 |
onClose={onClose}
|
| 73 |
- footer={
|
|
| 74 |
- <> |
|
| 75 |
- <Button type="button" variant="ghost" onClick={onClose}>
|
|
| 76 |
- 취소 |
|
| 77 |
- </Button> |
|
| 78 |
- <Button |
|
| 79 |
- type="submit" |
|
| 80 |
- form={FORM_ID}
|
|
| 81 |
- variant="primary" |
|
| 82 |
- disabled={isPending}
|
|
| 83 |
- > |
|
| 84 |
- {isPending ? '수정 중...' : '수정'}
|
|
| 85 |
- </Button> |
|
| 86 |
- </> |
|
| 83 |
+ size="sm" |
|
| 84 |
+ title="관리자 수정" |
|
| 85 |
+ actions={
|
|
| 86 |
+ <FoxButtonGroup size="md"> |
|
| 87 |
+ <FoxButton type="default" size="md" label="취소" onAction={onClose} />
|
|
| 88 |
+ <FoxButton |
|
| 89 |
+ type="primary" |
|
| 90 |
+ size="md" |
|
| 91 |
+ label="저장" |
|
| 92 |
+ loading={isPending}
|
|
| 93 |
+ onAction={() => formRef.current?.requestSubmit()}
|
|
| 94 |
+ /> |
|
| 95 |
+ </FoxButtonGroup> |
|
| 87 | 96 |
} |
| 88 | 97 |
> |
| 89 |
- <form id={FORM_ID} action={formAction} className="flex flex-col gap-4">
|
|
| 90 |
- {/* 수정 대상을 가리키는 유일한 입력. 이름·ID는 읽기 전용이라 아예 제출하지 않는다 —
|
|
| 91 |
- Server Action도 그 둘을 읽지 않으므로 위조해 보내도 반영되지 않는다. */} |
|
| 98 |
+ <form ref={formRef} action={formAction} className={styles.formList}>
|
|
| 99 |
+ {/* 수정 대상을 가리키는 유일한 입력. 이름·ID는 읽기 전용이라 아예 제출하지 않는다. */}
|
|
| 92 | 100 |
<input type="hidden" name="id" value={member.id} />
|
| 93 | 101 |
|
| 94 |
- <p className="text-right text-body-sm text-danger"> |
|
| 95 |
- * 는 필수 항목입니다. |
|
| 96 |
- </p> |
|
| 102 |
+ <FoxInput |
|
| 103 |
+ size="md" |
|
| 104 |
+ label="이름" |
|
| 105 |
+ requirement="required" |
|
| 106 |
+ value={member.name}
|
|
| 107 |
+ readOnly |
|
| 108 |
+ /> |
|
| 97 | 109 |
|
| 98 |
- <Field label="이름"> |
|
| 99 |
- <Input type="text" value={member.name} readOnly />
|
|
| 100 |
- </Field> |
|
| 110 |
+ <FoxInput |
|
| 111 |
+ size="md" |
|
| 112 |
+ label="ID" |
|
| 113 |
+ requirement="required" |
|
| 114 |
+ value={member.loginId}
|
|
| 115 |
+ readOnly |
|
| 116 |
+ /> |
|
| 101 | 117 |
|
| 102 |
- <Field label="ID"> |
|
| 103 |
- <Input type="text" value={member.loginId} readOnly />
|
|
| 104 |
- </Field> |
|
| 118 |
+ <div className={styles.fieldWithAction}>
|
|
| 119 |
+ <FoxInput |
|
| 120 |
+ size="md" |
|
| 121 |
+ type="password" |
|
| 122 |
+ name="password" |
|
| 123 |
+ label="비밀번호" |
|
| 124 |
+ requirement="required" |
|
| 125 |
+ placeholder="비밀번호를 입력하세요." |
|
| 126 |
+ autoComplete="new-password" |
|
| 127 |
+ disabled={!isPasswordEditable}
|
|
| 128 |
+ message={
|
|
| 129 |
+ errors.password ?? |
|
| 130 |
+ (isPasswordEditable ? ADMIN_PASSWORD_HELP_TEXT : undefined) |
|
| 131 |
+ } |
|
| 132 |
+ messageIcon={
|
|
| 133 |
+ isPasswordEditable ? <FoxChatCenteredDotsIcon /> : undefined |
|
| 134 |
+ } |
|
| 135 |
+ invalid={Boolean(errors.password)}
|
|
| 136 |
+ /> |
|
| 137 |
+ <FoxButton |
|
| 138 |
+ type="secondary" |
|
| 139 |
+ size="md" |
|
| 140 |
+ fullWidth |
|
| 141 |
+ label="비밀번호 변경" |
|
| 142 |
+ disabled={isPasswordEditable}
|
|
| 143 |
+ onAction={() => setIsPasswordEditable(true)}
|
|
| 144 |
+ /> |
|
| 145 |
+ </div> |
|
| 105 | 146 |
|
| 106 |
- <Field label="비밀번호"> |
|
| 107 |
- <div className="flex items-center gap-2"> |
|
| 108 |
- <Input |
|
| 109 |
- type="password" |
|
| 110 |
- name="password" |
|
| 111 |
- placeholder="비밀번호를 입력하세요." |
|
| 112 |
- autoComplete="new-password" |
|
| 113 |
- disabled={!isPasswordEditable}
|
|
| 147 |
+ <AdminMemberFormFields |
|
| 148 |
+ phoneNumber={phoneNumber}
|
|
| 149 |
+ onPhoneNumberChange={setPhoneNumber}
|
|
| 150 |
+ roleCode={roleCode}
|
|
| 151 |
+ onRoleCodeChange={setRoleCode}
|
|
| 152 |
+ menuCodes={menuCodes}
|
|
| 153 |
+ onMenuCodesChange={setMenuCodes}
|
|
| 154 |
+ errors={errors}
|
|
| 155 |
+ emailField={
|
|
| 156 |
+ <FoxInput |
|
| 157 |
+ size="md" |
|
| 158 |
+ type="email" |
|
| 159 |
+ name="email" |
|
| 160 |
+ label="이메일" |
|
| 161 |
+ placeholder="이메일을 입력하세요." |
|
| 162 |
+ value={email}
|
|
| 163 |
+ onChange={setEmail}
|
|
| 164 |
+ message={errors.email}
|
|
| 165 |
+ invalid={Boolean(errors.email)}
|
|
| 114 | 166 |
/> |
| 115 |
- <Button |
|
| 116 |
- type="button" |
|
| 117 |
- variant="secondary" |
|
| 118 |
- onClick={() => setIsPasswordEditable(true)}
|
|
| 119 |
- disabled={isPasswordEditable}
|
|
| 120 |
- className="shrink-0" |
|
| 121 |
- > |
|
| 122 |
- 비밀번호 변경 |
|
| 123 |
- </Button> |
|
| 124 |
- </div> |
|
| 125 |
- </Field> |
|
| 126 |
- {isPasswordEditable && (
|
|
| 127 |
- <p className="text-body-sm text-foreground-muted"> |
|
| 128 |
- {ADMIN_PASSWORD_HELP_TEXT}
|
|
| 129 |
- </p> |
|
| 130 |
- )} |
|
| 131 |
- <FieldError message={errors.password} />
|
|
| 167 |
+ } |
|
| 168 |
+ /> |
|
| 132 | 169 |
|
| 133 |
- <AdminMemberFormFields member={member} errors={errors} />
|
|
| 134 |
- |
|
| 135 |
- <Field label="잠김여부"> |
|
| 136 |
- <Select |
|
| 137 |
- name="isLocked" |
|
| 138 |
- defaultValue={String(member.isLocked ?? false)}
|
|
| 139 |
- > |
|
| 140 |
- {ADMIN_LOCK_STATUS_OPTIONS.map((option) => (
|
|
| 141 |
- <option key={option.value} value={option.value}>
|
|
| 142 |
- {option.label}
|
|
| 143 |
- </option> |
|
| 144 |
- ))} |
|
| 145 |
- </Select> |
|
| 146 |
- </Field> |
|
| 170 |
+ <div className={styles.field}>
|
|
| 171 |
+ <FoxFormLabel as="span">잠김여부</FoxFormLabel> |
|
| 172 |
+ <FoxToggleSwitch |
|
| 173 |
+ size="md" |
|
| 174 |
+ label={isActive ? '활성' : '비활성'}
|
|
| 175 |
+ checked={isActive}
|
|
| 176 |
+ onChange={setIsActive}
|
|
| 177 |
+ /> |
|
| 178 |
+ </div> |
|
| 179 |
+ {/* 화면은 활성(잠기지 않음)을 보여주고 서버는 잠김 여부를 받는다 — 여기서 뒤집는다. */}
|
|
| 180 |
+ <input type="hidden" name="isLocked" value={String(!isActive)} />
|
|
| 147 | 181 |
|
| 148 | 182 |
{state.status === 'error' && state.message && (
|
| 149 |
- <p className="text-body-sm text-danger">{state.message}</p>
|
|
| 183 |
+ <p role="alert">{state.message}</p>
|
|
| 150 | 184 |
)} |
| 151 | 185 |
</form> |
| 152 |
- </Modal> |
|
| 186 |
+ </FoxModal> |
|
| 153 | 187 |
); |
| 154 | 188 |
} |
--- app/(protected)/(basic)/admins/_components/admin-member-form-fields.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-form-fields.tsx
... | ... | @@ -1,115 +1,135 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 |
-import { Field } from '@/components/ui/field';
|
|
| 4 |
-import { Input } from '@/components/ui/input';
|
|
| 5 |
-import { Select } from '@/components/ui/select';
|
|
| 3 |
+import { useId, type ReactNode } from 'react';
|
|
| 4 |
+import { FoxChip } from '@fox/core/components/fox-chip';
|
|
| 5 |
+import { FoxChipArea } from '@fox/core/components/fox-chip-area';
|
|
| 6 |
+import { FoxFormLabel } from '@fox/core/components/fox-form-label';
|
|
| 7 |
+import { FoxPhoneNumber } from '@fox/core/components/fox-phone-number';
|
|
| 8 |
+import { FoxSelect } from '@fox/core/components/fox-select';
|
|
| 6 | 9 |
import {
|
| 10 |
+ ADMIN_MENU_OPTIONS, |
|
| 7 | 11 |
ADMIN_ROLE_OPTIONS, |
| 8 |
- DEFAULT_ADMIN_ROLE_CODE, |
|
| 9 |
- type AdminMember, |
|
| 10 | 12 |
} 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';
|
|
| 13 |
+import type { AdminMemberFormErrors } from '@/lib/domain/admin-member-form';
|
|
| 14 |
+import styles from './admin-member-modal.module.scss'; |
|
| 16 | 15 |
|
| 17 | 16 |
interface AdminMemberFormFieldsProps {
|
| 18 |
- /** 수정 팝업의 기존 값. 등록 팝업은 넘기지 않는다(빈 폼). */ |
|
| 19 |
- member?: AdminMember; |
|
| 17 |
+ /** 숫자만 담긴 휴대전화번호. 하이픈은 화면이 그리고 값에는 넣지 않는다. */ |
|
| 18 |
+ phoneNumber: string; |
|
| 19 |
+ onPhoneNumberChange: (phoneNumber: string) => void; |
|
| 20 |
+ roleCode: string; |
|
| 21 |
+ onRoleCodeChange: (roleCode: string) => void; |
|
| 22 |
+ menuCodes: string[]; |
|
| 23 |
+ onMenuCodesChange: (menuCodes: string[]) => void; |
|
| 24 |
+ /** |
|
| 25 |
+ * 휴대전화 번호와 역할 선택 **사이**에 들어가는 이메일 칸. 두 시안이 이 자리에 서로 다른 것을 |
|
| 26 |
+ * 놓아(등록은 도메인 셀렉트가 붙은 form-email, 수정은 단일 input) 슬롯으로 받는다 — 자리 순서는 |
|
| 27 |
+ * 두 시안이 같으므로 여기가 갖고, 무엇을 놓을지는 각 팝업이 정한다. |
|
| 28 |
+ */ |
|
| 29 |
+ emailField: ReactNode; |
|
| 20 | 30 |
errors: AdminMemberFormErrors; |
| 21 | 31 |
} |
| 22 | 32 |
|
| 23 | 33 |
/** |
| 24 |
- * 등록·수정 팝업이 공유하는 입력 항목 — 휴대전화 번호 / 이메일 / 역할 선택 / 메뉴 선택. |
|
| 25 |
- * 두 시안(ADM_ADM_102_p ②③ / 103_p ②)에서 규칙이 같은 부분만 모았다. 서로 다른 부분 |
|
| 26 |
- * (이름·ID의 편집 가능 여부, 비밀번호 입력 방식, 잠김여부)은 각 팝업이 직접 그린다. |
|
| 34 |
+ * 등록·수정 팝업이 공유하는 입력 항목 — 휴대전화 번호 / (이메일 슬롯) / 역할 선택 / 메뉴 선택. |
|
| 35 |
+ * 두 시안(ADM_ADM_102_p 5227:2035 / ADM_ADM_103_p 5227:3351)에서 자리와 순서가 같은 부분이다. |
|
| 36 |
+ * 이름·ID·비밀번호·잠김여부는 두 시안이 서로 달라 각 팝업이 직접 그린다. |
|
| 27 | 37 |
* |
| 28 |
- * 휴대전화 번호는 시안대로 3칸으로 나눠 받고 Server Action이 하나로 합친다 |
|
| 29 |
- * (`joinPhoneNumber`) — 칸을 나누는 것은 화면 표현이고, 저장 형식은 도메인이 정한다. |
|
| 38 |
+ * 세 칸 모두 @fox의 제어 위젯이라 값이 FormData에 실리지 않는다(`FoxSelect`는 네이티브 select가 |
|
| 39 |
+ * 아니라 버튼+리스트박스, `FoxPhoneNumber`·칩도 마찬가지다). 그래서 값은 호출부가 들고, 제출용 |
|
| 40 |
+ * hidden input을 여기서 함께 낸다 — 폼 제출 규약(Server Action + FormData)을 그대로 두기 위해서다. |
|
| 30 | 41 |
* |
| 31 |
- * ⚠️ **메뉴 선택은 아직 저장되지 않는다.** 백엔드에 관리자별 메뉴 권한 개념이 없어 보낼 곳이 |
|
| 32 |
- * 없다(등록·수정 VO에 필드가 없고 `/api/v1/common/menu`는 개인 북마크용이다). 시안대로 자리와 |
|
| 33 |
- * 필수 검증은 유지하되 값은 Repository까지 가지 않는다 — 권한 API가 생기면 여기는 그대로 두고 |
|
| 34 |
- * Server Action이 값을 넘기기만 하면 된다. |
|
| 42 |
+ * ⚠️ **메뉴 선택은 저장되지 않는다.** 백엔드에 관리자별 메뉴 권한이 테이블·VO·SQL 어디에도 없다 |
|
| 43 |
+ * (`/api/v1/common/menu`는 개인 북마크용이다). 시안대로 자리는 유지하되 **필수 검증은 걸지 않는다** |
|
| 44 |
+ * (사용자 확정) — 저장되지도 않는 값 때문에 등록이 막히면 안 되기 때문이다. 권한 API가 생기면 |
|
| 45 |
+ * 여기는 그대로 두고 Server Action이 값을 넘기기만 하면 된다. |
|
| 35 | 46 |
*/ |
| 36 | 47 |
export function AdminMemberFormFields({
|
| 37 |
- member, |
|
| 48 |
+ phoneNumber, |
|
| 49 |
+ onPhoneNumberChange, |
|
| 50 |
+ roleCode, |
|
| 51 |
+ onRoleCodeChange, |
|
| 52 |
+ menuCodes, |
|
| 53 |
+ onMenuCodesChange, |
|
| 54 |
+ emailField, |
|
| 38 | 55 |
errors, |
| 39 | 56 |
}: AdminMemberFormFieldsProps) {
|
| 40 |
- const phoneParts = splitPhoneNumber(member?.phoneNumber ?? null); |
|
| 57 |
+ const phoneLabelId = useId(); |
|
| 58 |
+ const menuLabelId = useId(); |
|
| 59 |
+ |
|
| 60 |
+ const allMenuValues = ADMIN_MENU_OPTIONS.map((option) => option.value); |
|
| 61 |
+ const isAllSelected = menuCodes.length === allMenuValues.length; |
|
| 62 |
+ |
|
| 63 |
+ function toggleMenu(value: string) {
|
|
| 64 |
+ onMenuCodesChange( |
|
| 65 |
+ menuCodes.includes(value) |
|
| 66 |
+ ? menuCodes.filter((code) => code !== value) |
|
| 67 |
+ : [...menuCodes, value] |
|
| 68 |
+ ); |
|
| 69 |
+ } |
|
| 41 | 70 |
|
| 42 | 71 |
return ( |
| 43 | 72 |
<> |
| 44 |
- <Field label="휴대전화 번호 *"> |
|
| 45 |
- <div className="flex items-center gap-2"> |
|
| 46 |
- <Input |
|
| 47 |
- type="tel" |
|
| 48 |
- name="phoneNumber1" |
|
| 49 |
- defaultValue={phoneParts[0]}
|
|
| 50 |
- maxLength={3}
|
|
| 51 |
- inputMode="numeric" |
|
| 52 |
- aria-label="휴대전화 번호 앞자리" |
|
| 53 |
- placeholder="010" |
|
| 54 |
- /> |
|
| 55 |
- <Input |
|
| 56 |
- type="tel" |
|
| 57 |
- name="phoneNumber2" |
|
| 58 |
- defaultValue={phoneParts[1]}
|
|
| 59 |
- maxLength={4}
|
|
| 60 |
- inputMode="numeric" |
|
| 61 |
- aria-label="휴대전화 번호 가운데자리" |
|
| 62 |
- placeholder="0000" |
|
| 63 |
- /> |
|
| 64 |
- <Input |
|
| 65 |
- type="tel" |
|
| 66 |
- name="phoneNumber3" |
|
| 67 |
- defaultValue={phoneParts[2]}
|
|
| 68 |
- maxLength={4}
|
|
| 69 |
- inputMode="numeric" |
|
| 70 |
- aria-label="휴대전화 번호 끝자리" |
|
| 71 |
- placeholder="0000" |
|
| 72 |
- /> |
|
| 73 |
- </div> |
|
| 74 |
- </Field> |
|
| 75 |
- <FieldError message={errors.phoneNumber} />
|
|
| 76 |
- |
|
| 77 |
- <Field label="이메일"> |
|
| 78 |
- <Input |
|
| 79 |
- type="email" |
|
| 80 |
- name="email" |
|
| 81 |
- defaultValue={member?.email ?? ''}
|
|
| 82 |
- placeholder="이메일을 입력하세요." |
|
| 73 |
+ {/* 칸이 셋으로 나뉘어 있어 `<label htmlFor>`가 가리킬 대상이 하나가 아니다 — 이름 연결은
|
|
| 74 |
+ 감싼 group이 aria-labelledby로 한다(FoxFormLabel의 `as="span"`이 그 용도다). */} |
|
| 75 |
+ <div className={styles.field} role="group" aria-labelledby={phoneLabelId}>
|
|
| 76 |
+ <FoxFormLabel as="span" id={phoneLabelId} requirement="required">
|
|
| 77 |
+ 휴대전화 번호 |
|
| 78 |
+ </FoxFormLabel> |
|
| 79 |
+ <FoxPhoneNumber |
|
| 80 |
+ type="unit" |
|
| 81 |
+ value={phoneNumber}
|
|
| 82 |
+ onChange={onPhoneNumberChange}
|
|
| 83 |
+ message={errors.phoneNumber}
|
|
| 84 |
+ invalid={Boolean(errors.phoneNumber)}
|
|
| 83 | 85 |
/> |
| 84 |
- </Field> |
|
| 85 |
- <FieldError message={errors.email} />
|
|
| 86 |
+ </div> |
|
| 87 |
+ <input type="hidden" name="phoneNumber" value={phoneNumber} />
|
|
| 86 | 88 |
|
| 87 |
- <Field label="역할 선택 *"> |
|
| 88 |
- <Select |
|
| 89 |
- name="roleCode" |
|
| 90 |
- defaultValue={member?.roleCode ?? DEFAULT_ADMIN_ROLE_CODE}
|
|
| 91 |
- > |
|
| 92 |
- {ADMIN_ROLE_OPTIONS.map((option) => (
|
|
| 93 |
- <option key={option.value} value={option.value}>
|
|
| 94 |
- {option.label}
|
|
| 95 |
- </option> |
|
| 89 |
+ {emailField}
|
|
| 90 |
+ |
|
| 91 |
+ <FoxSelect |
|
| 92 |
+ size="md" |
|
| 93 |
+ label="역할 선택" |
|
| 94 |
+ options={ADMIN_ROLE_OPTIONS.map((option) => ({
|
|
| 95 |
+ value: option.value, |
|
| 96 |
+ label: option.label, |
|
| 97 |
+ }))} |
|
| 98 |
+ value={roleCode}
|
|
| 99 |
+ onValueChange={onRoleCodeChange}
|
|
| 100 |
+ error={Boolean(errors.roleCode)}
|
|
| 101 |
+ hint={errors.roleCode}
|
|
| 102 |
+ /> |
|
| 103 |
+ <input type="hidden" name="roleCode" value={roleCode} />
|
|
| 104 |
+ |
|
| 105 |
+ <div className={styles.field} role="group" aria-labelledby={menuLabelId}>
|
|
| 106 |
+ <FoxFormLabel as="span" id={menuLabelId} requirement="required">
|
|
| 107 |
+ 메뉴 선택 |
|
| 108 |
+ </FoxFormLabel> |
|
| 109 |
+ <FoxChipArea size="md" wrap labelledBy={menuLabelId}>
|
|
| 110 |
+ {/* 시안의 첫 칩. 개별 메뉴가 아니라 나머지를 한 번에 켜고 끄는 조각이다. */}
|
|
| 111 |
+ <FoxChip |
|
| 112 |
+ type="check" |
|
| 113 |
+ label="전체" |
|
| 114 |
+ checked={isAllSelected}
|
|
| 115 |
+ onCheckedChange={(checked) =>
|
|
| 116 |
+ onMenuCodesChange(checked ? allMenuValues : []) |
|
| 117 |
+ } |
|
| 118 |
+ /> |
|
| 119 |
+ {ADMIN_MENU_OPTIONS.map((option) => (
|
|
| 120 |
+ <FoxChip |
|
| 121 |
+ key={option.value}
|
|
| 122 |
+ type="check" |
|
| 123 |
+ label={option.label}
|
|
| 124 |
+ checked={menuCodes.includes(option.value)}
|
|
| 125 |
+ onCheckedChange={() => toggleMenu(option.value)}
|
|
| 126 |
+ /> |
|
| 96 | 127 |
))} |
| 97 |
- </Select> |
|
| 98 |
- </Field> |
|
| 99 |
- <FieldError message={errors.roleCode} />
|
|
| 100 |
- |
|
| 101 |
- <Field label="메뉴 선택 *"> |
|
| 102 |
- <AdminMenuPicker defaultValue={member?.menuCodes ?? []} />
|
|
| 103 |
- </Field> |
|
| 104 |
- <FieldError message={errors.menuCodes} />
|
|
| 128 |
+ </FoxChipArea> |
|
| 129 |
+ </div> |
|
| 130 |
+ {menuCodes.map((code) => (
|
|
| 131 |
+ <input key={code} type="hidden" name="menuCodes" value={code} />
|
|
| 132 |
+ ))} |
|
| 105 | 133 |
</> |
| 106 | 134 |
); |
| 107 |
-} |
|
| 108 |
- |
|
| 109 |
-/** 필드 하단 오류 문구. 값이 없으면 아무것도 그리지 않아 레이아웃을 차지하지 않는다. */ |
|
| 110 |
-export function FieldError({ message }: { message?: string }) {
|
|
| 111 |
- if (!message) {
|
|
| 112 |
- return null; |
|
| 113 |
- } |
|
| 114 |
- return <p className="text-body-sm text-danger">{message}</p>;
|
|
| 115 | 135 |
} |
--- app/(protected)/(basic)/admins/_components/admin-member-list.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-list.tsx
... | ... | @@ -21,6 +21,7 @@ |
| 21 | 21 |
type AdminMember, |
| 22 | 22 |
} from '@/lib/domain/admin-member'; |
| 23 | 23 |
import {
|
| 24 |
+ ADMIN_MEMBERS_EXCEL_PATH, |
|
| 24 | 25 |
ADMIN_MEMBER_PAGE_SIZE_OPTIONS, |
| 25 | 26 |
ADMIN_MEMBER_SEARCH_FIELD_OPTIONS, |
| 26 | 27 |
ADMIN_MEMBER_SORT_OPTIONS, |
... | ... | @@ -43,8 +44,6 @@ |
| 43 | 44 |
/** 현재 로그인한 관리자의 id — 본인 행의 삭제 버튼을 막는 데 쓴다. */ |
| 44 | 45 |
currentAdminId: string; |
| 45 | 46 |
} |
| 46 |
- |
|
| 47 |
-const EXCEL_UNSUPPORTED_NOTE = '엑셀 다운로드는 추후 제공됩니다.'; |
|
| 48 | 47 |
|
| 49 | 48 |
/** 폭이 고정되지 않은 세 열이 나눠 갖는 몫 — 시안 1552px 기준 (1552 - 고정열 720) / 3. */ |
| 50 | 49 |
const FILL_COLUMN_WIDTH = 277; |
... | ... | @@ -70,9 +69,9 @@ |
| 70 | 69 |
* 새로고침·뒤로가기·공유가 그대로 동작한다), 검색·정렬·페이지 크기는 컨트롤이 눌린 순간 |
| 71 | 70 |
* `router.replace`로 같은 주소 규칙을 태운다. 어느 쪽이든 목록을 다시 그리는 것은 서버다. |
| 72 | 71 |
* |
| 73 |
- * **[엑셀 다운로드]는 자리만 잡고 비활성이다**(사용자 확정). 백엔드에 관리자 엑셀 API가 없다 |
|
| 74 |
- * — `/api/v1/mngr/admin/**`에는 목록 조회 하나뿐이라, 학생 목록처럼 라우트 핸들러로 중계할 |
|
| 75 |
- * 대상이 없다. 비활성 버튼은 스스로 hover 이벤트를 받지 못해 안내는 `<span title>`이 든다. |
|
| 72 |
+ * [엑셀 다운로드]는 네이티브 GET 폼이다 — 응답이 첨부파일(Content-Disposition)이라 라우터 |
|
| 73 |
+ * 내비게이션으로는 처리할 수 없고, 브라우저가 화면을 둔 채 파일만 내려받는다. 검색·페이징 값을 |
|
| 74 |
+ * 싣지 않는 것이 사양이다(파일은 항상 전체 데이터). |
|
| 76 | 75 |
*/ |
| 77 | 76 |
export function AdminMemberList({
|
| 78 | 77 |
items, |
... | ... | @@ -200,15 +199,15 @@ |
| 200 | 199 |
keyword={query.keyword}
|
| 201 | 200 |
toolbarActions={
|
| 202 | 201 |
<FoxButtonGroup size="md"> |
| 203 |
- <span title={EXCEL_UNSUPPORTED_NOTE}>
|
|
| 202 |
+ <form action={ADMIN_MEMBERS_EXCEL_PATH} method="get">
|
|
| 204 | 203 |
<FoxButton |
| 205 | 204 |
type="secondary" |
| 206 | 205 |
size="md" |
| 207 |
- disabled |
|
| 206 |
+ htmlType="submit" |
|
| 208 | 207 |
leadingIcon={<FoxDownloadSimpleIcon />}
|
| 209 | 208 |
label="엑셀 다운로드" |
| 210 | 209 |
/> |
| 211 |
- </span> |
|
| 210 |
+ </form> |
|
| 212 | 211 |
<FoxButton |
| 213 | 212 |
type="primary" |
| 214 | 213 |
size="md" |
+++ app/(protected)/(basic)/admins/_components/admin-member-modal.module.scss
... | ... | @@ -0,0 +1,49 @@ |
| 1 | +// 관리자 등록·수정 팝업의 배치 — 시안(ADM_ADM_102_p / 103_p)의 form-list. | |
| 2 | +// 값은 전부 @fox 토큰을 거치므로 없는 이름을 쓰면 빌드가 실패한다. | |
| 3 | + | |
| 4 | +@use "@fox/styles/abstracts" as fox; | |
| 5 | + | |
| 6 | +/// 필드 한 벌씩 세로로 쌓는다. 라벨·상자·헬퍼 사이 간격은 각 @fox 컴포넌트가 이미 갖는다. | |
| 7 | +/// 모달 contents는 `align-items: flex-start`라 자식이 늘어나지 않는다 — 폭을 주지 않으면 폼이 | |
| 8 | +/// 내용만큼 넓어져(전화번호 칸이 가장 넓다) 모달 밖으로 터진다. | |
| 9 | +.formList { | |
| 10 | + display: flex; | |
| 11 | + flex-direction: column; | |
| 12 | + gap: fox.gap(8); | |
| 13 | + inline-size: 100%; | |
| 14 | + min-inline-size: 0; | |
| 15 | +} | |
| 16 | + | |
| 17 | +/// 입력칸 바로 아래 전체폭 버튼이 붙는 칸([중복 확인]·[비밀번호 변경]). | |
| 18 | +.fieldWithAction { | |
| 19 | + display: flex; | |
| 20 | + flex-direction: column; | |
| 21 | + gap: fox.gap(3); | |
| 22 | +} | |
| 23 | + | |
| 24 | +/// 시안(102_p)의 이메일 한 줄 — [아이디] @ [도메인]. @fox의 `FoxEmail`을 쓰지 않는다: | |
| 25 | +/// 그쪽은 도메인 셀렉트 옆에 잠긴 '직접입력' 상자를 하나 더 그리고(시안은 상자 둘뿐), | |
| 26 | +/// 칸마다 240px 최소폭이 박혀 있어 320px 안에서 세 줄로 쌓인다(사용자 확정). | |
| 27 | +.emailRow { | |
| 28 | + display: flex; | |
| 29 | + align-items: center; | |
| 30 | + gap: fox.gap(2); | |
| 31 | + inline-size: 100%; | |
| 32 | +} | |
| 33 | + | |
| 34 | +/// 두 칸이 남는 폭을 나눠 갖는다. `min-inline-size: 0`이 없으면 내용만큼 넓어져 줄이 넘어간다. | |
| 35 | +.emailPart { | |
| 36 | + flex: 1 1 0; | |
| 37 | + min-inline-size: 0; | |
| 38 | +} | |
| 39 | + | |
| 40 | +/// 라벨 + 컨트롤 한 벌. @fox 입력 컴포넌트는 라벨을 스스로 갖지만, 여러 칸을 묶은 필드 | |
| 41 | +/// (전화번호·이메일·메뉴·잠김여부)는 라벨을 밖에서 얹으므로 그 간격을 여기서 준다. | |
| 42 | +/// `FoxFormLabel`은 인라인이라 이 틀이 없으면 토글 같은 인라인 컨트롤과 한 줄에 붙는다. | |
| 43 | +.field { | |
| 44 | + display: flex; | |
| 45 | + flex-direction: column; | |
| 46 | + align-items: flex-start; | |
| 47 | + gap: fox.gap(3); | |
| 48 | + inline-size: 100%; | |
| 49 | +} |
--- app/(protected)/(basic)/admins/_components/admin-member-row-actions.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-row-actions.tsx
... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 | 3 |
import { useState, useTransition } from 'react';
|
| 4 |
-import { Button } from '@/components/ui/button';
|
|
| 4 |
+import { FoxButton } from '@fox/core/components/fox-button';
|
|
| 5 | 5 |
import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
|
| 6 | 6 |
import { FoxIconButton } from '@fox/core/components/fox-icon-button';
|
| 7 | 7 |
import { FoxPencilSimpleIcon, FoxTrashIcon } from '@fox/core/icons';
|
... | ... | @@ -65,14 +65,10 @@ |
| 65 | 65 |
title: '관리자를 삭제하시겠습니까?', |
| 66 | 66 |
message: `${member.name}(${member.loginId}) 계정을 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
|
| 67 | 67 |
actions: ( |
| 68 |
- <> |
|
| 69 |
- <Button type="button" variant="ghost" onClick={hideAlert}>
|
|
| 70 |
- 취소 |
|
| 71 |
- </Button> |
|
| 72 |
- <Button type="button" variant="primary" onClick={runDelete}>
|
|
| 73 |
- 삭제 |
|
| 74 |
- </Button> |
|
| 75 |
- </> |
|
| 68 |
+ <FoxButtonGroup size="md"> |
|
| 69 |
+ <FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
|
|
| 70 |
+ <FoxButton type="error" size="md" label="삭제" onAction={runDelete} />
|
|
| 71 |
+ </FoxButtonGroup> |
|
| 76 | 72 |
), |
| 77 | 73 |
}); |
| 78 | 74 |
} |
+++ app/(protected)/(basic)/admins/excel/route.ts
... | ... | @@ -0,0 +1,67 @@ |
| 1 | +import { getSessionAccessToken, verifySession } from '@/lib/auth/dal'; | |
| 2 | +import { backendFetchStream } from '@/lib/http/backend-fetch'; | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * 관리자 회원 목록 엑셀 다운로드 — 백엔드가 만든 xlsx를 브라우저로 중계한다. | |
| 6 | + * | |
| 7 | + * 라우트 핸들러인 이유는 학생 목록(`students/excel/route.ts`)과 같다 — 브라우저는 백엔드를 직접 | |
| 8 | + * 호출하지 않고, 백엔드 엑셀 API가 요구하는 ROLE_ADMIN 토큰은 httpOnly 세션 안에만 있다. 본문은 | |
| 9 | + * 파싱하지 않고 업스트림 스트림을 그대로 흘려보낸다. | |
| 10 | + * | |
| 11 | + * 파일에 담기는 열은 백엔드가 정한다 — 번호·이름·아이디·휴대폰번호·이메일·생성일 | |
| 12 | + * (`MngrAdminApiController.downloadExcel`). 화면의 "역할" 열은 파일에 없다. | |
| 13 | + */ | |
| 14 | + | |
| 15 | +const ADMIN_MEMBER_EXCEL_PATH = '/api/v1/mngr/admin/excel/download'; | |
| 16 | + | |
| 17 | +/** | |
| 18 | + * 파일은 화면의 검색·페이징과 무관하게 **항상 전체**를 담는다(학생 목록과 같은 사양). | |
| 19 | + * | |
| 20 | + * 백엔드 엑셀 API가 목록과 같은 `selectPagination`을 그대로 쓰므로 페이징이 걸린다 — 파라미터를 | |
| 21 | + * 생략하면 기본값(`recordCountPerPage=10`)이 적용돼 10건만 담긴 파일이 나온다. "전체" 모드가 따로 | |
| 22 | + * 없어 충분히 큰 상한을 명시해 1페이지로 전부 받는다. 이 상한을 넘으면 파일이 조용히 잘린다. | |
| 23 | + */ | |
| 24 | +const EXCEL_ROW_LIMIT = 100_000; | |
| 25 | + | |
| 26 | +/** 전체 행을 모아 워크북을 만드는 시간이 있어 일반 조회보다 넉넉히 잡는다. */ | |
| 27 | +const EXCEL_TIMEOUT_MS = 60_000; | |
| 28 | + | |
| 29 | +const DOWNLOAD_FAILED_MESSAGE = | |
| 30 | + '엑셀 파일을 내려받지 못했습니다. 잠시 후 다시 시도해 주세요.'; | |
| 31 | + | |
| 32 | +const XLSX_CONTENT_TYPE = | |
| 33 | + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; | |
| 34 | + | |
| 35 | +export async function GET() { | |
| 36 | + // 라우트 핸들러는 UI를 거치지 않고 직접 호출될 수 있으므로 여기서 직접 인증을 확인한다 | |
| 37 | + // (proxy의 쿠키 존재 확인은 낙관적 필터일 뿐이다 — 설계서 §8). | |
| 38 | + await verifySession(); | |
| 39 | + | |
| 40 | + const accessToken = await getSessionAccessToken(); | |
| 41 | + | |
| 42 | + const result = await backendFetchStream(ADMIN_MEMBER_EXCEL_PATH, { | |
| 43 | + query: { pageIndex: 1, recordCountPerPage: EXCEL_ROW_LIMIT }, | |
| 44 | + accessToken: accessToken ?? undefined, | |
| 45 | + timeoutMs: EXCEL_TIMEOUT_MS, | |
| 46 | + }); | |
| 47 | + | |
| 48 | + if (!result.ok) { | |
| 49 | + // 실패 사유(코드·백엔드 메시지)는 backendFetchStream이 서버 콘솔에 남긴다. | |
| 50 | + return new Response(DOWNLOAD_FAILED_MESSAGE, { | |
| 51 | + status: 502, | |
| 52 | + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, | |
| 53 | + }); | |
| 54 | + } | |
| 55 | + | |
| 56 | + const upstream = result.data; | |
| 57 | + | |
| 58 | + return new Response(upstream.body, { | |
| 59 | + status: 200, | |
| 60 | + headers: { | |
| 61 | + 'Content-Type': upstream.headers.get('content-type') ?? XLSX_CONTENT_TYPE, | |
| 62 | + 'Content-Disposition': | |
| 63 | + upstream.headers.get('content-disposition') ?? | |
| 64 | + 'attachment; filename="admins.xlsx"', | |
| 65 | + }, | |
| 66 | + }); | |
| 67 | +} |
--- lib/data/mock/admin-member-store.ts
... | ... | @@ -1,45 +0,0 @@ |
| 1 | -import 'server-only'; | |
| 2 | -import type { AdminMember } from '@/lib/domain/admin-member'; | |
| 3 | - | |
| 4 | -/** | |
| 5 | - * 관리자 회원 **삭제**의 mock 오버레이 — 백엔드에 삭제 API가 없어 임시로 두는 계층이다. | |
| 6 | - * 등록·수정·중복확인은 실제 API를 쓰므로(`admin-member-repository.ts`) 여기 남는 책임은 삭제뿐이다. | |
| 7 | - * | |
| 8 | - * 백엔드(edupay-backend develop 924db37) `MngrAdminApiController`에는 GET 3개(목록·단건·중복확인)와 | |
| 9 | - * POST·PUT만 있고 `@DeleteMapping`이 없다. 수정 API로 비활성화를 대신할 수도 없다 — UPDATE 문이 | |
| 10 | - * `USE_YN`을 건드리지 않아 사용여부를 끌 수단 자체가 없다(바꿀 수 있는 건 `ACCT_LOCK_YN`뿐). | |
| 11 | - * | |
| 12 | - * **한계** — 서버 프로세스 메모리에만 있다. 재시작하면 삭제한 행이 되살아나고, 인스턴스가 여럿이면 | |
| 13 | - * 공유되지 않는다. 삭제한 계정의 로그인 ID도 백엔드에는 그대로 남아 재등록 시 중복으로 걸린다. | |
| 14 | - * | |
| 15 | - * 백엔드에 삭제 API가 생기면 이 파일을 지우고 Repository의 `deleteAdminMember` 본문만 교체한다. | |
| 16 | - */ | |
| 17 | - | |
| 18 | -type MockStoreState = { | |
| 19 | - deletedIds: Set<string>; | |
| 20 | -}; | |
| 21 | - | |
| 22 | -/** HMR로 모듈이 다시 평가돼도 삭제 상태가 초기화되지 않도록 globalThis에 붙인다(mock 전용 편법). */ | |
| 23 | -const globalStore = globalThis as typeof globalThis & { | |
| 24 | - __adminMemberMockStore?: MockStoreState; | |
| 25 | -}; | |
| 26 | - | |
| 27 | -function getState(): MockStoreState { | |
| 28 | - globalStore.__adminMemberMockStore ??= { deletedIds: new Set() }; | |
| 29 | - return globalStore.__adminMemberMockStore; | |
| 30 | -} | |
| 31 | - | |
| 32 | -/** 백엔드 목록에서 mock 삭제된 행을 걷어낸다. 정렬·검색·페이징은 Repository가 한다. */ | |
| 33 | -export function applyMockDeletions(backendItems: AdminMember[]): AdminMember[] { | |
| 34 | - const { deletedIds } = getState(); | |
| 35 | - return backendItems.filter((item) => !deletedIds.has(item.id)); | |
| 36 | -} | |
| 37 | - | |
| 38 | -export function isMockDeleted(id: string): boolean { | |
| 39 | - return getState().deletedIds.has(id); | |
| 40 | -} | |
| 41 | - | |
| 42 | -/** 백엔드 행은 매 조회마다 다시 등장하므로 삭제 사실을 따로 기억해야 한다. */ | |
| 43 | -export function markMockDeleted(id: string): void { | |
| 44 | - getState().deletedIds.add(id); | |
| 45 | -} |
--- lib/data/repositories/admin-member-repository.ts
+++ lib/data/repositories/admin-member-repository.ts
... | ... | @@ -6,29 +6,24 @@ |
| 6 | 6 |
AdminMemberQuery, |
| 7 | 7 |
AdminMemberSearchField, |
| 8 | 8 |
} from '@/lib/domain/admin-member-query'; |
| 9 |
-import {
|
|
| 10 |
- applyMockDeletions, |
|
| 11 |
- isMockDeleted, |
|
| 12 |
- markMockDeleted, |
|
| 13 |
-} from '@/lib/data/mock/admin-member-store'; |
|
| 14 | 9 |
|
| 15 | 10 |
/** |
| 16 | 11 |
* 관리자 회원 Repository — 이 도메인을 백엔드에서 "어떻게 읽고 쓰는지"만 안다(엔드포인트·파라미터· |
| 17 | 12 |
* 응답 매핑). 백엔드와 말하는 공통 규약(URL·헤더·응답 봉투·실패 정규화)은 |
| 18 | 13 |
* `lib/http/backend-fetch.ts`가, 토큰 보관·검증은 `lib/auth`가 소유하므로 여기에 들어오지 않는다. |
| 19 | 14 |
* |
| 20 |
- * GET /api/v1/mngr/admin/pagination 목록 |
|
| 21 |
- * GET /api/v1/mngr/admin/{admUserId} 단건
|
|
| 22 |
- * GET /api/v1/mngr/admin/duplication/{id} 로그인 ID 중복 확인
|
|
| 23 |
- * POST /api/v1/mngr/admin 등록 |
|
| 24 |
- * PUT /api/v1/mngr/admin 수정 |
|
| 15 |
+ * GET /api/v1/mngr/admin/pagination 목록 |
|
| 16 |
+ * GET /api/v1/mngr/admin/{admUserId} 단건
|
|
| 17 |
+ * GET /api/v1/mngr/admin/duplication/{id} 로그인 ID 중복 확인
|
|
| 18 |
+ * POST /api/v1/mngr/admin 등록 |
|
| 19 |
+ * PUT /api/v1/mngr/admin/{admUserId} 수정
|
|
| 20 |
+ * DELETE /api/v1/mngr/admin/{admUserId} 삭제
|
|
| 25 | 21 |
* |
| 26 |
- * 아래는 백엔드 저장소(edupay-backend, develop 924db37)의 실제 구현과 dev 서버 응답을 확인한 것이다. |
|
| 22 |
+ * 아래는 백엔드 저장소(edupay-backend, develop 4d98756)의 실제 구현을 확인한 것이다. |
|
| 27 | 23 |
* |
| 28 |
- * - **등록·수정은 JSON 본문을 받지 않는다.** 두 핸들러 모두 `@RequestBody` 없이 |
|
| 29 |
- * `@ParameterObject MngrAdmin*RequestVo`를 받아 query/form으로만 바인딩된다. 그래서 `form`으로 보낸다. |
|
| 30 |
- * - **이메일·휴대전화번호를 되읽을 수 없다.** 저장은 되지만 조회 SQL의 select 목록에 두 컬럼이 없어 |
|
| 31 |
- * 응답은 항상 `null`이다(실측). 매핑은 미리 해 두었으므로 백엔드가 컬럼을 추가하면 화면까지 그대로 흐른다. |
|
| 24 |
+ * - **등록과 수정의 본문 형식이 서로 다르다.** 등록은 `@RequestBody` 없이 `@ParameterObject`라 |
|
| 25 |
+ * query/form으로만 바인딩되고(→ `form`), 수정은 `@RequestBody`라 JSON이다(→ `body`). |
|
| 26 |
+ * 같은 도메인인데 갈린 것이라 헷갈리기 쉽다 — 한쪽 방식으로 통일해 보내면 값이 조용히 비어 저장된다. |
|
| 32 | 27 |
* - **이름(`admNm`)은 수정 대상이 아니다** — `MngrAdminUpdateRequestVo`에 필드가 없다. |
| 33 | 28 |
* - **정렬 파라미터가 없다** — 목록 SQL의 `ORDER BY RNUM DESC`가 하드코딩돼 있다. rnum이 |
| 34 | 29 |
* `ROW_NUMBER() OVER (ORDER BY frst_reg_dt, adm_nm DESC)`, 즉 최초등록일시 오름차순 순번이라 |
... | ... | @@ -36,6 +31,8 @@ |
| 36 | 31 |
* 그대로 쓴다"는 뜻이고, 이름순만 우리가 정렬한다. |
| 37 | 32 |
* - **`totalCount`가 전체 건수가 아니다.** count 쿼리가 없어 `PaginationUtil.execute`가 |
| 38 | 33 |
* `list.size()`(= 그 페이지의 행 수)를 총건수로 그대로 쓴다. 그래서 이 값은 신뢰하지 않는다. |
| 34 |
+ * - **삭제는 soft delete다**(`DEL_YN='Y'`). 조회 SQL이 모두 `DEL_YN != 'Y'`로 거르므로 삭제한 행은 |
|
| 35 |
+ * 목록·단건에서 함께 사라진다. |
|
| 39 | 36 |
* |
| 40 | 37 |
* 인증: `/api/v1/mngr/**`는 ROLE_ADMIN 전용이다. 세션에 보관된 백엔드 accessToken을 DAL에서 |
| 41 | 38 |
* 꺼내 Bearer로 붙인다. |
... | ... | @@ -54,10 +51,8 @@ |
| 54 | 51 |
* 페이지가 가득 찰 때마다 `totalPages`가 1로 계산돼 2페이지 이후에 영원히 닿을 수 없다. |
| 55 | 52 |
* 시안(ADM_ADM_101)은 "총 N명 | 현재페이지 1/1"과 번호 열(총건수에서 거꾸로 세는 순번)을 |
| 56 | 53 |
* 요구하는데, 둘 다 정확한 전체 건수를 전제한다. |
| 57 |
- * 2. **검색·정렬을 백엔드에 맡길 수 없다.** 이름순 정렬 파라미터가 없고, 휴대전화번호 검색 |
|
| 58 |
- * 분기는 관리자 테이블에 없는 컬럼을 참조한다(`admin-member-query.ts` 주석 참조). |
|
| 59 |
- * 3. **삭제가 mock이다.** 삭제된 행을 걷어낸 뒤 세어야 총건수가 맞는데, 페이징이 백엔드에 있으면 |
|
| 60 |
- * 페이지마다 구멍이 생겨 건수와 순번이 어긋난다. |
|
| 54 |
+ * 2. **이름순 정렬을 백엔드에 맡길 수 없다.** 정렬 파라미터가 없고 목록 SQL의 ORDER BY가 |
|
| 55 |
+ * 하드코딩돼 있다. |
|
| 61 | 56 |
* |
| 62 | 57 |
* 관리자 계정은 본래 수십 건 규모라 전체를 받아도 부담이 없다. 이 전제가 깨질 정도로 늘면 |
| 63 | 58 |
* 백엔드에 count·정렬·검색 파라미터가 필요하다 — 상한 인상은 임시방편일 뿐이다. |
... | ... | @@ -115,12 +110,13 @@ |
| 115 | 110 |
id: readRequiredString(raw, 'admUserId'), |
| 116 | 111 |
name: readRequiredString(raw, 'admNm'), |
| 117 | 112 |
loginId: readRequiredString(raw, 'loginId'), |
| 118 |
- // 지금은 항상 null이다 — 조회 SQL이 두 컬럼을 select하지 않는다(위 주석 참조). |
|
| 119 | 113 |
phoneNumber: readOptionalString(raw, 'admTelNo'), |
| 120 | 114 |
email: readOptionalString(raw, 'admEmlAddr'), |
| 121 | 115 |
roleCode: readOptionalString(raw, 'admRoleCd') ?? '', |
| 116 |
+ // 백엔드에 관리자별 메뉴 권한 개념이 없다 — 저장도 조회도 되지 않는다(쓰기 경로 주석 참조). |
|
| 122 | 117 |
menuCodes: [], |
| 123 |
- createdAt: null, |
|
| 118 |
+ // `DATE_FORMAT(FRST_REG_DT, '%Y-%m-%d')`라 이미 화면 표기 형식이다. |
|
| 119 |
+ createdAt: readOptionalString(raw, 'frstRegDtStr'), |
|
| 124 | 120 |
isLocked: parseYesNo(raw.acctLockYn), |
| 125 | 121 |
isActive: parseYesNo(raw.useYn), |
| 126 | 122 |
loginFailCount: typeof loginFailCnt === 'number' ? loginFailCnt : null, |
... | ... | @@ -130,9 +126,9 @@ |
| 130 | 126 |
/** |
| 131 | 127 |
* 백엔드 목록 전체를 한 번에 받아온다. |
| 132 | 128 |
* |
| 133 |
- * 검색 파라미터(`searchCondition`/`searchKeyword`)를 **의도적으로 보내지 않는다** — 검색은 |
|
| 134 |
- * mock 행까지 포함해 일관되게 걸러야 하므로 아래 `filterByKeyword`가 전담한다. 백엔드에 검색을 |
|
| 135 |
- * 맡기면 백엔드 행만 걸러지고 mock 행은 그대로 남아 결과가 어긋난다. |
|
| 129 |
+ * 검색 파라미터(`searchCondition`/`searchKeyword`)를 **의도적으로 보내지 않는다** — 백엔드에 |
|
| 130 |
+ * 맡기면 페이징도 함께 백엔드가 하게 되는데 그 총건수를 믿을 수 없다(위 상수 주석). 어차피 전체를 |
|
| 131 |
+ * 손에 쥐므로 `filterByKeyword`가 같은 조건으로 거른다. |
|
| 136 | 132 |
*/ |
| 137 | 133 |
async function fetchAllAdminMembers(): Promise<AdminMember[]> {
|
| 138 | 134 |
const accessToken = await getSessionAccessToken(); |
... | ... | @@ -165,6 +161,7 @@ |
| 165 | 161 |
> = {
|
| 166 | 162 |
name: (member) => member.name, |
| 167 | 163 |
loginId: (member) => member.loginId, |
| 164 |
+ phoneNumber: (member) => member.phoneNumber ?? '', |
|
| 168 | 165 |
}; |
| 169 | 166 |
|
| 170 | 167 |
function filterByKeyword( |
... | ... | @@ -181,9 +178,8 @@ |
| 181 | 178 |
} |
| 182 | 179 |
|
| 183 | 180 |
/** |
| 184 |
- * 정렬. 생성일순은 **정렬하지 않는다** — 백엔드가 이미 생성일 최신순으로 내려주고(고정 |
|
| 185 |
- * `ORDER BY rnum DESC`), mock 신규 등록 행은 오버레이가 맨 앞에 붙여 두기 때문이다. 응답에 |
|
| 186 |
- * `createdAt` 값 자체가 없어 우리가 다시 정렬할 수단도 없다. |
|
| 181 |
+ * 정렬. 생성일순은 **정렬하지 않는다** — 백엔드의 고정 `ORDER BY rnum DESC`가 곧 생성일 |
|
| 182 |
+ * 최신순이라 받은 순서가 이미 답이다(rnum이 `frst_reg_dt` 오름차순 순번이다). |
|
| 187 | 183 |
*/ |
| 188 | 184 |
function sortItems( |
| 189 | 185 |
items: AdminMember[], |
... | ... | @@ -205,7 +201,7 @@ |
| 205 | 201 |
export async function fetchAdminMembers( |
| 206 | 202 |
query: AdminMemberQuery |
| 207 | 203 |
): Promise<AdminMemberPage> {
|
| 208 |
- const all = applyMockDeletions(await fetchAllAdminMembers()); |
|
| 204 |
+ const all = await fetchAllAdminMembers(); |
|
| 209 | 205 |
const matched = sortItems(filterByKeyword(all, query), query); |
| 210 | 206 |
|
| 211 | 207 |
const offset = (query.page - 1) * query.pageSize; |
... | ... | @@ -216,14 +212,10 @@ |
| 216 | 212 |
}; |
| 217 | 213 |
} |
| 218 | 214 |
|
| 219 |
-/** 단건 조회 — 수정 팝업이 쓰는 진입점. mock 삭제된 행은 없는 것으로 취급한다. */ |
|
| 215 |
+/** 단건 조회 — 수정 팝업이 쓰는 진입점. */ |
|
| 220 | 216 |
export async function findAdminMemberById( |
| 221 | 217 |
id: string |
| 222 | 218 |
): Promise<AdminMember | null> {
|
| 223 |
- if (isMockDeleted(id)) {
|
|
| 224 |
- return null; |
|
| 225 |
- } |
|
| 226 |
- |
|
| 227 | 219 |
const accessToken = await getSessionAccessToken(); |
| 228 | 220 |
const result = await backendFetch<unknown>( |
| 229 | 221 |
`${ADMIN_MEMBER_BASE_PATH}/${encodeURIComponent(id)}`,
|
... | ... | @@ -272,10 +264,9 @@ |
| 272 | 264 |
|
| 273 | 265 |
/* |
| 274 | 266 |
* ─── 쓰기 경로 ──────────────────────────────────────────────────────────────── |
| 275 |
- * 등록·수정은 실제 API를 쓴다. 삭제만 백엔드에 엔드포인트가 없어 mock 오버레이에 남아 있다. |
|
| 267 |
+ * 등록·수정·삭제 모두 실제 API를 쓴다. 성공 응답은 `data: null`이라 `canHaveNullData`가 필요하다. |
|
| 276 | 268 |
* |
| 277 |
- * 두 요청 모두 `form`으로 보낸다 — 백엔드 핸들러가 `@RequestBody` 없이 VO를 받아 JSON 본문을 |
|
| 278 |
- * 바인딩하지 못하기 때문이다. 성공 응답은 `data: null`이라 `canHaveNullData`가 필요하다. |
|
| 269 |
+ * **본문 형식이 등록과 수정에서 갈린다** — 등록은 form, 수정은 JSON이다(파일 상단 주석 참조). |
|
| 279 | 270 |
* |
| 280 | 271 |
* 메뉴 선택(`menuCodes`)은 **보내지 않는다** — 백엔드에 관리자별 메뉴 권한 개념이 없다 |
| 281 | 272 |
* (`/api/v1/common/menu`는 개인 북마크용이고 등록·수정 VO에도 해당 필드가 없다). |
... | ... | @@ -292,8 +283,9 @@ |
| 292 | 283 |
|
| 293 | 284 |
export type UpdateAdminMemberInput = {
|
| 294 | 285 |
/** |
| 295 |
- * 백엔드 UPDATE 문이 `LOGIN_PW`를 무조건 덮어쓰므로 빈 값을 보내면 그 계정이 로그인 불가가 된다. |
|
| 296 |
- * 그래서 이 필드는 선택이 아니라 필수다 — 호출부가 빈 값을 걸러 여기까지 오지 않게 한다. |
|
| 286 |
+ * 비우면 비밀번호를 바꾸지 않는다 — 백엔드 UPDATE의 `LOGIN_PW`가 |
|
| 287 |
+ * `<if test='loginPw != null and loginPw != ""'>`로 감싸여 있어 빈 값은 SET 절에서 빠진다. |
|
| 288 |
+ * (예전에는 조건 없이 덮어써서 빈 값을 보내면 그 계정이 로그인 불가가 됐다.) |
|
| 297 | 289 |
*/ |
| 298 | 290 |
password: string; |
| 299 | 291 |
phoneNumber: string; |
... | ... | @@ -338,26 +330,43 @@ |
| 338 | 330 |
): Promise<void> {
|
| 339 | 331 |
const accessToken = await getSessionAccessToken(); |
| 340 | 332 |
|
| 341 |
- const result = await backendFetch<unknown>(ADMIN_MEMBER_BASE_PATH, {
|
|
| 342 |
- method: 'PUT', |
|
| 343 |
- form: {
|
|
| 344 |
- admUserId: id, |
|
| 345 |
- loginPw: input.password, |
|
| 346 |
- admEmlAddr: input.email, |
|
| 347 |
- admTelNo: input.phoneNumber, |
|
| 348 |
- admRoleCd: input.roleCode, |
|
| 349 |
- acctLockYn: toYesNo(input.isLocked), |
|
| 350 |
- }, |
|
| 351 |
- accessToken: accessToken ?? undefined, |
|
| 352 |
- canHaveNullData: true, |
|
| 353 |
- }); |
|
| 333 |
+ const result = await backendFetch<unknown>( |
|
| 334 |
+ `${ADMIN_MEMBER_BASE_PATH}/${encodeURIComponent(id)}`,
|
|
| 335 |
+ {
|
|
| 336 |
+ method: 'PUT', |
|
| 337 |
+ // 수정만 `@RequestBody`라 JSON이다. form으로 보내면 모든 필드가 null로 들어가 |
|
| 338 |
+ // 이메일·전화번호가 지워지고 역할이 비워진다. |
|
| 339 |
+ body: {
|
|
| 340 |
+ loginPw: input.password, |
|
| 341 |
+ admEmlAddr: input.email, |
|
| 342 |
+ admTelNo: input.phoneNumber, |
|
| 343 |
+ admRoleCd: input.roleCode, |
|
| 344 |
+ acctLockYn: toYesNo(input.isLocked), |
|
| 345 |
+ }, |
|
| 346 |
+ accessToken: accessToken ?? undefined, |
|
| 347 |
+ canHaveNullData: true, |
|
| 348 |
+ } |
|
| 349 |
+ ); |
|
| 354 | 350 |
|
| 355 | 351 |
if (!result.ok) {
|
| 356 | 352 |
throw new BackendRequestError(result); |
| 357 | 353 |
} |
| 358 | 354 |
} |
| 359 | 355 |
|
| 360 |
-/** 삭제 API가 없어 화면에서만 감춘다 — 한계는 `admin-member-store.ts` 주석 참조. */ |
|
| 356 |
+/** 삭제(soft delete). 백엔드가 `DEL_YN='Y'`로 표시하고 조회 SQL이 그 행을 제외한다. */ |
|
| 361 | 357 |
export async function deleteAdminMember(id: string): Promise<void> {
|
| 362 |
- markMockDeleted(id); |
|
| 358 |
+ const accessToken = await getSessionAccessToken(); |
|
| 359 |
+ |
|
| 360 |
+ const result = await backendFetch<unknown>( |
|
| 361 |
+ `${ADMIN_MEMBER_BASE_PATH}/${encodeURIComponent(id)}`,
|
|
| 362 |
+ {
|
|
| 363 |
+ method: 'DELETE', |
|
| 364 |
+ accessToken: accessToken ?? undefined, |
|
| 365 |
+ canHaveNullData: true, |
|
| 366 |
+ } |
|
| 367 |
+ ); |
|
| 368 |
+ |
|
| 369 |
+ if (!result.ok) {
|
|
| 370 |
+ throw new BackendRequestError(result); |
|
| 371 |
+ } |
|
| 363 | 372 |
} |
--- lib/domain/admin-member-form.ts
+++ lib/domain/admin-member-form.ts
... | ... | @@ -2,7 +2,7 @@ |
| 2 | 2 |
* 관리자 등록/수정 입력 규칙 — 순수 검증 로직만 담는다(외부 의존 없음). |
| 3 | 3 |
* |
| 4 | 4 |
* 규칙은 시안(ADM_ADM_102_p / ADM_ADM_103_p)의 안내 문구를 그대로 옮긴 것이다: |
| 5 |
- * - ID : "영어 소문자, 숫자를 조합하여 입력 후 중복여부를 확인합니다." |
|
| 5 |
+ * - ID : "영어 소문자, 숫자를 조합하여 입력 후 중복여부를 확인하세요." |
|
| 6 | 6 |
* - 비밀번호: "영어 소문자, 숫자, 특수문자 중 2종류 이상 조합, 최소 10자리 이상" |
| 7 | 7 |
* |
| 8 | 8 |
* **이 파일이 검증의 단일 진실원천이다.** Server Action(`_actions.ts`)이 저장 직전에 여기를 |
... | ... | @@ -26,20 +26,10 @@ |
| 26 | 26 |
} from '@/lib/domain/admin-member'; |
| 27 | 27 |
|
| 28 | 28 |
export const ADMIN_LOGIN_ID_HELP_TEXT = |
| 29 |
- '영어 소문자, 숫자를 조합하여 입력 후 중복여부를 확인합니다.'; |
|
| 29 |
+ '영어 소문자, 숫자를 조합하여 입력 후 중복여부를 확인하세요.'; |
|
| 30 | 30 |
export const ADMIN_PASSWORD_HELP_TEXT = |
| 31 | 31 |
'영어 소문자, 숫자, 특수문자 중 2종류 이상 조합, 최소 10자리 이상'; |
| 32 | 32 |
|
| 33 |
-/** |
|
| 34 |
- * 수정 시 비밀번호를 비울 수 없는 이유를 화면에 그대로 보여준다. |
|
| 35 |
- * |
|
| 36 |
- * 백엔드 UPDATE 문이 `LOGIN_PW`를 조건 없이 덮어쓴다(`MngrAdminMapper.xml`의 update). 빈 값을 |
|
| 37 |
- * 보내면 비밀번호가 빈 문자열이 되어 그 계정은 다시 로그인할 수 없다 — 로그인은 암호화된 값과 |
|
| 38 |
- * 비교하기 때문이다. 그래서 "바꾸지 않음"을 표현할 방법이 없고, 프론트에서 빈 값을 막는 것이 |
|
| 39 |
- * 유일한 안전장치다. 백엔드가 빈 값을 UPDATE에서 제외하도록 고치면 이 제약을 없앨 수 있다. |
|
| 40 |
- */ |
|
| 41 |
-export const ADMIN_PASSWORD_REQUIRED_ON_UPDATE_TEXT = |
|
| 42 |
- '수정하려면 비밀번호를 다시 입력해 주세요. (빈 값으로 저장하면 로그인할 수 없게 됩니다)'; |
|
| 43 | 33 |
|
| 44 | 34 |
const LOGIN_ID_MIN_LENGTH = 4; |
| 45 | 35 |
const LOGIN_ID_MAX_LENGTH = 20; |
... | ... | @@ -47,8 +37,8 @@ |
| 47 | 37 |
const NAME_MAX_LENGTH = 50; |
| 48 | 38 |
const EMAIL_MAX_LENGTH = 100; |
| 49 | 39 |
|
| 50 |
-/** 휴대전화번호는 시안처럼 3칸으로 나뉘어 입력된다. 앞자리는 010 등 3자리, 가운데 3~4자리, 끝 4자리. */ |
|
| 51 |
-const PHONE_PART_PATTERNS = [/^\d{3}$/, /^\d{3,4}$/, /^\d{4}$/] as const;
|
|
| 40 |
+/** 앞 3자리 + 가운데 3~4자리 + 끝 4자리. 화면은 숫자만 다루고 하이픈은 여기서 붙인다. */ |
|
| 41 |
+const PHONE_DIGITS_PATTERN = /^(\d{3})(\d{3,4})(\d{4})$/;
|
|
| 52 | 42 |
|
| 53 | 43 |
/** 등록·수정 양쪽에서 실제로 바뀔 수 있는 항목. */ |
| 54 | 44 |
export type AdminMemberEditableValues = {
|
... | ... | @@ -124,26 +114,20 @@ |
| 124 | 114 |
} |
| 125 | 115 |
|
| 126 | 116 |
/** |
| 127 |
- * 휴대전화번호 3칸을 하나의 문자열(`010-1234-5678`)로 합친다. 비어 있는 칸이 하나라도 있거나 |
|
| 128 |
- * 형식이 맞지 않으면 null — 부분적으로 채워진 번호를 저장하지 않기 위해서다. |
|
| 117 |
+ * 화면이 넘긴 숫자열(`01012345678`)을 저장 형식(`010-1234-5678`)으로 바꾼다. 자릿수가 맞지 않으면 |
|
| 118 |
+ * null — 부분적으로 채워진 번호를 저장하지 않기 위해서다. |
|
| 119 |
+ * |
|
| 120 |
+ * 화면(`FoxPhoneNumber`)은 하이픈을 그리기만 하고 값으로는 숫자만 내보낸다. 반대로 목록·엑셀은 |
|
| 121 |
+ * 하이픈이 있는 형태로 보여주므로, 두 표현 사이의 변환을 이 한 쌍이 책임진다. |
|
| 129 | 122 |
*/ |
| 130 |
-export function joinPhoneNumber(parts: string[]): string | null {
|
|
| 131 |
- if (parts.length !== PHONE_PART_PATTERNS.length) {
|
|
| 132 |
- return null; |
|
| 133 |
- } |
|
| 134 |
- |
|
| 135 |
- const trimmed = parts.map((part) => part.trim()); |
|
| 136 |
- const isValid = trimmed.every((part, index) => |
|
| 137 |
- PHONE_PART_PATTERNS[index].test(part) |
|
| 138 |
- ); |
|
| 139 |
- |
|
| 140 |
- return isValid ? trimmed.join('-') : null;
|
|
| 123 |
+export function formatPhoneNumber(digits: string): string | null {
|
|
| 124 |
+ const matched = PHONE_DIGITS_PATTERN.exec(digits.trim()); |
|
| 125 |
+ return matched === null ? null : `${matched[1]}-${matched[2]}-${matched[3]}`;
|
|
| 141 | 126 |
} |
| 142 | 127 |
|
| 143 |
-/** 저장된 번호를 다시 3칸으로 나눈다(수정 팝업의 초기값). 형식이 다르면 빈 칸들을 돌려준다. */ |
|
| 144 |
-export function splitPhoneNumber(phoneNumber: string | null): string[] {
|
|
| 145 |
- const parts = (phoneNumber ?? '').split('-');
|
|
| 146 |
- return parts.length === PHONE_PART_PATTERNS.length ? parts : ['', '', '']; |
|
| 128 |
+/** 저장된 번호에서 숫자만 남긴다(수정 팝업의 초기값). */ |
|
| 129 |
+export function toPhoneDigits(phoneNumber: string | null): string {
|
|
| 130 |
+ return (phoneNumber ?? '').replace(/\D/g, ''); |
|
| 147 | 131 |
} |
| 148 | 132 |
|
| 149 | 133 |
function isValidEmail(email: string): boolean {
|
... | ... | @@ -157,21 +141,24 @@ |
| 157 | 141 |
/** |
| 158 | 142 |
* 등록·수정 공통 항목 검증. 오류는 넘겨받은 객체에 채워 넣고, 정규화된 값을 돌려준다. |
| 159 | 143 |
* |
| 160 |
- * 비밀번호는 등록·수정 모두 필수다. 빈 값일 때의 안내 문구만 호출부가 정한다 — 등록은 형식 안내, |
|
| 161 |
- * 수정은 "비우면 로그인 불가"라는 백엔드 제약을 알려야 하기 때문이다 |
|
| 162 |
- * (`ADMIN_PASSWORD_REQUIRED_ON_UPDATE_TEXT` 주석 참조). |
|
| 144 |
+ * **비밀번호·이메일은 등록에서만 필수다.** 수정에서 비우면 "바꾸지 않음"이 된다 — 백엔드 UPDATE의 |
|
| 145 |
+ * `LOGIN_PW`가 `<if test='loginPw != null and loginPw != ""'>`로 감싸여 있어 빈 값은 SET 절에서 |
|
| 146 |
+ * 빠진다(예전에는 조건 없이 덮어써서, 빈 값을 보내면 그 계정이 로그인 불가가 됐다). 값이 있으면 |
|
| 147 |
+ * 등록·수정 모두 같은 형식 규칙을 통과해야 한다. |
|
| 163 | 148 |
*/ |
| 164 | 149 |
function validateEditableValues( |
| 165 | 150 |
values: AdminMemberEditableValues, |
| 166 | 151 |
errors: AdminMemberFormErrors, |
| 167 |
- options: { emptyPasswordMessage: string }
|
|
| 152 |
+ options: { passwordRequired: boolean; emailRequired: boolean }
|
|
| 168 | 153 |
): AdminMemberEditableValues {
|
| 169 | 154 |
const password = values.password; |
| 170 | 155 |
const phoneNumber = values.phoneNumber.trim(); |
| 171 | 156 |
const email = values.email.trim(); |
| 172 | 157 |
|
| 173 | 158 |
if (!password) {
|
| 174 |
- errors.password = options.emptyPasswordMessage; |
|
| 159 |
+ if (options.passwordRequired) {
|
|
| 160 |
+ errors.password = ADMIN_PASSWORD_HELP_TEXT; |
|
| 161 |
+ } |
|
| 175 | 162 |
} else if ( |
| 176 | 163 |
password.length < PASSWORD_MIN_LENGTH || |
| 177 | 164 |
countCharacterKinds(password) < 2 |
... | ... | @@ -183,8 +170,12 @@ |
| 183 | 170 |
errors.phoneNumber = '휴대전화 번호를 정확히 입력해 주세요.'; |
| 184 | 171 |
} |
| 185 | 172 |
|
| 186 |
- // 이메일은 선택 항목이라 비어 있는 것 자체는 오류가 아니다(시안에 필수 표시가 없다). |
|
| 187 |
- if (email && !isValidEmail(email)) {
|
|
| 173 |
+ // 이메일 필수 여부는 두 시안이 다르다 — 등록(102_p)에는 `*`가 있고 수정(103_p)에는 없다. |
|
| 174 |
+ if (!email) {
|
|
| 175 |
+ if (options.emailRequired) {
|
|
| 176 |
+ errors.email = '이메일을 입력해 주세요.'; |
|
| 177 |
+ } |
|
| 178 |
+ } else if (!isValidEmail(email)) {
|
|
| 188 | 179 |
errors.email = '이메일 형식이 올바르지 않습니다.'; |
| 189 | 180 |
} |
| 190 | 181 |
|
... | ... | @@ -192,12 +183,12 @@ |
| 192 | 183 |
errors.roleCode = '역할을 선택해 주세요.'; |
| 193 | 184 |
} |
| 194 | 185 |
|
| 186 |
+ // 메뉴 선택은 **필수가 아니다**(사용자 확정) — 백엔드에 관리자별 메뉴 권한이 없어 고른 값이 |
|
| 187 |
+ // 저장되지 않는데, 저장되지도 않는 값 때문에 등록이 막히면 안 된다. 허용 목록 밖의 코드만 |
|
| 188 |
+ // 걸러 둔다(권한 API가 생기면 여기에 필수 규칙을 되살린다). |
|
| 195 | 189 |
const menuCodes = values.menuCodes.filter((code) => |
| 196 | 190 |
ADMIN_MENU_OPTIONS.some((option) => option.value === code) |
| 197 | 191 |
); |
| 198 |
- if (menuCodes.length === 0) {
|
|
| 199 |
- errors.menuCodes = '메뉴를 1개 이상 선택해 주세요.'; |
|
| 200 |
- } |
|
| 201 | 192 |
|
| 202 | 193 |
return { password, phoneNumber, email, roleCode: values.roleCode, menuCodes };
|
| 203 | 194 |
} |
... | ... | @@ -221,7 +212,8 @@ |
| 221 | 212 |
} |
| 222 | 213 |
|
| 223 | 214 |
const editable = validateEditableValues(values, errors, {
|
| 224 |
- emptyPasswordMessage: ADMIN_PASSWORD_HELP_TEXT, |
|
| 215 |
+ passwordRequired: true, |
|
| 216 |
+ emailRequired: true, |
|
| 225 | 217 |
}); |
| 226 | 218 |
|
| 227 | 219 |
if (Object.keys(errors).length > 0) {
|
... | ... | @@ -241,7 +233,8 @@ |
| 241 | 233 |
const errors: AdminMemberFormErrors = {};
|
| 242 | 234 |
|
| 243 | 235 |
const editable = validateEditableValues(values, errors, {
|
| 244 |
- emptyPasswordMessage: ADMIN_PASSWORD_REQUIRED_ON_UPDATE_TEXT, |
|
| 236 |
+ passwordRequired: false, |
|
| 237 |
+ emailRequired: false, |
|
| 245 | 238 |
}); |
| 246 | 239 |
|
| 247 | 240 |
if (Object.keys(errors).length > 0) {
|
--- lib/domain/admin-member-query.ts
+++ lib/domain/admin-member-query.ts
... | ... | @@ -14,21 +14,19 @@ |
| 14 | 14 |
/** 라우트 경로 — 이 파일 안에서만 하드코딩하고 나머지는 이 상수를 참조한다. */ |
| 15 | 15 |
export const ADMIN_MEMBERS_PATH = '/admins'; |
| 16 | 16 |
|
| 17 |
+/** 엑셀 다운로드 라우트 핸들러의 경로 — 목록 툴바의 폼이 이 주소로 GET 제출한다. */ |
|
| 18 |
+export const ADMIN_MEMBERS_EXCEL_PATH = `${ADMIN_MEMBERS_PATH}/excel`;
|
|
| 19 |
+ |
|
| 17 | 20 |
/** |
| 18 |
- * 검색 대상 — 시안(ADM_ADM_101 ①)은 회원명/ID/휴대전화번호 셋을 요구하지만 **휴대전화번호는 뺐다.** |
|
| 21 |
+ * 검색 대상 — 시안(ADM_ADM_101 ①)의 회원명/ID/휴대전화번호 셋. |
|
| 19 | 22 |
* |
| 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 |
- * 떨어져 화면 전체가 에러 경계로 넘어간다. |
|
| 23 |
+ * 휴대전화번호는 한동안 빠져 있었다. 목록 응답에 그 값이 없어 결과를 눈으로 검증할 수 없었고, |
|
| 24 |
+ * 백엔드의 해당 분기가 관리자 테이블에 없는 `USER_TELNO`를 참조해 SQL 오류가 날 상태였다. |
|
| 25 |
+ * 백엔드가 `ADM_TEL_NO`를 select 목록과 검색 분기 양쪽에 넣으면서 두 이유가 모두 사라졌다. |
|
| 27 | 26 |
* |
| 28 |
- * 백엔드가 휴대전화번호를 응답에 포함하면 여기와 `admin-member-repository.ts`의 필터에 함께 |
|
| 29 |
- * 추가한다. |
|
| 27 |
+ * 실제 필터링은 Repository가 전체를 손에 쥐고 수행한다 — 이 값은 "어느 열로 거를지"만 정한다. |
|
| 30 | 28 |
*/ |
| 31 |
-export type AdminMemberSearchField = 'name' | 'loginId'; |
|
| 29 |
+export type AdminMemberSearchField = 'name' | 'loginId' | 'phoneNumber'; |
|
| 32 | 30 |
|
| 33 | 31 |
export const ADMIN_MEMBER_SEARCH_FIELD_OPTIONS: ReadonlyArray<{
|
| 34 | 32 |
value: AdminMemberSearchField; |
... | ... | @@ -36,14 +34,15 @@ |
| 36 | 34 |
}> = [ |
| 37 | 35 |
{ value: 'name', label: '회원명' },
|
| 38 | 36 |
{ value: 'loginId', label: 'ID' },
|
| 37 |
+ { value: 'phoneNumber', label: '휴대전화번호' },
|
|
| 39 | 38 |
]; |
| 40 | 39 |
|
| 41 | 40 |
/** |
| 42 | 41 |
* 정렬 기준 — 시안의 select는 "가입일순"이지만 관리자 회원의 해당 값은 생성일이라 이름을 맞췄다. |
| 43 | 42 |
* |
| 44 |
- * `createdAt`은 백엔드가 응답에 담아 주지 않는데도 정렬이 가능한 이유는, 목록 SQL의 고정 |
|
| 45 |
- * `ORDER BY rnum DESC`가 곧 생성일 최신순이기 때문이다(rnum은 `frst_reg_dt` 오름차순 행번호). |
|
| 46 |
- * 즉 이 정렬은 "백엔드가 준 순서를 그대로 쓴다"는 뜻이다 — 자세한 근거는 Repository 주석 참조. |
|
| 43 |
+ * 생성일순이 곧 "백엔드가 준 순서를 그대로 쓴다"는 뜻이다 — 목록 SQL의 고정 `ORDER BY rnum DESC`가 |
|
| 44 |
+ * 생성일 최신순이기 때문이다(rnum은 `frst_reg_dt` 오름차순 행번호). 정렬 파라미터가 없어 이름순만 |
|
| 45 |
+ * 우리가 정렬한다. |
|
| 47 | 46 |
*/ |
| 48 | 47 |
export type AdminMemberSortOption = 'createdAt' | 'name'; |
| 49 | 48 |
|
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?