--- @fox/README.md
+++ @fox/README.md
... | ... | @@ -16,6 +16,7 @@ |
| 16 | 16 |
_mixins.scss fox.pc / fox.mobile |
| 17 | 17 |
core/ |
| 18 | 18 |
components/ React 래퍼 (선택 — 아래 "React 없이 쓰기" 참고) |
| 19 |
+ validation/ 입력 검증기 (프레임워크 무관 — 아래 "입력 검증" 참고) |
|
| 19 | 20 |
utils/ cx() 등 의존성 없는 유틸 |
| 20 | 21 |
dev-test/ 개발 전용 테스트·감사 화면 |
| 21 | 22 |
tools/build-tokens.py Figma JSON → SCSS 변환기 |
... | ... | @@ -64,6 +65,45 @@ |
| 64 | 65 |
6. **폰트를 공급합니다** — 아래 "호스트 앱과의 계약" 참고. 빠뜨리면 컴포넌트가 OS 기본 |
| 65 | 66 |
서체로 그려져 글자 폭이 시안과 어긋납니다. |
| 66 | 67 |
|
| 68 |
+## 입력 검증 |
|
| 69 |
+ |
|
| 70 |
+`@fox/core/validation`은 Angular의 `Validators` 계약을 그대로 옮긴 것입니다. **검증기는 값을 받아 |
|
| 71 |
+통과면 `null`, 아니면 오류 객체를 돌려주는 순수 함수**이고, 오류 객체의 키가 오류의 이름입니다. |
|
| 72 |
+ |
|
| 73 |
+```ts |
|
| 74 |
+import { foxValidators, foxPasswordValidator, foxValidationMessage } from "@fox/core/validation";
|
|
| 75 |
+ |
|
| 76 |
+foxValidators.minLength(10)("abc");
|
|
| 77 |
+// → { minlength: { requiredLength: 10, actualLength: 3 } }
|
|
| 78 |
+``` |
|
| 79 |
+ |
|
| 80 |
+문구는 검증기가 정하지 않습니다(Angular와 같은 이유입니다) — 같은 규칙도 자리에 따라 다른 말로 |
|
| 81 |
+안내해야 하기 때문입니다. `foxValidationMessage(errors, overrides)`가 오류를 문구 하나로 옮기고, |
|
| 82 |
+`overrides`로 자리마다 덮어씁니다. |
|
| 83 |
+ |
|
| 84 |
+`FoxInput`은 이것을 `validators`·`validationMessages`·`validateOn`·`onValidationChange`로 받습니다. |
|
| 85 |
+`type="password"`처럼 규칙이 여러 개인 칸은 `foxPasswordValidator`가 한 벌로 묶어 줍니다. |
|
| 86 |
+ |
|
| 87 |
+```tsx |
|
| 88 |
+<FoxInput |
|
| 89 |
+ type="password" |
|
| 90 |
+ label="비밀번호" |
|
| 91 |
+ message="영문·숫자·특수문자 중 2종류 이상, 10자리 이상" // 평소엔 헬퍼 |
|
| 92 |
+ validators={[foxPasswordValidator({ minLength: 10, kinds: 2, required: true })]}
|
|
| 93 |
+/> |
|
| 94 |
+``` |
|
| 95 |
+ |
|
| 96 |
+- `message`는 평소 **헬퍼**이고, 검증이 걸리면 그 자리에 사유가 대신 들어갑니다. 호출부가 |
|
| 97 |
+ `invalid`를 직접 켠 경우에는 `message`가 이깁니다 — 서버가 돌려준 사유(중복·권한 등)는 화면 |
|
| 98 |
+ 규칙이 알 수 없는 것이라 덮이면 안 됩니다. |
|
| 99 |
+- 오류는 **한 번 포커스를 벗어난 뒤부터** 보입니다(`validateOn="blur"`, 기본값). 한 글자 쳤을 때 |
|
| 100 |
+ "10자 이상"이 뜨면 안내가 아니라 방해가 되기 때문입니다. 벗어난 뒤로는 입력할 때마다 갱신됩니다 |
|
| 101 |
+ (Angular의 `touched`와 같은 판단입니다 — 다만 Angular `updateOn`의 기본값은 `change`입니다). |
|
| 102 |
+- **화면 검증은 안내일 뿐 신뢰 경계가 아닙니다.** 최종 판정은 저장 직전의 서버 검증입니다. |
|
| 103 |
+ |
|
| 104 |
+검증기는 React에 의존하지 않으므로 다른 프레임워크·서버 코드에서도 그대로 씁니다 — 실제로 이 |
|
| 105 |
+저장소는 같은 규칙 값을 화면과 Server Action이 함께 봅니다. |
|
| 106 |
+ |
|
| 67 | 107 |
## React 없이 쓰기 |
| 68 | 108 |
|
| 69 | 109 |
**디자인 레이어는 프레임워크에 의존하지 않습니다.** 토큰도 컴포넌트 스타일도 순수 SCSS이고, |
--- @fox/core/components/fox-chip-area/fox-chip-area.tsx
+++ @fox/core/components/fox-chip-area/fox-chip-area.tsx
... | ... | @@ -24,6 +24,8 @@ |
| 24 | 24 |
label?: string; |
| 25 | 25 |
/** 묶음 이름 역할을 하는 요소의 id. `label`보다 우선한다 — 화면의 글자와 어긋나지 않는다. */ |
| 26 | 26 |
labelledBy?: string; |
| 27 |
+ /** 칩이 한 줄에 다 들어가지 않으면 다음 줄로 흘린다(`FoxButtonGroup`과 같은 규약). */ |
|
| 28 |
+ wrap?: boolean; |
|
| 27 | 29 |
/** `FoxChip`들. */ |
| 28 | 30 |
children?: ReactNode; |
| 29 | 31 |
/** 배치 조정용. */ |
... | ... | @@ -77,6 +79,7 @@ |
| 77 | 79 |
size, |
| 78 | 80 |
label, |
| 79 | 81 |
labelledBy, |
| 82 |
+ wrap = false, |
|
| 80 | 83 |
children, |
| 81 | 84 |
className, |
| 82 | 85 |
ref, |
... | ... | @@ -93,7 +96,7 @@ |
| 93 | 96 |
role="group" |
| 94 | 97 |
aria-label={labelledBy ? undefined : label}
|
| 95 | 98 |
aria-labelledby={labelledBy}
|
| 96 |
- className={cx("fox-chip-area", className)}
|
|
| 99 |
+ className={cx("fox-chip-area", wrap && "fox-chip-area--wrap", className)}
|
|
| 97 | 100 |
> |
| 98 | 101 |
{overrideChildren(children, overrides)}
|
| 99 | 102 |
</div> |
--- @fox/core/components/fox-input/fox-input.tsx
+++ @fox/core/components/fox-input/fox-input.tsx
... | ... | @@ -1,16 +1,25 @@ |
| 1 | 1 |
"use client"; |
| 2 | 2 |
|
| 3 | 3 |
import {
|
| 4 |
+ useEffect, |
|
| 4 | 5 |
useId, |
| 5 | 6 |
useRef, |
| 6 | 7 |
useState, |
| 7 | 8 |
type ChangeEvent, |
| 8 | 9 |
type ComponentPropsWithRef, |
| 10 |
+ type FocusEvent, |
|
| 9 | 11 |
type FormEvent, |
| 10 | 12 |
type ReactNode, |
| 11 | 13 |
} from "react"; |
| 12 | 14 |
import { cx } from "../../utils";
|
| 13 | 15 |
import { FoxFormLabel, type FoxFormLabelRequirement } from "../fox-form-label";
|
| 16 |
+import {
|
|
| 17 |
+ foxValidationMessage, |
|
| 18 |
+ foxValidators, |
|
| 19 |
+ type FoxValidationErrors, |
|
| 20 |
+ type FoxValidationMessages, |
|
| 21 |
+ type FoxValidator, |
|
| 22 |
+} from "../../validation"; |
|
| 14 | 23 |
|
| 15 | 24 |
export type FoxInputSize = "lg" | "md" | "sm"; |
| 16 | 25 |
export type FoxInputState = "default" | "focused" | "completed" | "error" | "disabled" | "view"; |
... | ... | @@ -48,6 +57,24 @@ |
| 48 | 57 |
invalid?: boolean; |
| 49 | 58 |
/** 주면 모양이 그 상태로 고정된다. 없으면 포커스·값·비활성으로 브라우저가 판단한다. */ |
| 50 | 59 |
state?: FoxInputState; |
| 60 |
+ |
|
| 61 |
+ /** |
|
| 62 |
+ * 값 검증기. 여럿이면 오류가 합쳐지고(`foxValidators.compose`와 같다) 그중 하나가 문구가 된다. |
|
| 63 |
+ * `type="password"`에는 `foxPasswordValidator(...)`를 그대로 얹으면 된다. |
|
| 64 |
+ * |
|
| 65 |
+ * 화면 검증은 **안내일 뿐 신뢰 경계가 아니다** — 저장 직전의 서버 검증이 최종 판정이다. |
|
| 66 |
+ */ |
|
| 67 |
+ validators?: FoxValidator[]; |
|
| 68 |
+ /** 오류 키별 문구 덮어쓰기. 주지 않으면 @fox의 기본 문구가 나온다. */ |
|
| 69 |
+ validationMessages?: FoxValidationMessages; |
|
| 70 |
+ /** |
|
| 71 |
+ * 언제부터 오류를 보여줄지. 기본 `blur` — 한 글자 쳤을 때 "10자 이상"이 뜨면 안내가 아니라 |
|
| 72 |
+ * 방해가 된다. 한 번 벗어난 뒤로는 두 모드 모두 입력할 때마다 갱신된다(Angular의 `touched`와 |
|
| 73 |
+ * 같은 판단이다. 다만 Angular의 `updateOn` 기본값은 `change`라 그 점만 다르다). |
|
| 74 |
+ */ |
|
| 75 |
+ validateOn?: "blur" | "change"; |
|
| 76 |
+ /** 검증 결과가 바뀔 때. 호출부가 저장 버튼을 잠그는 데 쓴다. */ |
|
| 77 |
+ onValidationChange?: (errors: FoxValidationErrors | null) => void; |
|
| 51 | 78 |
/** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ |
| 52 | 79 |
hidden?: boolean; |
| 53 | 80 |
/** 배치 조정용. */ |
... | ... | @@ -69,6 +96,11 @@ |
| 69 | 96 |
* ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` |
| 70 | 97 |
* (또는 개별 파티셜)로 한 번 불러와야 한다. |
| 71 | 98 |
*/ |
| 99 |
+/** 오류 객체를 비교 가능한 문자열로. 매 렌더 새 객체가 나와 참조로는 견줄 수 없다. */ |
|
| 100 |
+function errorKey(errors: FoxValidationErrors | null): string {
|
|
| 101 |
+ return errors ? Object.keys(errors).sort().join("|") : "";
|
|
| 102 |
+} |
|
| 103 |
+ |
|
| 72 | 104 |
export function FoxInput({
|
| 73 | 105 |
size = "md", |
| 74 | 106 |
onChange, |
... | ... | @@ -83,6 +115,10 @@ |
| 83 | 115 |
clearLabel = "입력 지우기", |
| 84 | 116 |
invalid = false, |
| 85 | 117 |
state, |
| 118 |
+ validators, |
|
| 119 |
+ validationMessages, |
|
| 120 |
+ validateOn = "blur", |
|
| 121 |
+ onValidationChange, |
|
| 86 | 122 |
hidden = false, |
| 87 | 123 |
className, |
| 88 | 124 |
id, |
... | ... | @@ -97,6 +133,28 @@ |
| 97 | 133 |
const [typedIn, setTypedIn] = useState(() => String(defaultValue ?? "").length > 0); |
| 98 | 134 |
const fieldRef = useRef<HTMLInputElement>(null); |
| 99 | 135 |
|
| 136 |
+ // 값을 밖에서 들지 않는(비제어) 호출부도 있어 검증할 값을 직접 따라간다. |
|
| 137 |
+ const [draft, setDraft] = useState(() => String(defaultValue ?? "")); |
|
| 138 |
+ const [touched, setTouched] = useState(false); |
|
| 139 |
+ |
|
| 140 |
+ const validationErrors = |
|
| 141 |
+ validators && validators.length > 0 |
|
| 142 |
+ ? foxValidators.compose(validators)( |
|
| 143 |
+ value !== undefined ? String(value) : draft |
|
| 144 |
+ ) |
|
| 145 |
+ : null; |
|
| 146 |
+ |
|
| 147 |
+ // 알림은 렌더가 끝난 뒤에 낸다 — 렌더 도중 부모 상태를 바꾸면 React가 경고한다. |
|
| 148 |
+ // 매 렌더 새 객체가 나오므로 의존은 오류 **키 목록**으로 잡는다(값 자체는 ref로 읽는다). |
|
| 149 |
+ const errorsKey = errorKey(validationErrors); |
|
| 150 |
+ const latestRef = useRef({ errors: validationErrors, notify: onValidationChange });
|
|
| 151 |
+ useEffect(() => {
|
|
| 152 |
+ latestRef.current = { errors: validationErrors, notify: onValidationChange };
|
|
| 153 |
+ }); |
|
| 154 |
+ useEffect(() => {
|
|
| 155 |
+ latestRef.current.notify?.(latestRef.current.errors); |
|
| 156 |
+ }, [errorsKey]); |
|
| 157 |
+ |
|
| 100 | 158 |
if (hidden) {
|
| 101 | 159 |
return null; |
| 102 | 160 |
} |
... | ... | @@ -104,7 +162,19 @@ |
| 104 | 162 |
const fieldId = id ?? `${autoId}-field`;
|
| 105 | 163 |
const messageId = `${autoId}-message`;
|
| 106 | 164 |
const hasValue = value !== undefined ? String(value).length > 0 : typedIn; |
| 107 |
- const errored = invalid || state === "error"; |
|
| 165 |
+ |
|
| 166 |
+ const showValidation = validateOn === "change" || touched; |
|
| 167 |
+ const validationText = showValidation |
|
| 168 |
+ ? foxValidationMessage(validationErrors, validationMessages) |
|
| 169 |
+ : undefined; |
|
| 170 |
+ |
|
| 171 |
+ // `message`는 평소엔 **헬퍼**이고 검증이 걸리면 그 문구에 자리를 내준다(시안이 그 자리에 |
|
| 172 |
+ // 규칙 안내를 두고, 규칙을 어기면 같은 자리가 사유로 바뀐다). |
|
| 173 |
+ // 다만 호출부가 `invalid`를 직접 켰으면 그쪽이 이긴다 — 서버가 돌려준 사유는 화면 규칙이 |
|
| 174 |
+ // 알 수 없는 것이라(중복·권한 등) 덮이면 안 된다. |
|
| 175 |
+ const callerErrored = invalid || state === "error"; |
|
| 176 |
+ const shownMessage = callerErrored ? message : (validationText ?? message); |
|
| 177 |
+ const errored = callerErrored || validationText !== undefined; |
|
| 108 | 178 |
|
| 109 | 179 |
// 호출부 ref와 내부 ref를 함께 채운다 — 지우기가 비제어 입력의 DOM 값을 비워야 한다. |
| 110 | 180 |
const attachField = (node: HTMLInputElement | null) => {
|
... | ... | @@ -120,7 +190,13 @@ |
| 120 | 190 |
if (value === undefined) {
|
| 121 | 191 |
setTypedIn(event.target.value.length > 0); |
| 122 | 192 |
} |
| 193 |
+ setDraft(event.target.value); |
|
| 123 | 194 |
onChange?.(event.target.value, event); |
| 195 |
+ }; |
|
| 196 |
+ |
|
| 197 |
+ const handleBlur = (event: FocusEvent<HTMLInputElement>) => {
|
|
| 198 |
+ setTouched(true); |
|
| 199 |
+ rest.onBlur?.(event); |
|
| 124 | 200 |
}; |
| 125 | 201 |
|
| 126 | 202 |
const handleInput = (event: FormEvent<HTMLInputElement>) => {
|
... | ... | @@ -135,6 +211,7 @@ |
| 135 | 211 |
field.focus(); |
| 136 | 212 |
} |
| 137 | 213 |
setTypedIn(false); |
| 214 |
+ setDraft("");
|
|
| 138 | 215 |
onChange?.("");
|
| 139 | 216 |
}; |
| 140 | 217 |
|
... | ... | @@ -158,9 +235,10 @@ |
| 158 | 235 |
value={value}
|
| 159 | 236 |
defaultValue={defaultValue}
|
| 160 | 237 |
aria-invalid={errored || undefined}
|
| 161 |
- aria-describedby={message ? messageId : undefined}
|
|
| 238 |
+ aria-describedby={shownMessage ? messageId : undefined}
|
|
| 162 | 239 |
onChange={handleChange}
|
| 163 | 240 |
onInput={handleInput}
|
| 241 |
+ onBlur={handleBlur}
|
|
| 164 | 242 |
/> |
| 165 | 243 |
|
| 166 | 244 |
{clearable && hasValue && (
|
... | ... | @@ -187,14 +265,14 @@ |
| 187 | 265 |
)} |
| 188 | 266 |
</div> |
| 189 | 267 |
|
| 190 |
- {message && (
|
|
| 268 |
+ {shownMessage && (
|
|
| 191 | 269 |
<p className="fox-input__message" id={messageId}>
|
| 192 | 270 |
{messageIcon && (
|
| 193 | 271 |
<span className="fox-input__message-icon" aria-hidden="true"> |
| 194 | 272 |
{messageIcon}
|
| 195 | 273 |
</span> |
| 196 | 274 |
)} |
| 197 |
- {message}
|
|
| 275 |
+ {shownMessage}
|
|
| 198 | 276 |
</p> |
| 199 | 277 |
)} |
| 200 | 278 |
</div> |
+++ @fox/core/components/fox-step-indicator-item/fox-step-indicator-item.tsx
... | ... | @@ -0,0 +1,108 @@ |
| 1 | +import type { ReactNode, Ref } from "react"; | |
| 2 | +import { cx } from "../../utils"; | |
| 3 | + | |
| 4 | +/** 시안의 state. 지난 단계 · 지금 단계 · 아직 오지 않은 단계다. */ | |
| 5 | +export type FoxStepIndicatorItemState = "completion" | "ongoing" | "before"; | |
| 6 | + | |
| 7 | +export interface FoxStepIndicatorItemProps { | |
| 8 | + state?: FoxStepIndicatorItemState; | |
| 9 | + /** 단계 번호 줄(작고 흐린 글자). 넘기지 않으면 렌더링하지 않는다. */ | |
| 10 | + step?: ReactNode; | |
| 11 | + /** 단계 제목 줄. 넘기지 않으면 렌더링하지 않는다. */ | |
| 12 | + title?: ReactNode; | |
| 13 | + /** | |
| 14 | + * 동그라미 오른쪽으로 뻗는 선. **마지막 단계에서는 꺼야 한다** — 켜 두면 선이 묶음 밖으로 | |
| 15 | + * 삐져나간다. 선 색은 `state`가 정한다(지난 단계만 진하다). | |
| 16 | + */ | |
| 17 | + line?: boolean; | |
| 18 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 19 | + hidden?: boolean; | |
| 20 | + id?: string; | |
| 21 | + /** 배치 조정용. 시안의 폭(80px)을 바꿔야 할 때도 여기로 준다. */ | |
| 22 | + className?: string; | |
| 23 | + ref?: Ref<HTMLDivElement>; | |
| 24 | +} | |
| 25 | + | |
| 26 | +/** `Record`로 고정해 상태를 추가하면 항목 누락이 타입 에러가 되게 한다. */ | |
| 27 | +const STATE_CLASS: Record<FoxStepIndicatorItemState, string> = { | |
| 28 | + completion: "fox-step-indicator-item--completion", | |
| 29 | + ongoing: "fox-step-indicator-item--ongoing", | |
| 30 | + before: "fox-step-indicator-item--before", | |
| 31 | +}; | |
| 32 | + | |
| 33 | +/** | |
| 34 | + * @fox 단계 하나. 위에 동그라미와 선, 아래에 단계 번호와 제목이 놓인다. | |
| 35 | + * `FoxStepIndicator` 안에 여러 개를 늘어놓아 쓴다. | |
| 36 | + * | |
| 37 | + * 상태를 갖지 않는다 — 어디까지 왔는지는 호출부가 각 단계의 `state`로 정한다. | |
| 38 | + * | |
| 39 | + * **모바일에서는 글자가 사라지고 동그라미와 선만 남는다.** 그 판단은 CSS가 한다(화면 폭) — | |
| 40 | + * 기기를 prop으로 받지 않는다. 조건부 렌더로 하면 창 폭이 바뀔 때마다 DOM이 들락거리고, | |
| 41 | + * React를 쓰지 않는 소비자는 같은 결과를 얻지 못한다(`FoxChipSelectOption`의 확인 버튼과 | |
| 42 | + * 같은 근거). | |
| 43 | + * | |
| 44 | + * ⚠️ 진행 상태를 소리로도 전해야 하면 감싼 쪽이 알린다 — 이 조각의 동그라미·선은 장식이라 | |
| 45 | + * 보조기술에 아무것도 말하지 않고, 읽히는 것은 단계 번호와 제목뿐이다. 모바일에서는 그 | |
| 46 | + * 글자마저 사라지므로, 그 화면에서 순서를 전해야 하면 호출부가 이름을 따로 준다. | |
| 47 | + * | |
| 48 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 49 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 50 | + */ | |
| 51 | +export function FoxStepIndicatorItem({ | |
| 52 | + state = "before", | |
| 53 | + step, | |
| 54 | + title, | |
| 55 | + line = true, | |
| 56 | + hidden = false, | |
| 57 | + id, | |
| 58 | + className, | |
| 59 | + ref, | |
| 60 | +}: FoxStepIndicatorItemProps) { | |
| 61 | + if (hidden) { | |
| 62 | + return null; | |
| 63 | + } | |
| 64 | + | |
| 65 | + return ( | |
| 66 | + <div | |
| 67 | + ref={ref} | |
| 68 | + id={id} | |
| 69 | + className={cx("fox-step-indicator-item", STATE_CLASS[state], className)} | |
| 70 | + > | |
| 71 | + <div className="fox-step-indicator-item__indicator" aria-hidden="true"> | |
| 72 | + <span className="fox-step-indicator-item__mark"> | |
| 73 | + {state === "completion" ? <CheckMark /> : null} | |
| 74 | + {state === "ongoing" ? <span className="fox-step-indicator-item__dot" /> : null} | |
| 75 | + </span> | |
| 76 | + {line ? <span className="fox-step-indicator-item__line" /> : null} | |
| 77 | + </div> | |
| 78 | + | |
| 79 | + {step === undefined && title === undefined ? null : ( | |
| 80 | + <div className="fox-step-indicator-item__text"> | |
| 81 | + {step === undefined ? null : ( | |
| 82 | + <span className="fox-step-indicator-item__step">{step}</span> | |
| 83 | + )} | |
| 84 | + {title === undefined ? null : ( | |
| 85 | + <span className="fox-step-indicator-item__title">{title}</span> | |
| 86 | + )} | |
| 87 | + </div> | |
| 88 | + )} | |
| 89 | + </div> | |
| 90 | + ); | |
| 91 | +} | |
| 92 | + | |
| 93 | +// 지난 단계의 체크 표시. `@fox/core/icons`(Phosphor)를 쓰지 않는다 — 시안이 준 것은 선으로 | |
| 94 | +// 그린 10×8 글리프이고, Phosphor의 Check는 면으로 채운 다른 그림이라 크기·굵기가 맞지 않는다. | |
| 95 | +// `stroke`를 시안의 리터럴(흰색) 대신 `currentColor`로 두면 색을 상태 규칙이 정한다. | |
| 96 | +function CheckMark() { | |
| 97 | + return ( | |
| 98 | + <svg viewBox="0 0 12 10" fill="none" aria-hidden="true"> | |
| 99 | + <path | |
| 100 | + d="M0.75 4.75006L4.96726 8.75006L10.75 0.750061" | |
| 101 | + stroke="currentColor" | |
| 102 | + strokeWidth="1.5" | |
| 103 | + strokeLinecap="round" | |
| 104 | + strokeLinejoin="round" | |
| 105 | + /> | |
| 106 | + </svg> | |
| 107 | + ); | |
| 108 | +} |
+++ @fox/core/components/fox-step-indicator-item/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export { FoxStepIndicatorItem, type FoxStepIndicatorItemProps, type FoxStepIndicatorItemState } from "./fox-step-indicator-item"; |
+++ @fox/core/components/fox-step-indicator/fox-step-indicator.tsx
... | ... | @@ -0,0 +1,56 @@ |
| 1 | +import type { ReactNode, Ref } from "react"; | |
| 2 | +import { cx } from "../../utils"; | |
| 3 | + | |
| 4 | +export interface FoxStepIndicatorProps { | |
| 5 | + /** 묶음을 읽어 줄 이름. 화면에 보이는 제목이 이미 있으면 `labelledBy`를 쓴다. */ | |
| 6 | + label?: string; | |
| 7 | + /** 묶음 이름 역할을 하는 요소의 id. `label`보다 우선한다 — 화면의 글자와 어긋나지 않는다. */ | |
| 8 | + labelledBy?: string; | |
| 9 | + /** `FoxStepIndicatorItem`들. */ | |
| 10 | + children?: ReactNode; | |
| 11 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 12 | + hidden?: boolean; | |
| 13 | + /** 배치 조정용. 시안의 폭(400px = 80px × 5단계)을 바꿔야 할 때도 여기로 준다. */ | |
| 14 | + className?: string; | |
| 15 | + ref?: Ref<HTMLDivElement>; | |
| 16 | +} | |
| 17 | + | |
| 18 | +/** | |
| 19 | + * @fox 단계 묶음. `FoxStepIndicatorItem`들을 가로로 늘어놓는다. 배치만 책임지고 상태를 갖지 않는다 — | |
| 20 | + * 어디까지 왔는지는 각 단계의 `state`가 말한다. | |
| 21 | + * | |
| 22 | + * 자식에게 덮어쓰는 값이 없다(`FoxTagList`·`FoxChipArea`와 다른 점이다). 단계마다 `state`가 | |
| 23 | + * 다른 것이 정상이고, **마지막 단계는 `line={false}`로 선을 꺼야 한다** — 묶음이 자식의 | |
| 24 | + * 순서를 알고 대신 꺼 주지 않는 것은, 자식을 조건부로 렌더하거나 프래그먼트로 감싸는 | |
| 25 | + * 호출부에서 "마지막"의 판단이 어긋나기 때문이다. | |
| 26 | + * | |
| 27 | + * `role="list"`를 쓰지 않는다. 목록이 아니라 하나의 진행 상황이고, 읽히는 것은 각 단계의 | |
| 28 | + * 글자다 — 진행률을 소리로 전해야 하면 호출부가 `label`에 담는다("전체 5단계 중 2단계" 등). | |
| 29 | + * | |
| 30 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 31 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 32 | + */ | |
| 33 | +export function FoxStepIndicator({ | |
| 34 | + label, | |
| 35 | + labelledBy, | |
| 36 | + children, | |
| 37 | + hidden = false, | |
| 38 | + className, | |
| 39 | + ref, | |
| 40 | +}: FoxStepIndicatorProps) { | |
| 41 | + if (hidden) { | |
| 42 | + return null; | |
| 43 | + } | |
| 44 | + | |
| 45 | + return ( | |
| 46 | + <div | |
| 47 | + ref={ref} | |
| 48 | + role="group" | |
| 49 | + aria-label={labelledBy ? undefined : label} | |
| 50 | + aria-labelledby={labelledBy} | |
| 51 | + className={cx("fox-step-indicator", className)} | |
| 52 | + > | |
| 53 | + {children} | |
| 54 | + </div> | |
| 55 | + ); | |
| 56 | +} |
+++ @fox/core/components/fox-step-indicator/index.ts
... | ... | @@ -0,0 +1,4 @@ |
| 1 | +export { | |
| 2 | + FoxStepIndicator, | |
| 3 | + type FoxStepIndicatorProps, | |
| 4 | +} from "./fox-step-indicator"; |
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
... | ... | @@ -48,6 +48,8 @@ |
| 48 | 48 |
export * from "./fox-snackbar-list"; |
| 49 | 49 |
export * from "./fox-spinner"; |
| 50 | 50 |
export * from "./fox-status-indicator"; |
| 51 |
+export * from "./fox-step-indicator-item"; |
|
| 52 |
+export * from "./fox-step-indicator"; |
|
| 51 | 53 |
export * from "./fox-tab"; |
| 52 | 54 |
export * from "./fox-tab-item"; |
| 53 | 55 |
export * from "./fox-table"; |
+++ @fox/core/validation/fox-validation-messages.ts
... | ... | @@ -0,0 +1,97 @@ |
| 1 | +/** | |
| 2 | + * 오류 객체 → 화면 문구. | |
| 3 | + * | |
| 4 | + * 검증기와 분리한 이유는 Angular가 `Validators`에 문구를 두지 않은 이유와 같다 — 같은 규칙도 | |
| 5 | + * 자리에 따라 다른 말로 안내해야 한다("10자 이상"과 "비밀번호는 10자 이상"). 검증기는 무엇이 | |
| 6 | + * 틀렸는지만 말하고, 그것을 무슨 말로 옮길지는 여기서(또는 호출부의 `overrides`로) 정한다. | |
| 7 | + * | |
| 8 | + * 한 번에 **한 문구만** 낸다 — 입력 칸 아래 헬퍼 자리가 한 줄이고, 오류를 쌓아 보여주면 어느 | |
| 9 | + * 것부터 고쳐야 할지 알기 어렵다. 순서는 `MESSAGE_ORDER`가 정한다(비어 있음 → 길이 → 조합). | |
| 10 | + */ | |
| 11 | + | |
| 12 | +import type { FoxValidationErrors } from './fox-validators'; | |
| 13 | + | |
| 14 | +/** 오류 키별 기본 문구. 값이 필요한 것은 오류 객체의 맥락을 받아 만든다. */ | |
| 15 | +type MessageFactory = (detail: unknown) => string; | |
| 16 | + | |
| 17 | +function detailOf<T>(detail: unknown): T { | |
| 18 | + return detail as T; | |
| 19 | +} | |
| 20 | + | |
| 21 | +const DEFAULT_MESSAGES: Record<string, MessageFactory> = { | |
| 22 | + required: () => '필수 입력 항목입니다.', | |
| 23 | + minlength: (detail) => | |
| 24 | + `${detailOf<{ requiredLength: number }>(detail).requiredLength}자 이상 입력해 주세요.`, | |
| 25 | + maxlength: (detail) => | |
| 26 | + `${detailOf<{ requiredLength: number }>(detail).requiredLength}자 이내로 입력해 주세요.`, | |
| 27 | + pattern: () => '형식이 올바르지 않습니다.', | |
| 28 | + email: () => '이메일 형식이 올바르지 않습니다.', | |
| 29 | + characterKinds: (detail) => | |
| 30 | + `영문, 숫자, 특수문자 중 ${ | |
| 31 | + detailOf<{ requiredKinds: number }>(detail).requiredKinds | |
| 32 | + }종류 이상을 섞어 주세요.`, | |
| 33 | + repeatedCharacters: (detail) => | |
| 34 | + `같은 문자를 ${detailOf<{ max: number }>(detail).max}번까지만 연달아 쓸 수 있습니다.`, | |
| 35 | + sequentialCharacters: (detail) => | |
| 36 | + `연속된 문자나 숫자를 ${ | |
| 37 | + detailOf<{ max: number }>(detail).max | |
| 38 | + }자를 넘겨 쓸 수 없습니다.`, | |
| 39 | + notContaining: (detail) => | |
| 40 | + `${detailOf<{ forbidden: string }>(detail).forbidden}은(는) 포함할 수 없습니다.`, | |
| 41 | + equalTo: () => '값이 일치하지 않습니다.', | |
| 42 | +}; | |
| 43 | + | |
| 44 | +/** 먼저 고쳐야 하는 것부터. 여기 없는 키는 뒤에 남은 순서대로 본다. */ | |
| 45 | +const MESSAGE_ORDER = [ | |
| 46 | + 'required', | |
| 47 | + 'minlength', | |
| 48 | + 'maxlength', | |
| 49 | + 'pattern', | |
| 50 | + 'email', | |
| 51 | + 'characterKinds', | |
| 52 | + 'repeatedCharacters', | |
| 53 | + 'sequentialCharacters', | |
| 54 | + 'notContaining', | |
| 55 | + 'equalTo', | |
| 56 | +]; | |
| 57 | + | |
| 58 | +/** 오류 키 → 문구 덮어쓰기. 문자열이면 그대로, 함수면 맥락을 받아 만든다. */ | |
| 59 | +export type FoxValidationMessages = Record< | |
| 60 | + string, | |
| 61 | + string | ((detail: unknown) => string) | |
| 62 | +>; | |
| 63 | + | |
| 64 | +/** | |
| 65 | + * 오류 객체에서 보여줄 문구 하나를 고른다. 오류가 없으면 `undefined`. | |
| 66 | + * 덮어쓰기에 없는 키는 기본 문구로 떨어지고, 기본 문구에도 없으면 키를 그대로 쓰지 않고 | |
| 67 | + * `undefined`를 돌려준다 — 정체불명의 영문 키를 사용자에게 보이지 않기 위해서다. | |
| 68 | + */ | |
| 69 | +export function foxValidationMessage( | |
| 70 | + errors: FoxValidationErrors | null, | |
| 71 | + overrides: FoxValidationMessages = {} | |
| 72 | +): string | undefined { | |
| 73 | + if (!errors) { | |
| 74 | + return undefined; | |
| 75 | + } | |
| 76 | + | |
| 77 | + const keys = Object.keys(errors); | |
| 78 | + const ordered = [ | |
| 79 | + ...MESSAGE_ORDER.filter((key) => keys.includes(key)), | |
| 80 | + ...keys.filter((key) => !MESSAGE_ORDER.includes(key)), | |
| 81 | + ]; | |
| 82 | + | |
| 83 | + for (const key of ordered) { | |
| 84 | + const override = overrides[key]; | |
| 85 | + if (typeof override === 'string') { | |
| 86 | + return override; | |
| 87 | + } | |
| 88 | + if (typeof override === 'function') { | |
| 89 | + return override(errors[key]); | |
| 90 | + } | |
| 91 | + const fallback = DEFAULT_MESSAGES[key]; | |
| 92 | + if (fallback) { | |
| 93 | + return fallback(errors[key]); | |
| 94 | + } | |
| 95 | + } | |
| 96 | + return undefined; | |
| 97 | +} |
+++ @fox/core/validation/fox-validators.ts
... | ... | @@ -0,0 +1,235 @@ |
| 1 | +/** | |
| 2 | + * @fox 입력 검증기 — Angular `Validators`의 계약을 그대로 옮겼다. | |
| 3 | + * | |
| 4 | + * 검증기는 **값을 받아 통과면 `null`, 아니면 오류 객체**를 돌려주는 순수 함수다. 오류 객체의 | |
| 5 | + * 키가 곧 오류의 이름이고(`{ minlength: … }`), 값에는 문구를 만드는 데 필요한 맥락을 담는다 | |
| 6 | + * (`{ requiredLength, actualLength }`). Angular가 이 모양을 쓰는 이유가 여기서도 그대로다 — | |
| 7 | + * **문구를 검증기가 정하지 않기 때문에** 같은 규칙을 화면마다 다른 말로 안내할 수 있다. | |
| 8 | + * 기본 문구는 `fox-validation-messages.ts`가 따로 갖는다. | |
| 9 | + * | |
| 10 | + * Angular와 다른 점은 하나다: 인자가 `AbstractControl`이 아니라 문자열이다. @fox에는 폼 모델이 | |
| 11 | + * 없고 검증 대상이 입력 칸의 값 하나뿐이라, 컨트롤 객체를 두면 감싸는 비용만 늘어난다. | |
| 12 | + * | |
| 13 | + * `required`를 뺀 모든 검증기는 **빈 값을 통과시킨다**(Angular와 같다) — "비어 있음"의 판정은 | |
| 14 | + * `required` 하나가 맡아야 빈 칸에 오류가 두 개씩 뜨지 않는다. | |
| 15 | + */ | |
| 16 | + | |
| 17 | +export type FoxValidationErrors = Record<string, unknown>; | |
| 18 | + | |
| 19 | +/** 통과면 `null`, 아니면 오류 객체. */ | |
| 20 | +export type FoxValidator = (value: string) => FoxValidationErrors | null; | |
| 21 | + | |
| 22 | +function isEmpty(value: string): boolean { | |
| 23 | + return value.trim().length === 0; | |
| 24 | +} | |
| 25 | + | |
| 26 | +/** 값이 비어 있으면 `{ required: true }`. */ | |
| 27 | +function required(value: string): FoxValidationErrors | null { | |
| 28 | + return isEmpty(value) ? { required: true } : null; | |
| 29 | +} | |
| 30 | + | |
| 31 | +function minLength(requiredLength: number): FoxValidator { | |
| 32 | + return (value) => { | |
| 33 | + if (isEmpty(value) || value.length >= requiredLength) { | |
| 34 | + return null; | |
| 35 | + } | |
| 36 | + return { minlength: { requiredLength, actualLength: value.length } }; | |
| 37 | + }; | |
| 38 | +} | |
| 39 | + | |
| 40 | +function maxLength(requiredLength: number): FoxValidator { | |
| 41 | + return (value) => { | |
| 42 | + if (value.length <= requiredLength) { | |
| 43 | + return null; | |
| 44 | + } | |
| 45 | + return { maxlength: { requiredLength, actualLength: value.length } }; | |
| 46 | + }; | |
| 47 | +} | |
| 48 | + | |
| 49 | +function pattern(requiredPattern: RegExp): FoxValidator { | |
| 50 | + return (value) => { | |
| 51 | + if (isEmpty(value) || requiredPattern.test(value)) { | |
| 52 | + return null; | |
| 53 | + } | |
| 54 | + return { pattern: { requiredPattern: requiredPattern.source, actualValue: value } }; | |
| 55 | + }; | |
| 56 | +} | |
| 57 | + | |
| 58 | +/** 오탈자를 걸러내는 수준만 본다 — 이메일의 완전한 문법은 정규식으로 판정할 수 없다. */ | |
| 59 | +function email(value: string): FoxValidationErrors | null { | |
| 60 | + if (isEmpty(value) || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { | |
| 61 | + return null; | |
| 62 | + } | |
| 63 | + return { email: true }; | |
| 64 | +} | |
| 65 | + | |
| 66 | +/** 비밀번호 조합에 쓰는 문자 종류. 대문자는 소문자와 한 종류로 센다(시안이 그렇게 묻는다). */ | |
| 67 | +const CHARACTER_KIND_PATTERNS = { | |
| 68 | + letter: /[a-zA-Z]/, | |
| 69 | + digit: /\d/, | |
| 70 | + symbol: /[^a-zA-Z0-9]/, | |
| 71 | +} as const; | |
| 72 | + | |
| 73 | +export type FoxCharacterKind = keyof typeof CHARACTER_KIND_PATTERNS; | |
| 74 | + | |
| 75 | +/** 영문·숫자·특수문자 중 몇 종류가 섞였는지 센다. */ | |
| 76 | +function countCharacterKinds(value: string): FoxCharacterKind[] { | |
| 77 | + return (Object.keys(CHARACTER_KIND_PATTERNS) as FoxCharacterKind[]).filter( | |
| 78 | + (kind) => CHARACTER_KIND_PATTERNS[kind].test(value) | |
| 79 | + ); | |
| 80 | +} | |
| 81 | + | |
| 82 | +/** "영문·숫자·특수문자 중 N종류 이상" — 비밀번호 규칙의 가장 흔한 형태다. */ | |
| 83 | +function characterKinds(requiredKinds: number): FoxValidator { | |
| 84 | + return (value) => { | |
| 85 | + if (isEmpty(value)) { | |
| 86 | + return null; | |
| 87 | + } | |
| 88 | + const kinds = countCharacterKinds(value); | |
| 89 | + if (kinds.length >= requiredKinds) { | |
| 90 | + return null; | |
| 91 | + } | |
| 92 | + return { characterKinds: { requiredKinds, actualKinds: kinds.length, kinds } }; | |
| 93 | + }; | |
| 94 | +} | |
| 95 | + | |
| 96 | +/** 같은 문자가 `max`회를 넘겨 연달아 오면 오류(`aaa`). */ | |
| 97 | +function noRepeatedCharacters(max: number): FoxValidator { | |
| 98 | + return (value) => { | |
| 99 | + if (isEmpty(value)) { | |
| 100 | + return null; | |
| 101 | + } | |
| 102 | + let run = 1; | |
| 103 | + for (let i = 1; i < value.length; i += 1) { | |
| 104 | + run = value[i] === value[i - 1] ? run + 1 : 1; | |
| 105 | + if (run > max) { | |
| 106 | + return { repeatedCharacters: { max, character: value[i] } }; | |
| 107 | + } | |
| 108 | + } | |
| 109 | + return null; | |
| 110 | + }; | |
| 111 | +} | |
| 112 | + | |
| 113 | +/** 코드값이 잇따르는 문자가 `max`회를 넘기면 오류(`abcd`·`4321`). 오름·내림 둘 다 본다. */ | |
| 114 | +function noSequentialCharacters(max: number): FoxValidator { | |
| 115 | + return (value) => { | |
| 116 | + if (isEmpty(value)) { | |
| 117 | + return null; | |
| 118 | + } | |
| 119 | + let ascending = 1; | |
| 120 | + let descending = 1; | |
| 121 | + for (let i = 1; i < value.length; i += 1) { | |
| 122 | + const step = value.charCodeAt(i) - value.charCodeAt(i - 1); | |
| 123 | + ascending = step === 1 ? ascending + 1 : 1; | |
| 124 | + descending = step === -1 ? descending + 1 : 1; | |
| 125 | + if (ascending > max || descending > max) { | |
| 126 | + return { sequentialCharacters: { max, at: i - max } }; | |
| 127 | + } | |
| 128 | + } | |
| 129 | + return null; | |
| 130 | + }; | |
| 131 | +} | |
| 132 | + | |
| 133 | +/** | |
| 134 | + * 지정한 문자열을 품고 있으면 오류. 비밀번호에 ID·이름을 넣지 못하게 하는 규칙이다. | |
| 135 | + * | |
| 136 | + * 값을 함수로도 받는 이유는 그 대상이 **다른 칸의 지금 값**이기 때문이다 — 배열로 굳혀 두면 | |
| 137 | + * ID를 고친 뒤에도 옛 ID로 검사한다. | |
| 138 | + */ | |
| 139 | +function notContaining( | |
| 140 | + forbidden: string[] | (() => string[]), | |
| 141 | + options: { caseSensitive?: boolean } = {} | |
| 142 | +): FoxValidator { | |
| 143 | + return (value) => { | |
| 144 | + if (isEmpty(value)) { | |
| 145 | + return null; | |
| 146 | + } | |
| 147 | + const list = typeof forbidden === 'function' ? forbidden() : forbidden; | |
| 148 | + const haystack = options.caseSensitive ? value : value.toLowerCase(); | |
| 149 | + | |
| 150 | + for (const raw of list) { | |
| 151 | + const needle = options.caseSensitive ? raw : raw.toLowerCase(); | |
| 152 | + if (needle.length > 0 && haystack.includes(needle)) { | |
| 153 | + return { notContaining: { forbidden: raw } }; | |
| 154 | + } | |
| 155 | + } | |
| 156 | + return null; | |
| 157 | + }; | |
| 158 | +} | |
| 159 | + | |
| 160 | +/** 다른 칸과 값이 같아야 한다(비밀번호 확인). 대상이 바뀌므로 함수로 받는다. */ | |
| 161 | +function equalTo(other: () => string): FoxValidator { | |
| 162 | + return (value) => (isEmpty(value) || value === other() ? null : { equalTo: true }); | |
| 163 | +} | |
| 164 | + | |
| 165 | +/** | |
| 166 | + * 여러 검증기를 하나로 합친다. **통과하지 못한 것들의 오류 객체를 전부 병합해** 돌려준다 | |
| 167 | + * (Angular의 `Validators.compose`와 같다) — 첫 오류에서 멈추지 않아야 "10자 이상"과 | |
| 168 | + * "2종류 이상"을 함께 안내할 수 있다. | |
| 169 | + */ | |
| 170 | +function compose(validators: FoxValidator[]): FoxValidator { | |
| 171 | + return (value) => { | |
| 172 | + const merged = validators.reduce<FoxValidationErrors>((acc, validate) => { | |
| 173 | + const errors = validate(value); | |
| 174 | + return errors ? { ...acc, ...errors } : acc; | |
| 175 | + }, {}); | |
| 176 | + return Object.keys(merged).length > 0 ? merged : null; | |
| 177 | + }; | |
| 178 | +} | |
| 179 | + | |
| 180 | +export const foxValidators = { | |
| 181 | + required, | |
| 182 | + minLength, | |
| 183 | + maxLength, | |
| 184 | + pattern, | |
| 185 | + email, | |
| 186 | + characterKinds, | |
| 187 | + noRepeatedCharacters, | |
| 188 | + noSequentialCharacters, | |
| 189 | + notContaining, | |
| 190 | + equalTo, | |
| 191 | + compose, | |
| 192 | +}; | |
| 193 | + | |
| 194 | +export interface FoxPasswordPolicy { | |
| 195 | + /** 기본 8. 시안이 더 길게 요구하면 그 값을 준다. */ | |
| 196 | + minLength?: number; | |
| 197 | + maxLength?: number; | |
| 198 | + /** 영문·숫자·특수문자 중 최소 몇 종류를 섞을지. 기본 2. */ | |
| 199 | + kinds?: number; | |
| 200 | + /** 같은 문자 연속 허용 횟수. 주지 않으면 검사하지 않는다. */ | |
| 201 | + maxRepeated?: number; | |
| 202 | + /** 잇따르는 문자 허용 길이(`abc`). 주지 않으면 검사하지 않는다. */ | |
| 203 | + maxSequential?: number; | |
| 204 | + /** 비밀번호에 들어가면 안 되는 값(보통 ID·이름). 지금 값을 읽도록 함수로 줄 수 있다. */ | |
| 205 | + forbidden?: string[] | (() => string[]); | |
| 206 | + /** 참이면 비어 있는 것도 오류다. 수정 화면처럼 "비우면 유지"인 자리에서는 끈다. */ | |
| 207 | + required?: boolean; | |
| 208 | +} | |
| 209 | + | |
| 210 | +/** | |
| 211 | + * 비밀번호 칸의 규칙 한 벌. `type="password"` 입력에 그대로 얹으라고 둔 조합이다 — | |
| 212 | + * 규칙 자체는 위 검증기들이고, 이 함수는 자주 쓰는 묶음에 이름을 붙인 것뿐이다. | |
| 213 | + */ | |
| 214 | +export function foxPasswordValidator(policy: FoxPasswordPolicy = {}): FoxValidator { | |
| 215 | + const { | |
| 216 | + minLength: min = 8, | |
| 217 | + maxLength: max, | |
| 218 | + kinds = 2, | |
| 219 | + maxRepeated, | |
| 220 | + maxSequential, | |
| 221 | + forbidden, | |
| 222 | + required: isRequired = false, | |
| 223 | + } = policy; | |
| 224 | + | |
| 225 | + const validators: FoxValidator[] = []; | |
| 226 | + if (isRequired) validators.push(required); | |
| 227 | + validators.push(minLength(min)); | |
| 228 | + if (max !== undefined) validators.push(maxLength(max)); | |
| 229 | + validators.push(characterKinds(kinds)); | |
| 230 | + if (maxRepeated !== undefined) validators.push(noRepeatedCharacters(maxRepeated)); | |
| 231 | + if (maxSequential !== undefined) validators.push(noSequentialCharacters(maxSequential)); | |
| 232 | + if (forbidden !== undefined) validators.push(notContaining(forbidden)); | |
| 233 | + | |
| 234 | + return compose(validators); | |
| 235 | +} |
+++ @fox/core/validation/index.ts
... | ... | @@ -0,0 +1,12 @@ |
| 1 | +export { | |
| 2 | + foxValidators, | |
| 3 | + foxPasswordValidator, | |
| 4 | + type FoxValidator, | |
| 5 | + type FoxValidationErrors, | |
| 6 | + type FoxCharacterKind, | |
| 7 | + type FoxPasswordPolicy, | |
| 8 | +} from "./fox-validators"; | |
| 9 | +export { | |
| 10 | + foxValidationMessage, | |
| 11 | + type FoxValidationMessages, | |
| 12 | +} from "./fox-validation-messages"; |
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
... | ... | @@ -57,6 +57,8 @@ |
| 57 | 57 |
import { FoxSnackbar } from "../core/components/fox-snackbar";
|
| 58 | 58 |
import { FoxSnackbarList } from "../core/components/fox-snackbar-list";
|
| 59 | 59 |
import { FoxStatusIndicator } from "../core/components/fox-status-indicator";
|
| 60 |
+import { FoxStepIndicatorItem } from "../core/components/fox-step-indicator-item";
|
|
| 61 |
+import { FoxStepIndicator } from "../core/components/fox-step-indicator";
|
|
| 60 | 62 |
import { FoxChip, type FoxChipItem } from "../core/components/fox-chip";
|
| 61 | 63 |
import { FoxChipArea } from "../core/components/fox-chip-area";
|
| 62 | 64 |
import { FoxSelectText } from "../core/components/fox-select-text";
|
... | ... | @@ -3833,4 +3835,62 @@ |
| 3833 | 3835 |
}, |
| 3834 | 3836 |
], |
| 3835 | 3837 |
}, |
| 3838 |
+ {
|
|
| 3839 |
+ id: "fox-step-indicator-item", |
|
| 3840 |
+ name: "FoxStepIndicatorItem", |
|
| 3841 |
+ description: |
|
| 3842 |
+ "Figma 시안(step 251:1539) 단계 하나입니다. 위에 동그라미와 선, 아래에 단계 번호(body/xsm)와 제목(body/sm)이 놓입니다. state 세 가지가 동그라미 안과 선 색을 정합니다: completion은 회색으로 채우고 체크를 얹으며 선도 진합니다(여기까지 왔다는 표시), ongoing은 테두리만 강조색이고 가운데 점이 자리를 채우며 선은 흐립니다, before는 흐린 테두리에 연한 면이고 안이 비었습니다. 마지막 단계는 line={false}로 선을 꺼야 합니다 — 켜 두면 묶음 밖으로 삐져나갑니다. 동그라미와 선은 aria-hidden 장식이고 읽히는 것은 글자뿐입니다. 제목 굵기는 시안이 700인데 토큰 세트에 700이 없어(400 regular · 600 medium · 800 bold) 시스템의 bold(800)로 올렸습니다 — Pretendard 기준 Bold보다 한 단계 굵은 ExtraBold입니다. 너무 굵어 보이면 medium으로 내립니다.",
|
|
| 3843 |
+ variants: [ |
|
| 3844 |
+ {
|
|
| 3845 |
+ label: "상태 3종", |
|
| 3846 |
+ node: ( |
|
| 3847 |
+ <> |
|
| 3848 |
+ <FoxStepIndicatorItem state="completion" step="STEP 01" title="약관 동의" /> |
|
| 3849 |
+ <FoxStepIndicatorItem state="ongoing" step="STEP 02" title="정보 입력" /> |
|
| 3850 |
+ <FoxStepIndicatorItem state="before" step="STEP 03" title="가입 완료" /> |
|
| 3851 |
+ </> |
|
| 3852 |
+ ), |
|
| 3853 |
+ }, |
|
| 3854 |
+ {
|
|
| 3855 |
+ label: "글자 없이 — 동그라미와 선만 (모바일에서 자동으로 이 모양이 됩니다)", |
|
| 3856 |
+ node: ( |
|
| 3857 |
+ <> |
|
| 3858 |
+ <FoxStepIndicatorItem state="completion" /> |
|
| 3859 |
+ <FoxStepIndicatorItem state="ongoing" /> |
|
| 3860 |
+ <FoxStepIndicatorItem state="before" /> |
|
| 3861 |
+ </> |
|
| 3862 |
+ ), |
|
| 3863 |
+ }, |
|
| 3864 |
+ ], |
|
| 3865 |
+ }, |
|
| 3866 |
+ {
|
|
| 3867 |
+ id: "fox-step-indicator", |
|
| 3868 |
+ name: "FoxStepIndicator", |
|
| 3869 |
+ description: |
|
| 3870 |
+ "Figma 시안(step-indicator 708:1623) FoxStepIndicatorItem들을 가로로 늘어놓는 묶음입니다. 배치만 갖고 상태는 없습니다 — 어디까지 왔는지는 각 단계의 state가 말합니다. 자식에게 덮어쓰는 값이 없어서 마지막 단계의 line={false}는 호출부가 줍니다: 묶음이 '마지막'을 대신 판단하면 자식을 조건부로 렌더하거나 프래그먼트로 감싼 호출부에서 어긋납니다. role=list가 아니라 role=group입니다 — 목록이 아니라 하나의 진행 상황이고, 진행률을 소리로 전해야 하면 label에 담습니다. ⚠️ 폭 400px은 80px × 5단계라 단계가 다섯 개일 때의 값입니다. 개수가 다르면 className으로 덮습니다. 화면을 좁히면 글자가 사라지고 동그라미 줄만 남습니다.",
|
|
| 3871 |
+ variants: [ |
|
| 3872 |
+ {
|
|
| 3873 |
+ label: "5단계 — 시안 폭(400px)에 맞는 개수", |
|
| 3874 |
+ node: ( |
|
| 3875 |
+ <FoxStepIndicator label="가입 절차 (전체 5단계 중 3단계)"> |
|
| 3876 |
+ <FoxStepIndicatorItem state="completion" step="STEP 01" title="약관 동의" /> |
|
| 3877 |
+ <FoxStepIndicatorItem state="completion" step="STEP 02" title="본인 인증" /> |
|
| 3878 |
+ <FoxStepIndicatorItem state="ongoing" step="STEP 03" title="정보 입력" /> |
|
| 3879 |
+ <FoxStepIndicatorItem state="before" step="STEP 04" title="결제 수단" /> |
|
| 3880 |
+ <FoxStepIndicatorItem state="before" step="STEP 05" title="가입 완료" line={false} />
|
|
| 3881 |
+ </FoxStepIndicator> |
|
| 3882 |
+ ), |
|
| 3883 |
+ }, |
|
| 3884 |
+ {
|
|
| 3885 |
+ label: "3단계 — 폭이 고정이라 오른쪽이 남습니다 (className으로 덮는 자리)", |
|
| 3886 |
+ node: ( |
|
| 3887 |
+ <FoxStepIndicator label="결제 절차"> |
|
| 3888 |
+ <FoxStepIndicatorItem state="completion" step="STEP 01" title="장바구니" /> |
|
| 3889 |
+ <FoxStepIndicatorItem state="ongoing" step="STEP 02" title="결제" /> |
|
| 3890 |
+ <FoxStepIndicatorItem state="before" step="STEP 03" title="완료" line={false} />
|
|
| 3891 |
+ </FoxStepIndicator> |
|
| 3892 |
+ ), |
|
| 3893 |
+ }, |
|
| 3894 |
+ ], |
|
| 3895 |
+ }, |
|
| 3836 | 3896 |
]; |
--- @fox/styles/_fox-chip-area.scss
+++ @fox/styles/_fox-chip-area.scss
... | ... | @@ -15,8 +15,8 @@ |
| 15 | 15 |
// `align-items: center`가 필요한 이유는 칩의 크기(높이)가 섞일 수 있어서다. `FoxChipArea`의 |
| 16 | 16 |
// `size`로 통일하면 섞이지 않지만, 통일하지 않고 쓰는 것도 막지 않는다. |
| 17 | 17 |
// |
| 18 |
-// ⚠️ 줄바꿈이 없다(`flex-wrap`을 주지 않는다) — 시안이 한 줄만 그린다. 폭이 모자라면 칩이 |
|
| 19 |
-// 눌리는 게 아니라 넘친다. 칩이 여러 줄로 흘러야 하는 자리가 나오면 시안에 먼저 추가한다. |
|
| 18 |
+// 기본은 한 줄이다 — 폭이 모자라면 칩이 눌리는 게 아니라 넘친다. 여러 줄로 흘러야 하는 자리는 |
|
| 19 |
+// `--wrap`을 건다(시안 ADM_ADM_102_p의 "메뉴 선택"이 칩을 두 줄로 감는다). |
|
| 20 | 20 |
|
| 21 | 21 |
@use "@fox/styles/abstracts" as fox; |
| 22 | 22 |
|
... | ... | @@ -24,4 +24,8 @@ |
| 24 | 24 |
display: inline-flex; |
| 25 | 25 |
align-items: center; |
| 26 | 26 |
gap: fox.gap(2); |
| 27 |
+ |
|
| 28 |
+ &--wrap {
|
|
| 29 |
+ flex-wrap: wrap; |
|
| 30 |
+ } |
|
| 27 | 31 |
} |
+++ @fox/styles/_fox-step-indicator-item.scss
... | ... | @@ -0,0 +1,168 @@ |
| 1 | +// FoxStepIndicatorItem — 시안: 통합관리자페이지 디자인시스템 Figma step | |
| 2 | +// | |
| 3 | +// 단계 하나. 위에 동그라미와 선(인디케이터), 아래에 단계 번호와 제목이 놓인다. | |
| 4 | +// | |
| 5 | +// 마크업 계약 (React 밖 소비자용): | |
| 6 | +// <div class="fox-step-indicator-item fox-step-indicator-item--ongoing"> | |
| 7 | +// <div class="fox-step-indicator-item__indicator" aria-hidden="true"> | |
| 8 | +// <span class="fox-step-indicator-item__mark"><span class="fox-step-indicator-item__dot"></span></span> | |
| 9 | +// <span class="fox-step-indicator-item__line"></span> <!-- 마지막 단계는 뺀다 --> | |
| 10 | +// </div> | |
| 11 | +// <div class="fox-step-indicator-item__text"> <!-- 선택 --> | |
| 12 | +// <span class="fox-step-indicator-item__step">STEP 01</span> | |
| 13 | +// <span class="fox-step-indicator-item__title">약관 동의</span> | |
| 14 | +// </div> | |
| 15 | +// </div> | |
| 16 | +// | |
| 17 | +// 상태별로 동그라미 안이 다르다: completion은 체크 글리프, ongoing은 `__dot`, before는 비었다. | |
| 18 | +// 상태 클래스가 색·테두리를 정하므로 마크업은 그대로 두고 클래스만 바꾸면 된다. | |
| 19 | +// | |
| 20 | +// **모바일에서는 `__text`가 사라진다.** 시안의 device 변형을 CSS 브레이크포인트로 옮긴 | |
| 21 | +// 것이다 — 조건부 렌더로 하면 창 폭이 바뀔 때마다 DOM이 들락거리고, React를 쓰지 않는 | |
| 22 | +// 소비자는 같은 결과를 얻지 못한다(`fox-chip-select-option`의 확인 버튼과 같은 근거). | |
| 23 | +// | |
| 24 | +// 동그라미 안쪽 여백을 시안대로 적지 않는다. 시안은 `padding: 6px 5px`로 20px 안에 10×8 | |
| 25 | +// 글리프를 밀어 넣는데, 그 값은 **가운데 정렬을 여백으로 계산한 것**이라(20-5-5=10, | |
| 26 | +// 20-6-6=8) `justify-content`·`align-items`로 바꿔 적는다. 결과는 같고 5px·6px이라는 | |
| 27 | +// 토큰 없는 값이 사라진다. | |
| 28 | +// | |
| 29 | +// 토큰이 아닌 값: | |
| 30 | +// - ⚠️ `inline-size: 6.1rem`(선 61px): 시안 값 그대로다. 토큰 스케일에 61이 없고, 동그라미 | |
| 31 | +// (20px)와 합치면 81px이라 단계 폭(80px)보다 1px 길다 — 다음 동그라미까지 틈 없이 | |
| 32 | +// 닿게 하려고 겹치는 값으로 보인다. 단계 폭을 바꾸면 이 값도 함께 봐야 한다. | |
| 33 | +// - ⚠️ `block-size: 0.15rem`(선 굵기 1.5px) / `border-width: 0.15rem`(ongoing 테두리): | |
| 34 | +// 시안 값이나 border 토큰은 1px·2px·4px뿐이라 1.5px이 없다. 토큰이 생기면 바꾼다. | |
| 35 | +// - ⚠️ 체크 글리프의 `10px × 8px`: 시안이 준 비율(5:4)이 다른 아이콘과 달라 `icon()` 스케일에 | |
| 36 | +// 없다. `number()`의 같은 값(10px·8px)으로 지목한다. | |
| 37 | +// - box-sizing / display / flex / aspect-ratio: 구조 규칙. | |
| 38 | +// | |
| 39 | +// ⚠️ 단계 제목의 굵기: 시안 CSS가 `var(--font-weight-regular, 700)`으로 이름과 값이 | |
| 40 | +// 어긋나는데, **값(굵게)이 맞다**(사용자 확인). 700은 이 토큰 세트에 없어서 | |
| 41 | +// (400 regular · 600 medium · 800 bold) 시스템의 `bold`를 쓴다 — Pretendard 기준으로 시안이 | |
| 42 | +// Bold(700)라면 한 단계 더 굵은 ExtraBold(800)로 그려진다. 너무 굵어 보이면 `medium`으로 | |
| 43 | +// 내린다. | |
| 44 | + | |
| 45 | +@use "@fox/styles/abstracts" as fox; | |
| 46 | + | |
| 47 | +.fox-step-indicator-item { | |
| 48 | + box-sizing: border-box; | |
| 49 | + display: flex; | |
| 50 | + flex-direction: column; | |
| 51 | + align-items: flex-start; | |
| 52 | + gap: fox.gap(3); | |
| 53 | + inline-size: fox.number(20); | |
| 54 | + | |
| 55 | + // ── 인디케이터(동그라미 + 선) ─────────────────────────────────────────────── | |
| 56 | + &__indicator { | |
| 57 | + display: flex; | |
| 58 | + align-items: center; | |
| 59 | + } | |
| 60 | + | |
| 61 | + // 20px 정원. 안에 든 것(체크·점)은 상태가 정하고, 여기서는 자리만 만든다. | |
| 62 | + &__mark { | |
| 63 | + box-sizing: border-box; | |
| 64 | + display: flex; | |
| 65 | + flex-shrink: 0; | |
| 66 | + align-items: center; | |
| 67 | + justify-content: center; | |
| 68 | + inline-size: fox.number(9); | |
| 69 | + block-size: fox.number(9); | |
| 70 | + aspect-ratio: 1 / 1; | |
| 71 | + border-radius: fox.radius(max); | |
| 72 | + } | |
| 73 | + | |
| 74 | + // 체크 글리프. 크기는 슬롯이 정하고 SVG는 따라온다. | |
| 75 | + &__mark > svg { | |
| 76 | + inline-size: fox.number(6); | |
| 77 | + block-size: fox.number(5); | |
| 78 | + } | |
| 79 | + | |
| 80 | + // ongoing의 가운데 점. | |
| 81 | + &__dot { | |
| 82 | + inline-size: fox.number(7); | |
| 83 | + block-size: fox.number(7); | |
| 84 | + flex-shrink: 0; | |
| 85 | + aspect-ratio: 1 / 1; | |
| 86 | + border-radius: fox.radius(max); | |
| 87 | + background: fox.color(element-theme-primary); | |
| 88 | + } | |
| 89 | + | |
| 90 | + &__line { | |
| 91 | + flex-shrink: 0; | |
| 92 | + inline-size: 6.1rem; | |
| 93 | + block-size: 0.15rem; | |
| 94 | + } | |
| 95 | + | |
| 96 | + // ── 글자 ──────────────────────────────────────────────────────────────────── | |
| 97 | + &__text { | |
| 98 | + display: flex; | |
| 99 | + flex-direction: column; | |
| 100 | + align-items: flex-start; | |
| 101 | + align-self: stretch; | |
| 102 | + padding-inline-end: fox.padding(3); | |
| 103 | + | |
| 104 | + // 모바일에서는 동그라미와 선만 남는다(파일 상단 주석 참고). | |
| 105 | + @include fox.mobile { | |
| 106 | + display: none; | |
| 107 | + } | |
| 108 | + } | |
| 109 | + | |
| 110 | + &__step { | |
| 111 | + align-self: stretch; | |
| 112 | + color: fox.color(font-neutral-subtle); | |
| 113 | + font-family: fox.font-family(body); | |
| 114 | + font-size: fox.font-size(body-xsm); | |
| 115 | + font-weight: fox.font-weight(regular); | |
| 116 | + line-height: 1.5; | |
| 117 | + letter-spacing: -0.025em; | |
| 118 | + } | |
| 119 | + | |
| 120 | + &__title { | |
| 121 | + align-self: stretch; | |
| 122 | + color: fox.color(font-neutral-default); | |
| 123 | + font-family: fox.font-family(body); | |
| 124 | + font-size: fox.font-size(body-sm); | |
| 125 | + // 시안의 700은 토큰에 없어 시스템의 bold(800)로 올린다 — 파일 상단 주석 참고. | |
| 126 | + font-weight: fox.font-weight(bold); | |
| 127 | + line-height: 1.5; | |
| 128 | + letter-spacing: -0.025em; | |
| 129 | + } | |
| 130 | + | |
| 131 | + // ── 상태 ──────────────────────────────────────────────────────────────────── | |
| 132 | + // 지난 단계. 동그라미가 회색으로 채워지고 체크가 반전색으로 얹힌다. 선도 함께 진하다 — | |
| 133 | + // 여기까지 왔다는 표시다. | |
| 134 | + &--completion { | |
| 135 | + .fox-step-indicator-item__mark { | |
| 136 | + background: fox.color(element-neutral-default); | |
| 137 | + color: fox.color(element-neutral-inverse-static); | |
| 138 | + } | |
| 139 | + | |
| 140 | + .fox-step-indicator-item__line { | |
| 141 | + background: fox.color(element-neutral-default); | |
| 142 | + } | |
| 143 | + } | |
| 144 | + | |
| 145 | + // 지금 단계. 테두리만 강조색이고 안은 비어 있으며, 가운데 점이 그 자리를 채운다. | |
| 146 | + // 선은 앞으로 갈 길이라 흐리다. | |
| 147 | + &--ongoing { | |
| 148 | + .fox-step-indicator-item__mark { | |
| 149 | + border: 0.15rem solid fox.color(element-theme-primary); | |
| 150 | + } | |
| 151 | + | |
| 152 | + .fox-step-indicator-item__line { | |
| 153 | + background: fox.color(element-neutral-disabled); | |
| 154 | + } | |
| 155 | + } | |
| 156 | + | |
| 157 | + // 아직 오지 않은 단계. 흐린 테두리에 연한 면이고 안은 비었다. | |
| 158 | + &--before { | |
| 159 | + .fox-step-indicator-item__mark { | |
| 160 | + border: fox.border(1) solid fox.color(element-neutral-disabled); | |
| 161 | + background: fox.color(element-neutral-subtle); | |
| 162 | + } | |
| 163 | + | |
| 164 | + .fox-step-indicator-item__line { | |
| 165 | + background: fox.color(element-neutral-disabled); | |
| 166 | + } | |
| 167 | + } | |
| 168 | +} |
+++ @fox/styles/_fox-step-indicator.scss
... | ... | @@ -0,0 +1,34 @@ |
| 1 | +// FoxStepIndicator — 시안: 통합관리자페이지 디자인시스템 Figma step-indicator | |
| 2 | +// | |
| 3 | +// `fox-step-indicator-item`들을 가로로 늘어놓는 묶음이다. 배치만 갖는다 — 어디까지 왔는지는 각 단계가 말한다. | |
| 4 | +// | |
| 5 | +// 마크업 계약 (React 밖 소비자용): | |
| 6 | +// <div class="fox-step-indicator" role="group" aria-label="가입 절차"> | |
| 7 | +// …fox-step-indicator-item (completion)… | |
| 8 | +// …fox-step-indicator-item (ongoing)… | |
| 9 | +// …fox-step-indicator-item (before, 마지막이면 선 없이)… | |
| 10 | +// </div> | |
| 11 | +// | |
| 12 | +// 토큰이 아닌 값: | |
| 13 | +// - ⚠️ `inline-size: 40rem`(400px): 시안 값 그대로다. 80px × 5단계라 **단계가 다섯 개일 때의 | |
| 14 | +// 폭**이고, 개수가 다르면 맞지 않는다. 토큰 스케일에도 400이 없다. 단계 수가 다른 화면은 | |
| 15 | +// 호출부가 `className`으로 덮는다 — 시안에서 폭 규칙을 받으면 그 값으로 바꾼다. | |
| 16 | +// - display / align-items: 구조 규칙. | |
| 17 | +// | |
| 18 | +// 세로 정렬이 `center`라 글자가 있는 화면과 없는 화면(모바일)에서 동그라미 줄이 같은 높이에 | |
| 19 | +// 놓인다. | |
| 20 | + | |
| 21 | +@use "@fox/styles/abstracts" as fox; | |
| 22 | + | |
| 23 | +.fox-step-indicator { | |
| 24 | + box-sizing: border-box; | |
| 25 | + display: flex; | |
| 26 | + align-items: center; | |
| 27 | + inline-size: 40rem; | |
| 28 | + | |
| 29 | + // 모바일에서는 글자가 사라져 폭이 남는다 — 고정 폭을 풀어 화면에 맞춘다 | |
| 30 | + // (`fox-step-indicator-item`의 `__text`가 숨는 것과 짝이다). | |
| 31 | + @include fox.mobile { | |
| 32 | + inline-size: 100%; | |
| 33 | + } | |
| 34 | +} |
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
... | ... | @@ -56,6 +56,8 @@ |
| 56 | 56 |
@use "fox-snackbar-list"; |
| 57 | 57 |
@use "fox-spinner"; |
| 58 | 58 |
@use "fox-status-indicator"; |
| 59 |
+@use "fox-step-indicator-item"; |
|
| 60 |
+@use "fox-step-indicator"; |
|
| 59 | 61 |
@use "fox-toast"; |
| 60 | 62 |
@use "fox-toggle-quantity"; |
| 61 | 63 |
@use "fox-page-header"; |
--- 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, |
... | ... | @@ -41,6 +41,8 @@ |
| 41 | 41 |
const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.'; |
| 42 | 42 |
const SELF_DELETE_MESSAGE = '현재 로그인한 본인 계정은 삭제할 수 없습니다.'; |
| 43 | 43 |
const DUPLICATE_LOGIN_ID_MESSAGE = '이미 사용 중인 ID입니다.'; |
| 44 |
+const LOGIN_ID_CHECK_FAILED_MESSAGE = |
|
| 45 |
+ '중복 확인에 실패했습니다. 잠시 후 다시 시도해 주세요.'; |
|
| 44 | 46 |
|
| 45 | 47 |
function readString(formData: FormData, key: string): string {
|
| 46 | 48 |
const value = formData.get(key); |
... | ... | @@ -58,15 +60,11 @@ |
| 58 | 60 |
} |
| 59 | 61 |
|
| 60 | 62 |
/** |
| 61 |
- * 등록·수정이 공유하는 입력 항목을 읽는다. 휴대전화번호는 시안대로 3칸으로 나뉘어 오므로 여기서 |
|
| 62 |
- * 하나의 값으로 합친다 — 형식이 어긋나면 빈 문자열이 되어 검증에서 걸린다. |
|
| 63 |
+ * 등록·수정이 공유하는 입력 항목을 읽는다. 휴대전화번호는 숫자만 담겨 오므로 여기서 저장 형식으로 |
|
| 64 |
+ * 바꾼다 — 자릿수가 어긋나면 빈 문자열이 되어 검증에서 걸린다. |
|
| 63 | 65 |
*/ |
| 64 | 66 |
function readEditableValues(formData: FormData): AdminMemberEditableValues {
|
| 65 |
- const phoneNumber = joinPhoneNumber([ |
|
| 66 |
- readString(formData, 'phoneNumber1'), |
|
| 67 |
- readString(formData, 'phoneNumber2'), |
|
| 68 |
- readString(formData, 'phoneNumber3'), |
|
| 69 |
- ]); |
|
| 67 |
+ const phoneNumber = formatPhoneNumber(readString(formData, 'phoneNumber')); |
|
| 70 | 68 |
|
| 71 | 69 |
return {
|
| 72 | 70 |
password: readString(formData, 'password'), |
... | ... | @@ -205,7 +203,10 @@ |
| 205 | 203 |
export type LoginIdCheckResult = |
| 206 | 204 |
| { status: 'idle' }
|
| 207 | 205 |
| { status: 'available'; loginId: string }
|
| 208 |
- | { status: 'unavailable'; message: string };
|
|
| 206 |
+ /** 쓸 수 없는 ID — 형식 오류이거나 이미 선점됐다. */ |
|
| 207 |
+ | { status: 'unavailable'; message: string }
|
|
| 208 |
+ /** 확인 자체를 못 했다(통신·권한 등). 값의 판정이 아니라 **확인 실패**다. */ |
|
| 209 |
+ | { status: 'failed'; message: string };
|
|
| 209 | 210 |
|
| 210 | 211 |
/** |
| 211 | 212 |
* 시안 ADM_ADM_102_p ① — ID 중복 확인. |
... | ... | @@ -230,8 +231,17 @@ |
| 230 | 231 |
return { status: 'unavailable', message: formatError };
|
| 231 | 232 |
} |
| 232 | 233 |
|
| 233 |
- if (await isAdminLoginIdTaken(normalized)) {
|
|
| 234 |
- return { status: 'unavailable', message: DUPLICATE_LOGIN_ID_MESSAGE };
|
|
| 234 |
+ // 확인 호출이 실패하면 그 사유를 화면에 그대로 돌려준다 — 예외로 두면 클라이언트의 |
|
| 235 |
+ // transition에서 처리되지 않은 rejection이 되어 버튼만 원복되고 아무 말도 남지 않는다. |
|
| 236 |
+ try {
|
|
| 237 |
+ if (await isAdminLoginIdTaken(normalized)) {
|
|
| 238 |
+ return { status: 'unavailable', message: DUPLICATE_LOGIN_ID_MESSAGE };
|
|
| 239 |
+ } |
|
| 240 |
+ } catch (error) {
|
|
| 241 |
+ if (error instanceof BackendRequestError) {
|
|
| 242 |
+ return { status: 'failed', message: error.message };
|
|
| 243 |
+ } |
|
| 244 |
+ return { status: 'failed', message: LOGIN_ID_CHECK_FAILED_MESSAGE };
|
|
| 235 | 245 |
} |
| 236 | 246 |
|
| 237 | 247 |
return { status: 'available', loginId: normalized };
|
--- app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx
... | ... | @@ -1,34 +1,62 @@ |
| 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';
|
|
| 8 |
-import { useFeedback } from '@/app/_hooks/use-feedback';
|
|
| 9 | 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 {
|
|
| 17 |
+ foxPasswordValidator, |
|
| 18 |
+ foxValidationMessage, |
|
| 19 |
+ foxValidators, |
|
| 20 |
+} from '@fox/core/validation'; |
|
| 21 |
+import { FoxChatCenteredDotsIcon } from '@fox/core/icons';
|
|
| 22 |
+import { useFeedback } from '@/app/_hooks/use-feedback';
|
|
| 23 |
+import { DEFAULT_ADMIN_ROLE_CODE } from '@/lib/domain/admin-member';
|
|
| 24 |
+import {
|
|
| 25 |
+ ADMIN_EMAIL_MESSAGES, |
|
| 10 | 26 |
ADMIN_LOGIN_ID_HELP_TEXT, |
| 27 |
+ ADMIN_LOGIN_ID_MESSAGES, |
|
| 28 |
+ ADMIN_LOGIN_ID_VALIDATORS, |
|
| 11 | 29 |
ADMIN_PASSWORD_HELP_TEXT, |
| 30 |
+ ADMIN_PASSWORD_POLICY, |
|
| 12 | 31 |
INITIAL_ADMIN_MEMBER_FORM_STATE, |
| 32 |
+ adminEmailValidators, |
|
| 13 | 33 |
} from '@/lib/domain/admin-member-form'; |
| 14 | 34 |
import {
|
| 15 | 35 |
checkAdminLoginId, |
| 16 | 36 |
createAdminMemberAction, |
| 17 | 37 |
type LoginIdCheckResult, |
| 18 | 38 |
} from '../_actions'; |
| 19 |
-import {
|
|
| 20 |
- AdminMemberFormFields, |
|
| 21 |
- FieldError, |
|
| 22 |
-} from './admin-member-form-fields'; |
|
| 39 |
+import { AdminMemberFormFields } from './admin-member-form-fields';
|
|
| 40 |
+import styles from './admin-member-modal.module.scss'; |
|
| 23 | 41 |
|
| 24 | 42 |
interface AdminMemberCreateModalProps {
|
| 25 | 43 |
onClose: () => void; |
| 26 | 44 |
} |
| 27 | 45 |
|
| 28 |
-const FORM_ID = 'admin-member-create-form'; |
|
| 46 |
+/** 목록에 없는 주소를 위해 도메인 칸을 입력으로 바꾸는 값. 실제 도메인과 겹치지 않게 잡았다. */ |
|
| 47 |
+const CUSTOM_EMAIL_DOMAIN = '__custom__'; |
|
| 48 |
+ |
|
| 49 |
+/** 시안의 도메인 목록. 맨 아래 "직접입력"은 같은 칸을 입력으로 바꾼다(상자를 늘리지 않는다). */ |
|
| 50 |
+const EMAIL_DOMAIN_OPTIONS = [ |
|
| 51 |
+ 'naver.com', |
|
| 52 |
+ 'gmail.com', |
|
| 53 |
+ 'daum.net', |
|
| 54 |
+ 'hanmail.net', |
|
| 55 |
+ 'nate.com', |
|
| 56 |
+]; |
|
| 29 | 57 |
|
| 30 | 58 |
/** |
| 31 |
- * 관리자 등록 팝업(시안 ADM_ADM_102_p). |
|
| 59 |
+ * 관리자 등록 팝업 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) ADM_ADM_102_p (5227:2035) |
|
| 32 | 60 |
* |
| 33 | 61 |
* **ID는 중복 확인을 통과해야 저장할 수 있다**(시안 ①). 확인은 등록 폼과 별개의 서버 호출인데, |
| 34 | 62 |
* 폼 안에 폼을 중첩할 수 없어 `useActionState` 대신 값을 직접 넘기는 Server Action |
... | ... | @@ -36,12 +64,14 @@ |
| 36 | 64 |
* "확인에 성공한 ID"와 "지금 입력창의 값"이 같을 때만 통과로 본다 — 확인만 받아 두고 다른 ID로 |
| 37 | 65 |
* 바꿔 저장하는 경로를 막기 위해서다. 그래도 최종 방어선은 Server Action의 재확인이다. |
| 38 | 66 |
* |
| 39 |
- * 저장 버튼은 footer 슬롯에서 `form={FORM_ID}` 속성으로 폼과 연결한다 — 네이티브 HTML의 `form`
|
|
| 40 |
- * 속성은 버튼이 실제 DOM상 form의 자손이 아니어도 같은 문서 안에서 id만 일치하면 그 form을 |
|
| 41 |
- * 제출한다. |
|
| 67 |
+ * 저장 버튼은 `FoxModal`의 actions 슬롯에 있어 폼 **바깥**에 그려진다. `FoxButton`에는 네이티브 |
|
| 68 |
+ * `form` 속성이 없으므로 폼을 ref로 잡아 `requestSubmit()`을 부른다 — `useActionState`의 폼 |
|
| 69 |
+ * 액션이 그대로 타는 정식 제출이라 검증·상태 흐름이 동일하다. |
|
| 42 | 70 |
* |
| 43 |
- * 비밀번호는 형식만 검증하고 저장되지 않는다 — 백엔드에 등록 API가 없어 지금은 mock 저장소가 |
|
| 44 |
- * 받는데, 평문 비밀번호를 메모리에 들고 있지 않기로 했기 때문이다(Repository·mock 주석 참조). |
|
| 71 |
+ * @fox의 제어 위젯(전화번호·이메일·역할·메뉴)은 값이 FormData에 실리지 않아 이 컴포넌트가 값을 |
|
| 72 |
+ * 들고 hidden input으로 제출한다(`AdminMemberFormFields` 주석 참조). |
|
| 73 |
+ * |
|
| 74 |
+ * 비밀번호는 형식만 검증하고 백엔드가 SHA-256으로 암호화해 저장한다(`MngrAdminServiceImpl`). |
|
| 45 | 75 |
*/ |
| 46 | 76 |
export function AdminMemberCreateModal({
|
| 47 | 77 |
onClose, |
... | ... | @@ -51,12 +81,23 @@ |
| 51 | 81 |
createAdminMemberAction, |
| 52 | 82 |
INITIAL_ADMIN_MEMBER_FORM_STATE |
| 53 | 83 |
); |
| 84 |
+ const formRef = useRef<HTMLFormElement>(null); |
|
| 54 | 85 |
|
| 55 | 86 |
const [loginId, setLoginId] = useState('');
|
| 56 | 87 |
const [checkResult, setCheckResult] = useState<LoginIdCheckResult>({
|
| 57 | 88 |
status: 'idle', |
| 58 | 89 |
}); |
| 59 | 90 |
const [isChecking, startChecking] = useTransition(); |
| 91 |
+ |
|
| 92 |
+ const [phoneNumber, setPhoneNumber] = useState('');
|
|
| 93 |
+ const [emailLocal, setEmailLocal] = useState('');
|
|
| 94 |
+ const [emailDomain, setEmailDomain] = useState(EMAIL_DOMAIN_OPTIONS[0]); |
|
| 95 |
+ const [isCustomDomain, setIsCustomDomain] = useState(false); |
|
| 96 |
+ // 이메일은 칸이 둘로 나뉘어 있어 검증 대상이 **합친 값**이다 — FoxInput 한 칸이 스스로 |
|
| 97 |
+ // 판정할 수 없어 여기서 돌리고, `touched`도 직접 든다(FoxInput의 blur 규칙과 같은 시점). |
|
| 98 |
+ const [isEmailTouched, setIsEmailTouched] = useState(false); |
|
| 99 |
+ const [roleCode, setRoleCode] = useState<string>(DEFAULT_ADMIN_ROLE_CODE); |
|
| 100 |
+ const [menuCodes, setMenuCodes] = useState<string[]>([]); |
|
| 60 | 101 |
|
| 61 | 102 |
useEffect(() => {
|
| 62 | 103 |
if (state.status === 'success') {
|
... | ... | @@ -66,99 +107,201 @@ |
| 66 | 107 |
}, [state, showToast, onClose]); |
| 67 | 108 |
|
| 68 | 109 |
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 |
- } |
|
| 110 |
+ checkResult.status === 'available' && checkResult.loginId === loginId.trim(); |
|
| 77 | 111 |
|
| 78 | 112 |
const errors = state.status === 'error' ? (state.errors ?? {}) : {};
|
| 79 | 113 |
|
| 114 |
+ // 둘 중 하나라도 비면 **빈 이메일**로 본다 — 도메인만 고른 상태(`@naver.com`)를 값으로 보내면 |
|
| 115 |
+ // "형식이 올바르지 않습니다"가 뜨지만, 사용자가 한 일은 아무것도 입력하지 않은 것이다. |
|
| 116 |
+ const email = emailLocal && emailDomain ? `${emailLocal}@${emailDomain}` : '';
|
|
| 117 |
+ |
|
| 118 |
+ const emailMessage = |
|
| 119 |
+ errors.email ?? |
|
| 120 |
+ (isEmailTouched |
|
| 121 |
+ ? foxValidationMessage( |
|
| 122 |
+ foxValidators.compose(adminEmailValidators(true))(email), |
|
| 123 |
+ ADMIN_EMAIL_MESSAGES |
|
| 124 |
+ ) |
|
| 125 |
+ : undefined); |
|
| 126 |
+ |
|
| 127 |
+ // 중복 확인 결과는 서버 검증 오류보다 먼저 보여준다 — 방금 누른 버튼의 답이기 때문이다. |
|
| 128 |
+ // 값이 나쁜 것(unavailable)과 확인을 못 한 것(failed)은 사유가 다르므로 둘 다 문구로 낸다. |
|
| 129 |
+ const loginIdFailure = |
|
| 130 |
+ checkResult.status === 'unavailable' || checkResult.status === 'failed' |
|
| 131 |
+ ? checkResult.message |
|
| 132 |
+ : undefined; |
|
| 133 |
+ const loginIdMessage = |
|
| 134 |
+ loginIdFailure ?? |
|
| 135 |
+ (isLoginIdVerified |
|
| 136 |
+ ? '사용할 수 있는 ID입니다.' |
|
| 137 |
+ : (errors.loginId ?? ADMIN_LOGIN_ID_HELP_TEXT)); |
|
| 138 |
+ |
|
| 80 | 139 |
return ( |
| 81 |
- <Modal |
|
| 82 |
- title="관리자 등록" |
|
| 140 |
+ <FoxModal |
|
| 141 |
+ open |
|
| 83 | 142 |
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 |
- </> |
|
| 143 |
+ size="sm" |
|
| 144 |
+ title="관리자 등록" |
|
| 145 |
+ actions={
|
|
| 146 |
+ <FoxButtonGroup size="md"> |
|
| 147 |
+ <FoxButton type="default" size="md" label="취소" onAction={onClose} />
|
|
| 148 |
+ <FoxButton |
|
| 149 |
+ type="primary" |
|
| 150 |
+ size="md" |
|
| 151 |
+ label="저장" |
|
| 152 |
+ loading={isPending}
|
|
| 153 |
+ disabled={!isLoginIdVerified}
|
|
| 154 |
+ onAction={() => formRef.current?.requestSubmit()}
|
|
| 155 |
+ /> |
|
| 156 |
+ </FoxButtonGroup> |
|
| 99 | 157 |
} |
| 100 | 158 |
> |
| 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> |
|
| 159 |
+ <form ref={formRef} action={formAction} className={styles.formList}>
|
|
| 160 |
+ <FoxInput |
|
| 161 |
+ size="md" |
|
| 162 |
+ name="name" |
|
| 163 |
+ label="이름" |
|
| 164 |
+ requirement="required" |
|
| 165 |
+ placeholder="이름을 입력하세요." |
|
| 166 |
+ message={errors.name}
|
|
| 167 |
+ invalid={Boolean(errors.name)}
|
|
| 168 |
+ /> |
|
| 105 | 169 |
|
| 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" |
|
| 170 |
+ <div className={styles.fieldWithAction}>
|
|
| 171 |
+ <FoxInput |
|
| 172 |
+ size="md" |
|
| 173 |
+ name="loginId" |
|
| 174 |
+ label="ID" |
|
| 175 |
+ requirement="required" |
|
| 176 |
+ placeholder="관리자 ID를 입력하세요." |
|
| 177 |
+ autoComplete="off" |
|
| 178 |
+ value={loginId}
|
|
| 179 |
+ onChange={setLoginId}
|
|
| 180 |
+ message={loginIdMessage}
|
|
| 181 |
+ messageIcon={<FoxChatCenteredDotsIcon />}
|
|
| 182 |
+ // 중복 확인 결과·서버 오류는 화면 규칙이 알 수 없는 사유라 이쪽이 이긴다. |
|
| 183 |
+ invalid={Boolean(loginIdFailure) || Boolean(errors.loginId)}
|
|
| 184 |
+ validators={ADMIN_LOGIN_ID_VALIDATORS}
|
|
| 185 |
+ validationMessages={ADMIN_LOGIN_ID_MESSAGES}
|
|
| 149 | 186 |
/> |
| 150 |
- </Field> |
|
| 151 |
- <p className="text-body-sm text-foreground-muted"> |
|
| 152 |
- {ADMIN_PASSWORD_HELP_TEXT}
|
|
| 153 |
- </p> |
|
| 154 |
- <FieldError message={errors.password} />
|
|
| 187 |
+ <FoxButton |
|
| 188 |
+ type="secondary" |
|
| 189 |
+ size="md" |
|
| 190 |
+ fullWidth |
|
| 191 |
+ label="중복 확인" |
|
| 192 |
+ loading={isChecking}
|
|
| 193 |
+ disabled={loginId.trim().length === 0}
|
|
| 194 |
+ onAction={() =>
|
|
| 195 |
+ startChecking(async () => {
|
|
| 196 |
+ try {
|
|
| 197 |
+ setCheckResult(await checkAdminLoginId(loginId)); |
|
| 198 |
+ } catch {
|
|
| 199 |
+ // Server Action 호출 자체가 깨진 경우(네트워크 등). 조용히 끝나면 버튼만 |
|
| 200 |
+ // 원복되고 아무 말도 남지 않아, 사유를 알 수 없어도 실패는 알린다. |
|
| 201 |
+ setCheckResult({
|
|
| 202 |
+ status: 'failed', |
|
| 203 |
+ message: '중복 확인에 실패했습니다. 잠시 후 다시 시도해 주세요.', |
|
| 204 |
+ }); |
|
| 205 |
+ } |
|
| 206 |
+ }) |
|
| 207 |
+ } |
|
| 208 |
+ /> |
|
| 209 |
+ </div> |
|
| 155 | 210 |
|
| 156 |
- <AdminMemberFormFields errors={errors} />
|
|
| 211 |
+ <FoxInput |
|
| 212 |
+ size="md" |
|
| 213 |
+ type="password" |
|
| 214 |
+ name="password" |
|
| 215 |
+ label="비밀번호" |
|
| 216 |
+ requirement="required" |
|
| 217 |
+ placeholder="비밀번호를 입력하세요." |
|
| 218 |
+ autoComplete="new-password" |
|
| 219 |
+ message={errors.password ?? ADMIN_PASSWORD_HELP_TEXT}
|
|
| 220 |
+ messageIcon={<FoxChatCenteredDotsIcon />}
|
|
| 221 |
+ invalid={Boolean(errors.password)}
|
|
| 222 |
+ // 규칙은 도메인이 한 벌로 갖는다 — 저장 직전 서버 검증이 같은 값을 본다. |
|
| 223 |
+ validators={[foxPasswordValidator({ ...ADMIN_PASSWORD_POLICY, required: true })]}
|
|
| 224 |
+ validationMessages={{ minlength: ADMIN_PASSWORD_HELP_TEXT }}
|
|
| 225 |
+ /> |
|
| 226 |
+ |
|
| 227 |
+ <AdminMemberFormFields |
|
| 228 |
+ phoneNumber={phoneNumber}
|
|
| 229 |
+ onPhoneNumberChange={setPhoneNumber}
|
|
| 230 |
+ roleCode={roleCode}
|
|
| 231 |
+ onRoleCodeChange={setRoleCode}
|
|
| 232 |
+ menuCodes={menuCodes}
|
|
| 233 |
+ onMenuCodesChange={setMenuCodes}
|
|
| 234 |
+ errors={errors}
|
|
| 235 |
+ emailField={
|
|
| 236 |
+ <> |
|
| 237 |
+ <div className={styles.field}>
|
|
| 238 |
+ <FoxFormLabel as="span" requirement="required"> |
|
| 239 |
+ 이메일 |
|
| 240 |
+ </FoxFormLabel> |
|
| 241 |
+ <div className={styles.emailRow}>
|
|
| 242 |
+ <span className={styles.emailPart}>
|
|
| 243 |
+ <FoxInput |
|
| 244 |
+ size="md" |
|
| 245 |
+ aria-label="이메일 아이디" |
|
| 246 |
+ placeholder="1234-5678" |
|
| 247 |
+ value={emailLocal}
|
|
| 248 |
+ onChange={setEmailLocal}
|
|
| 249 |
+ onBlur={() => setIsEmailTouched(true)}
|
|
| 250 |
+ invalid={Boolean(emailMessage)}
|
|
| 251 |
+ /> |
|
| 252 |
+ </span> |
|
| 253 |
+ <span aria-hidden="true">@</span> |
|
| 254 |
+ <span className={styles.emailPart}>
|
|
| 255 |
+ {isCustomDomain ? (
|
|
| 256 |
+ <FoxInput |
|
| 257 |
+ size="md" |
|
| 258 |
+ aria-label="이메일 도메인" |
|
| 259 |
+ placeholder="직접입력" |
|
| 260 |
+ value={emailDomain}
|
|
| 261 |
+ onChange={setEmailDomain}
|
|
| 262 |
+ onBlur={() => setIsEmailTouched(true)}
|
|
| 263 |
+ invalid={Boolean(emailMessage)}
|
|
| 264 |
+ /> |
|
| 265 |
+ ) : ( |
|
| 266 |
+ <FoxSelect |
|
| 267 |
+ size="md" |
|
| 268 |
+ options={[
|
|
| 269 |
+ ...EMAIL_DOMAIN_OPTIONS.map((domain) => ({
|
|
| 270 |
+ value: domain, |
|
| 271 |
+ label: domain, |
|
| 272 |
+ })), |
|
| 273 |
+ { value: CUSTOM_EMAIL_DOMAIN, label: '직접입력' },
|
|
| 274 |
+ ]} |
|
| 275 |
+ value={emailDomain}
|
|
| 276 |
+ onValueChange={(next) => {
|
|
| 277 |
+ setIsEmailTouched(true); |
|
| 278 |
+ if (next === CUSTOM_EMAIL_DOMAIN) {
|
|
| 279 |
+ setIsCustomDomain(true); |
|
| 280 |
+ setEmailDomain('');
|
|
| 281 |
+ return; |
|
| 282 |
+ } |
|
| 283 |
+ setEmailDomain(next); |
|
| 284 |
+ }} |
|
| 285 |
+ error={Boolean(emailMessage)}
|
|
| 286 |
+ /> |
|
| 287 |
+ )} |
|
| 288 |
+ </span> |
|
| 289 |
+ </div> |
|
| 290 |
+ {emailMessage && (
|
|
| 291 |
+ <p className={styles.fieldError} role="alert">
|
|
| 292 |
+ {emailMessage}
|
|
| 293 |
+ </p> |
|
| 294 |
+ )} |
|
| 295 |
+ </div> |
|
| 296 |
+ <input type="hidden" name="email" value={email} />
|
|
| 297 |
+ </> |
|
| 298 |
+ } |
|
| 299 |
+ /> |
|
| 157 | 300 |
|
| 158 | 301 |
{state.status === 'error' && state.message && (
|
| 159 |
- <p className="text-body-sm text-danger">{state.message}</p>
|
|
| 302 |
+ <p role="alert">{state.message}</p>
|
|
| 160 | 303 |
)} |
| 161 | 304 |
</form> |
| 162 |
- </Modal> |
|
| 305 |
+ </FoxModal> |
|
| 163 | 306 |
); |
| 164 | 307 |
} |
--- app/(protected)/(basic)/admins/_components/admin-member-edit-modal.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-edit-modal.tsx
... | ... | @@ -1,49 +1,53 @@ |
| 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 { foxPasswordValidator } from '@fox/core/validation';
|
|
| 11 |
+import { FoxChatCenteredDotsIcon } from '@fox/core/icons';
|
|
| 9 | 12 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 13 |
+import { DEFAULT_ADMIN_ROLE_CODE, type AdminMember } from '@/lib/domain/admin-member';
|
|
| 10 | 14 |
import {
|
| 11 |
- ADMIN_LOCK_STATUS_OPTIONS, |
|
| 12 |
- type AdminMember, |
|
| 13 |
-} from '@/lib/domain/admin-member'; |
|
| 14 |
-import {
|
|
| 15 |
+ ADMIN_EMAIL_MESSAGES, |
|
| 15 | 16 |
ADMIN_PASSWORD_HELP_TEXT, |
| 17 |
+ ADMIN_PASSWORD_POLICY, |
|
| 16 | 18 |
INITIAL_ADMIN_MEMBER_FORM_STATE, |
| 19 |
+ adminEmailValidators, |
|
| 20 |
+ toPhoneDigits, |
|
| 17 | 21 |
} from '@/lib/domain/admin-member-form'; |
| 18 | 22 |
import { updateAdminMemberAction } from '../_actions';
|
| 19 |
-import {
|
|
| 20 |
- AdminMemberFormFields, |
|
| 21 |
- FieldError, |
|
| 22 |
-} from './admin-member-form-fields'; |
|
| 23 |
+import { AdminMemberFormFields } from './admin-member-form-fields';
|
|
| 24 |
+import styles from './admin-member-modal.module.scss'; |
|
| 23 | 25 |
|
| 24 | 26 |
interface AdminMemberEditModalProps {
|
| 25 | 27 |
member: AdminMember; |
| 26 | 28 |
onClose: () => void; |
| 27 | 29 |
} |
| 28 | 30 |
|
| 29 |
-const FORM_ID = 'admin-member-edit-form'; |
|
| 30 |
- |
|
| 31 | 31 |
/** |
| 32 |
- * 관리자 수정 팝업(시안 ADM_ADM_103_p). |
|
| 32 |
+ * 관리자 수정 팝업 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) ADM_ADM_103_p (5227:3351) |
|
| 33 | 33 |
* |
| 34 |
- * 이름·ID는 읽기 전용이다(시안 ①). 다만 화면의 readOnly는 표시일 뿐이라 Server Action은 두 값을 |
|
| 35 |
- * 아예 반영하지 않는다 — 직접 POST로 다른 값을 보내도 바뀌지 않는다. |
|
| 34 |
+ * 이름·ID는 읽기 전용이다(시안 ①). 화면의 readOnly는 표시일 뿐이지만 Server Action도 두 값을 아예 |
|
| 35 |
+ * 읽지 않고, 백엔드 수정 API 역시 받지 않는다(`MngrAdminUpdateRequestVo`에 필드가 없다) — 세 겹이 |
|
| 36 |
+ * 같은 말을 한다. |
|
| 36 | 37 |
* |
| 37 |
- * **비밀번호는 [비밀번호 변경]을 눌러야 입력할 수 있다**(시안 ②). 다만 비워 둔 채로는 수정이 |
|
| 38 |
- * 저장되지 않는다 — 백엔드 UPDATE가 비밀번호를 조건 없이 덮어써서 빈 값을 보내면 그 계정이 로그인 |
|
| 39 |
- * 불가가 되기 때문이다(`ADMIN_PASSWORD_REQUIRED_ON_UPDATE_TEXT` 주석). 비활성 input은 FormData에 |
|
| 40 |
- * 실리지 않으므로 버튼을 누르지 않고 제출하면 그 안내가 그대로 뜬다. |
|
| 38 |
+ * **비밀번호는 [비밀번호 변경]을 눌러야 입력할 수 있고, 비워 두면 바꾸지 않는다**(시안 ②). |
|
| 39 |
+ * 백엔드 UPDATE의 `LOGIN_PW`가 `<if test='loginPw != null and loginPw != ""'>`로 감싸여 있어 |
|
| 40 |
+ * 빈 값은 SET 절에서 빠진다. (예전에는 조건 없이 덮어써서 빈 값을 보내면 그 계정이 로그인 불가가 |
|
| 41 |
+ * 됐고, 그래서 수정 시 비밀번호를 필수로 막아 두었다 — 그 제약은 이제 없다.) |
|
| 41 | 42 |
* |
| 42 |
- * 휴대전화번호·이메일은 항상 빈 칸으로 시작한다 — 백엔드가 두 값을 되돌려주지 않아 기존 값을 |
|
| 43 |
- * 채울 수단이 없다(Repository 주석). 입력한 값으로 덮어쓰는 동작이다. |
|
| 43 |
+ * 이메일은 시안이 **단일 입력**이다(등록 팝업의 도메인 셀렉트가 없다). 두 시안의 차이를 그대로 |
|
| 44 |
+ * 따른다 — 수정은 이미 있는 주소를 고치는 자리라 도메인만 고르는 형태가 맞지 않는다. |
|
| 44 | 45 |
* |
| 45 |
- * "잠김여부"는 활성/비활성으로 표기되지만 백엔드 필드는 `acctLockYn`(잠김 여부)이라 의미가 |
|
| 46 |
- * 뒤집혀 있다. 그 변환은 도메인(`ADMIN_LOCK_STATUS_OPTIONS`)이 한 곳에서 책임진다. |
|
| 46 |
+ * 휴대전화번호·이메일·역할은 이제 목록 응답에 담겨 오므로 **기존 값으로 채운다**(백엔드가 |
|
| 47 |
+ * `ADM_TEL_NO`·`ADM_EML_ADDR`을 select 목록에 넣었다). |
|
| 48 |
+ * |
|
| 49 |
+ * "잠김여부"는 시안이 활성/비활성 토글이고 백엔드 필드는 `acctLockYn`(잠김 여부)이라 의미가 |
|
| 50 |
+ * 뒤집혀 있다 — 켜짐이 곧 "잠기지 않음"이다. |
|
| 47 | 51 |
*/ |
| 48 | 52 |
export function AdminMemberEditModal({
|
| 49 | 53 |
member, |
... | ... | @@ -54,8 +58,18 @@ |
| 54 | 58 |
updateAdminMemberAction, |
| 55 | 59 |
INITIAL_ADMIN_MEMBER_FORM_STATE |
| 56 | 60 |
); |
| 61 |
+ const formRef = useRef<HTMLFormElement>(null); |
|
| 57 | 62 |
|
| 58 | 63 |
const [isPasswordEditable, setIsPasswordEditable] = useState(false); |
| 64 |
+ const [phoneNumber, setPhoneNumber] = useState( |
|
| 65 |
+ toPhoneDigits(member.phoneNumber) |
|
| 66 |
+ ); |
|
| 67 |
+ const [email, setEmail] = useState(member.email ?? ''); |
|
| 68 |
+ const [roleCode, setRoleCode] = useState( |
|
| 69 |
+ member.roleCode || DEFAULT_ADMIN_ROLE_CODE |
|
| 70 |
+ ); |
|
| 71 |
+ const [menuCodes, setMenuCodes] = useState<string[]>(member.menuCodes); |
|
| 72 |
+ const [isActive, setIsActive] = useState(!(member.isLocked ?? false)); |
|
| 59 | 73 |
|
| 60 | 74 |
useEffect(() => {
|
| 61 | 75 |
if (state.status === 'success') {
|
... | ... | @@ -67,88 +81,118 @@ |
| 67 | 81 |
const errors = state.status === 'error' ? (state.errors ?? {}) : {};
|
| 68 | 82 |
|
| 69 | 83 |
return ( |
| 70 |
- <Modal |
|
| 71 |
- title="관리자 수정" |
|
| 84 |
+ <FoxModal |
|
| 85 |
+ open |
|
| 72 | 86 |
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 |
- </> |
|
| 87 |
+ size="sm" |
|
| 88 |
+ title="관리자 수정" |
|
| 89 |
+ actions={
|
|
| 90 |
+ <FoxButtonGroup size="md"> |
|
| 91 |
+ <FoxButton type="default" size="md" label="취소" onAction={onClose} />
|
|
| 92 |
+ <FoxButton |
|
| 93 |
+ type="primary" |
|
| 94 |
+ size="md" |
|
| 95 |
+ label="저장" |
|
| 96 |
+ loading={isPending}
|
|
| 97 |
+ onAction={() => formRef.current?.requestSubmit()}
|
|
| 98 |
+ /> |
|
| 99 |
+ </FoxButtonGroup> |
|
| 87 | 100 |
} |
| 88 | 101 |
> |
| 89 |
- <form id={FORM_ID} action={formAction} className="flex flex-col gap-4">
|
|
| 90 |
- {/* 수정 대상을 가리키는 유일한 입력. 이름·ID는 읽기 전용이라 아예 제출하지 않는다 —
|
|
| 91 |
- Server Action도 그 둘을 읽지 않으므로 위조해 보내도 반영되지 않는다. */} |
|
| 102 |
+ <form ref={formRef} action={formAction} className={styles.formList}>
|
|
| 103 |
+ {/* 수정 대상을 가리키는 유일한 입력. 이름·ID는 읽기 전용이라 아예 제출하지 않는다. */}
|
|
| 92 | 104 |
<input type="hidden" name="id" value={member.id} />
|
| 93 | 105 |
|
| 94 |
- <p className="text-right text-body-sm text-danger"> |
|
| 95 |
- * 는 필수 항목입니다. |
|
| 96 |
- </p> |
|
| 106 |
+ <FoxInput |
|
| 107 |
+ size="md" |
|
| 108 |
+ label="이름" |
|
| 109 |
+ requirement="required" |
|
| 110 |
+ value={member.name}
|
|
| 111 |
+ readOnly |
|
| 112 |
+ /> |
|
| 97 | 113 |
|
| 98 |
- <Field label="이름"> |
|
| 99 |
- <Input type="text" value={member.name} readOnly />
|
|
| 100 |
- </Field> |
|
| 114 |
+ <FoxInput |
|
| 115 |
+ size="md" |
|
| 116 |
+ label="ID" |
|
| 117 |
+ requirement="required" |
|
| 118 |
+ value={member.loginId}
|
|
| 119 |
+ readOnly |
|
| 120 |
+ /> |
|
| 101 | 121 |
|
| 102 |
- <Field label="ID"> |
|
| 103 |
- <Input type="text" value={member.loginId} readOnly />
|
|
| 104 |
- </Field> |
|
| 122 |
+ <div className={styles.fieldWithAction}>
|
|
| 123 |
+ <FoxInput |
|
| 124 |
+ size="md" |
|
| 125 |
+ type="password" |
|
| 126 |
+ name="password" |
|
| 127 |
+ label="비밀번호" |
|
| 128 |
+ requirement="required" |
|
| 129 |
+ placeholder="비밀번호를 입력하세요." |
|
| 130 |
+ autoComplete="new-password" |
|
| 131 |
+ disabled={!isPasswordEditable}
|
|
| 132 |
+ message={
|
|
| 133 |
+ errors.password ?? |
|
| 134 |
+ (isPasswordEditable ? ADMIN_PASSWORD_HELP_TEXT : undefined) |
|
| 135 |
+ } |
|
| 136 |
+ messageIcon={
|
|
| 137 |
+ isPasswordEditable ? <FoxChatCenteredDotsIcon /> : undefined |
|
| 138 |
+ } |
|
| 139 |
+ invalid={Boolean(errors.password)}
|
|
| 140 |
+ // 비우면 "바꾸지 않음"이라 required를 걸지 않는다(등록 팝업과 다른 점). |
|
| 141 |
+ validators={[foxPasswordValidator(ADMIN_PASSWORD_POLICY)]}
|
|
| 142 |
+ validationMessages={{ minlength: ADMIN_PASSWORD_HELP_TEXT }}
|
|
| 143 |
+ /> |
|
| 144 |
+ <FoxButton |
|
| 145 |
+ type="secondary" |
|
| 146 |
+ size="md" |
|
| 147 |
+ fullWidth |
|
| 148 |
+ label="비밀번호 변경" |
|
| 149 |
+ disabled={isPasswordEditable}
|
|
| 150 |
+ onAction={() => setIsPasswordEditable(true)}
|
|
| 151 |
+ /> |
|
| 152 |
+ </div> |
|
| 105 | 153 |
|
| 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}
|
|
| 154 |
+ <AdminMemberFormFields |
|
| 155 |
+ phoneNumber={phoneNumber}
|
|
| 156 |
+ onPhoneNumberChange={setPhoneNumber}
|
|
| 157 |
+ roleCode={roleCode}
|
|
| 158 |
+ onRoleCodeChange={setRoleCode}
|
|
| 159 |
+ menuCodes={menuCodes}
|
|
| 160 |
+ onMenuCodesChange={setMenuCodes}
|
|
| 161 |
+ errors={errors}
|
|
| 162 |
+ emailField={
|
|
| 163 |
+ <FoxInput |
|
| 164 |
+ size="md" |
|
| 165 |
+ type="email" |
|
| 166 |
+ name="email" |
|
| 167 |
+ label="이메일" |
|
| 168 |
+ placeholder="이메일을 입력하세요." |
|
| 169 |
+ value={email}
|
|
| 170 |
+ onChange={setEmail}
|
|
| 171 |
+ message={errors.email}
|
|
| 172 |
+ invalid={Boolean(errors.email)}
|
|
| 173 |
+ // 수정 시안에는 `*`가 없다 — 비우면 이메일을 지우는 것이고 오류가 아니다. |
|
| 174 |
+ validators={adminEmailValidators(false)}
|
|
| 175 |
+ validationMessages={ADMIN_EMAIL_MESSAGES}
|
|
| 114 | 176 |
/> |
| 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} />
|
|
| 177 |
+ } |
|
| 178 |
+ /> |
|
| 132 | 179 |
|
| 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> |
|
| 180 |
+ <div className={styles.field}>
|
|
| 181 |
+ <FoxFormLabel as="span">잠김여부</FoxFormLabel> |
|
| 182 |
+ <FoxToggleSwitch |
|
| 183 |
+ size="md" |
|
| 184 |
+ label={isActive ? '활성' : '비활성'}
|
|
| 185 |
+ checked={isActive}
|
|
| 186 |
+ onChange={setIsActive}
|
|
| 187 |
+ /> |
|
| 188 |
+ </div> |
|
| 189 |
+ {/* 화면은 활성(잠기지 않음)을 보여주고 서버는 잠김 여부를 받는다 — 여기서 뒤집는다. */}
|
|
| 190 |
+ <input type="hidden" name="isLocked" value={String(!isActive)} />
|
|
| 147 | 191 |
|
| 148 | 192 |
{state.status === 'error' && state.message && (
|
| 149 |
- <p className="text-body-sm text-danger">{state.message}</p>
|
|
| 193 |
+ <p role="alert">{state.message}</p>
|
|
| 150 | 194 |
)} |
| 151 | 195 |
</form> |
| 152 |
- </Modal> |
|
| 196 |
+ </FoxModal> |
|
| 153 | 197 |
); |
| 154 | 198 |
} |
--- 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,58 @@ |
| 1 | +// 관리자 등록·수정 팝업의 배치 — 시안(ADM_ADM_102_p / 103_p)의 form-list. | |
| 2 | +// 값은 전부 @fox 토큰을 거치므로 없는 이름을 쓰면 빌드가 실패한다. | |
| 3 | + | |
| 4 | +@use "@fox/styles/abstracts" as fox; | |
| 5 | +@use "@fox/styles/form-field" as field; | |
| 6 | + | |
| 7 | +/// 필드 한 벌씩 세로로 쌓는다. 라벨·상자·헬퍼 사이 간격은 각 @fox 컴포넌트가 이미 갖는다. | |
| 8 | +/// 모달 contents는 `align-items: flex-start`라 자식이 늘어나지 않는다 — 폭을 주지 않으면 폼이 | |
| 9 | +/// 내용만큼 넓어져(전화번호 칸이 가장 넓다) 모달 밖으로 터진다. | |
| 10 | +.formList { | |
| 11 | + display: flex; | |
| 12 | + flex-direction: column; | |
| 13 | + gap: fox.gap(8); | |
| 14 | + inline-size: 100%; | |
| 15 | + min-inline-size: 0; | |
| 16 | +} | |
| 17 | + | |
| 18 | +/// 입력칸 바로 아래 전체폭 버튼이 붙는 칸([중복 확인]·[비밀번호 변경]). | |
| 19 | +.fieldWithAction { | |
| 20 | + display: flex; | |
| 21 | + flex-direction: column; | |
| 22 | + gap: fox.gap(3); | |
| 23 | +} | |
| 24 | + | |
| 25 | +/// 시안(102_p)의 이메일 한 줄 — [아이디] @ [도메인]. @fox의 `FoxEmail`을 쓰지 않는다: | |
| 26 | +/// 그쪽은 도메인 셀렉트 옆에 잠긴 '직접입력' 상자를 하나 더 그리고(시안은 상자 둘뿐), | |
| 27 | +/// 칸마다 240px 최소폭이 박혀 있어 320px 안에서 세 줄로 쌓인다(사용자 확정). | |
| 28 | +.emailRow { | |
| 29 | + display: flex; | |
| 30 | + align-items: center; | |
| 31 | + gap: fox.gap(2); | |
| 32 | + inline-size: 100%; | |
| 33 | +} | |
| 34 | + | |
| 35 | +/// 두 칸이 남는 폭을 나눠 갖는다. `min-inline-size: 0`이 없으면 내용만큼 넓어져 줄이 넘어간다. | |
| 36 | +.emailPart { | |
| 37 | + flex: 1 1 0; | |
| 38 | + min-inline-size: 0; | |
| 39 | +} | |
| 40 | + | |
| 41 | +/// 라벨 + 컨트롤 한 벌. @fox 입력 컴포넌트는 라벨을 스스로 갖지만, 여러 칸을 묶은 필드 | |
| 42 | +/// (전화번호·이메일·메뉴·잠김여부)는 라벨을 밖에서 얹으므로 그 간격을 여기서 준다. | |
| 43 | +/// `FoxFormLabel`은 인라인이라 이 틀이 없으면 토글 같은 인라인 컨트롤과 한 줄에 붙는다. | |
| 44 | +.field { | |
| 45 | + display: flex; | |
| 46 | + flex-direction: column; | |
| 47 | + align-items: flex-start; | |
| 48 | + gap: fox.gap(3); | |
| 49 | + inline-size: 100%; | |
| 50 | +} | |
| 51 | + | |
| 52 | +/// 칸 여럿을 묶은 필드(이메일)의 오류 문구. FoxInput은 자기 칸의 값만 판정할 수 있어 | |
| 53 | +/// 합친 값의 사유는 바깥에서 그린다 — 모양은 입력 헬퍼와 같은 조각을 쓴다. | |
| 54 | +.fieldError { | |
| 55 | + @include field.message; | |
| 56 | + | |
| 57 | + color: fox.color(font-system-danger); | |
| 58 | +} |
--- 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`)이 저장 직전에 여기를 |
... | ... | @@ -20,35 +20,83 @@ |
| 20 | 20 |
*/ |
| 21 | 21 |
|
| 22 | 22 |
import {
|
| 23 |
+ foxValidationMessage, |
|
| 24 |
+ foxValidators, |
|
| 25 |
+ type FoxValidationMessages, |
|
| 26 |
+ type FoxValidator, |
|
| 27 |
+} from '@fox/core/validation'; |
|
| 28 |
+import {
|
|
| 23 | 29 |
ADMIN_MENU_OPTIONS, |
| 24 | 30 |
ADMIN_ROLE_OPTIONS, |
| 25 | 31 |
type AdminRoleCode, |
| 26 | 32 |
} from '@/lib/domain/admin-member'; |
| 27 | 33 |
|
| 28 | 34 |
export const ADMIN_LOGIN_ID_HELP_TEXT = |
| 29 |
- '영어 소문자, 숫자를 조합하여 입력 후 중복여부를 확인합니다.'; |
|
| 35 |
+ '영어 소문자, 숫자를 조합하여 입력 후 중복여부를 확인하세요.'; |
|
| 30 | 36 |
export const ADMIN_PASSWORD_HELP_TEXT = |
| 31 | 37 |
'영어 소문자, 숫자, 특수문자 중 2종류 이상 조합, 최소 10자리 이상'; |
| 32 | 38 |
|
| 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 | 39 |
|
| 44 | 40 |
const LOGIN_ID_MIN_LENGTH = 4; |
| 45 | 41 |
const LOGIN_ID_MAX_LENGTH = 20; |
| 46 |
-const PASSWORD_MIN_LENGTH = 10; |
|
| 42 |
+ |
|
| 43 |
+/** |
|
| 44 |
+ * ID 규칙 — 검증기와 문구를 한 벌로 내보낸다. 등록 팝업의 입력 칸이 이것을 그대로 얹고, |
|
| 45 |
+ * 아래 `validateAdminLoginId`(서버 검증·중복 확인)도 같은 배열을 돌린다. 규칙이 한 군데뿐이라 |
|
| 46 |
+ * 화면은 통과시키는데 저장은 거부하는 상태가 생기지 않는다. |
|
| 47 |
+ * |
|
| 48 |
+ * "영어 소문자와 숫자를 조합"은 두 조각으로 나뉜다 — 허용 문자를 `pattern`이 소문자·숫자로 |
|
| 49 |
+ * 묶고, 그 안에서 **두 종류가 다 있어야 한다**를 `characterKinds(2)`가 본다. |
|
| 50 |
+ */ |
|
| 51 |
+export const ADMIN_LOGIN_ID_VALIDATORS: FoxValidator[] = [ |
|
| 52 |
+ foxValidators.required, |
|
| 53 |
+ foxValidators.minLength(LOGIN_ID_MIN_LENGTH), |
|
| 54 |
+ foxValidators.maxLength(LOGIN_ID_MAX_LENGTH), |
|
| 55 |
+ foxValidators.pattern(/^[a-z0-9]+$/), |
|
| 56 |
+ foxValidators.characterKinds(2), |
|
| 57 |
+]; |
|
| 58 |
+ |
|
| 59 |
+/** 위 검증기의 오류를 시안 문구로 옮긴다. 길이 두 종류는 같은 말이라 한 문장으로 합친다. */ |
|
| 60 |
+export const ADMIN_LOGIN_ID_MESSAGES: FoxValidationMessages = {
|
|
| 61 |
+ required: 'ID를 입력해 주세요.', |
|
| 62 |
+ minlength: `ID는 ${LOGIN_ID_MIN_LENGTH}~${LOGIN_ID_MAX_LENGTH}자로 입력해 주세요.`,
|
|
| 63 |
+ maxlength: `ID는 ${LOGIN_ID_MIN_LENGTH}~${LOGIN_ID_MAX_LENGTH}자로 입력해 주세요.`,
|
|
| 64 |
+ pattern: ADMIN_LOGIN_ID_HELP_TEXT, |
|
| 65 |
+ characterKinds: ADMIN_LOGIN_ID_HELP_TEXT, |
|
| 66 |
+}; |
|
| 67 |
+ |
|
| 68 |
+/** |
|
| 69 |
+ * 비밀번호 규칙 — **화면과 서버가 같은 값을 본다.** 팝업은 이 값을 `foxPasswordValidator`에 |
|
| 70 |
+ * 그대로 넘겨 입력 중에 안내하고, 서버 검증은 아래 `validateEditableValues`가 같은 값으로 판정한다. |
|
| 71 |
+ * 한쪽만 고치면 화면은 통과시키고 저장은 거부하는 상태가 된다. |
|
| 72 |
+ */ |
|
| 73 |
+export const ADMIN_PASSWORD_POLICY = {
|
|
| 74 |
+ minLength: 10, |
|
| 75 |
+ kinds: 2, |
|
| 76 |
+} as const; |
|
| 47 | 77 |
const NAME_MAX_LENGTH = 50; |
| 48 | 78 |
const EMAIL_MAX_LENGTH = 100; |
| 49 | 79 |
|
| 50 |
-/** 휴대전화번호는 시안처럼 3칸으로 나뉘어 입력된다. 앞자리는 010 등 3자리, 가운데 3~4자리, 끝 4자리. */ |
|
| 51 |
-const PHONE_PART_PATTERNS = [/^\d{3}$/, /^\d{3,4}$/, /^\d{4}$/] as const;
|
|
| 80 |
+/** |
|
| 81 |
+ * 이메일 규칙. **필수 여부만 두 시안이 다르다** — 등록(102_p)에는 `*`가 있고 수정(103_p)에는 |
|
| 82 |
+ * 없어서, 그 하나만 인자로 받고 나머지는 공유한다. |
|
| 83 |
+ */ |
|
| 84 |
+export function adminEmailValidators(required: boolean): FoxValidator[] {
|
|
| 85 |
+ return [ |
|
| 86 |
+ ...(required ? [foxValidators.required] : []), |
|
| 87 |
+ foxValidators.email, |
|
| 88 |
+ foxValidators.maxLength(EMAIL_MAX_LENGTH), |
|
| 89 |
+ ]; |
|
| 90 |
+} |
|
| 91 |
+ |
|
| 92 |
+export const ADMIN_EMAIL_MESSAGES: FoxValidationMessages = {
|
|
| 93 |
+ required: '이메일을 입력해 주세요.', |
|
| 94 |
+ email: '이메일 형식이 올바르지 않습니다.', |
|
| 95 |
+ maxlength: `이메일은 ${EMAIL_MAX_LENGTH}자 이내로 입력해 주세요.`,
|
|
| 96 |
+}; |
|
| 97 |
+ |
|
| 98 |
+/** 앞 3자리 + 가운데 3~4자리 + 끝 4자리. 화면은 숫자만 다루고 하이픈은 여기서 붙인다. */ |
|
| 99 |
+const PHONE_DIGITS_PATTERN = /^(\d{3})(\d{3,4})(\d{4})$/;
|
|
| 52 | 100 |
|
| 53 | 101 |
/** 등록·수정 양쪽에서 실제로 바뀔 수 있는 항목. */ |
| 54 | 102 |
export type AdminMemberEditableValues = {
|
... | ... | @@ -105,76 +153,55 @@ |
| 105 | 153 |
* ID 형식 검증 — 문제가 있으면 안내 문구, 없으면 null. |
| 106 | 154 |
* |
| 107 | 155 |
* 중복 확인(`checkAdminLoginId`)도 이 함수를 그대로 쓴다 — 중복을 묻기 전에 형식부터 봐야 하고, |
| 108 |
- * 그 판단 기준이 등록 시점과 달라지면 안 되기 때문이다. |
|
| 156 |
+ * 그 판단 기준이 등록 시점과 달라지면 안 되기 때문이다. 화면이 얹는 검증기와 **같은 배열**을 |
|
| 157 |
+ * 돌리므로 서버가 돌려주는 문구와 입력 중에 보이는 문구가 같다. |
|
| 109 | 158 |
*/ |
| 110 | 159 |
export function validateAdminLoginId(loginId: string): string | null {
|
| 111 |
- const value = loginId.trim(); |
|
| 112 |
- |
|
| 113 |
- if (!value) {
|
|
| 114 |
- return 'ID를 입력해 주세요.'; |
|
| 115 |
- } |
|
| 116 |
- if (value.length < LOGIN_ID_MIN_LENGTH || value.length > LOGIN_ID_MAX_LENGTH) {
|
|
| 117 |
- return `ID는 ${LOGIN_ID_MIN_LENGTH}~${LOGIN_ID_MAX_LENGTH}자로 입력해 주세요.`;
|
|
| 118 |
- } |
|
| 119 |
- // "영어 소문자, 숫자를 조합" — 허용 문자를 두 종류로 제한하고, 둘 다 포함되어야 한다. |
|
| 120 |
- if (!/^[a-z0-9]+$/.test(value) || !/[a-z]/.test(value) || !/\d/.test(value)) {
|
|
| 121 |
- return ADMIN_LOGIN_ID_HELP_TEXT; |
|
| 122 |
- } |
|
| 123 |
- return null; |
|
| 160 |
+ const errors = foxValidators.compose(ADMIN_LOGIN_ID_VALIDATORS)(loginId.trim()); |
|
| 161 |
+ return foxValidationMessage(errors, ADMIN_LOGIN_ID_MESSAGES) ?? null; |
|
| 124 | 162 |
} |
| 125 | 163 |
|
| 126 | 164 |
/** |
| 127 |
- * 휴대전화번호 3칸을 하나의 문자열(`010-1234-5678`)로 합친다. 비어 있는 칸이 하나라도 있거나 |
|
| 128 |
- * 형식이 맞지 않으면 null — 부분적으로 채워진 번호를 저장하지 않기 위해서다. |
|
| 165 |
+ * 화면이 넘긴 숫자열(`01012345678`)을 저장 형식(`010-1234-5678`)으로 바꾼다. 자릿수가 맞지 않으면 |
|
| 166 |
+ * null — 부분적으로 채워진 번호를 저장하지 않기 위해서다. |
|
| 167 |
+ * |
|
| 168 |
+ * 화면(`FoxPhoneNumber`)은 하이픈을 그리기만 하고 값으로는 숫자만 내보낸다. 반대로 목록·엑셀은 |
|
| 169 |
+ * 하이픈이 있는 형태로 보여주므로, 두 표현 사이의 변환을 이 한 쌍이 책임진다. |
|
| 129 | 170 |
*/ |
| 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;
|
|
| 171 |
+export function formatPhoneNumber(digits: string): string | null {
|
|
| 172 |
+ const matched = PHONE_DIGITS_PATTERN.exec(digits.trim()); |
|
| 173 |
+ return matched === null ? null : `${matched[1]}-${matched[2]}-${matched[3]}`;
|
|
| 141 | 174 |
} |
| 142 | 175 |
|
| 143 |
-/** 저장된 번호를 다시 3칸으로 나눈다(수정 팝업의 초기값). 형식이 다르면 빈 칸들을 돌려준다. */ |
|
| 144 |
-export function splitPhoneNumber(phoneNumber: string | null): string[] {
|
|
| 145 |
- const parts = (phoneNumber ?? '').split('-');
|
|
| 146 |
- return parts.length === PHONE_PART_PATTERNS.length ? parts : ['', '', '']; |
|
| 147 |
-} |
|
| 148 |
- |
|
| 149 |
-function isValidEmail(email: string): boolean {
|
|
| 150 |
- // 공백 없는 `로컬부@도메인.최상위` 정도만 본다 — 이메일의 완전한 문법 검증은 정규식으로 |
|
| 151 |
- // 할 수 없고, 실제 유효성은 발송으로만 확인된다. 오탈자를 걸러내는 것이 목적이다. |
|
| 152 |
- return ( |
|
| 153 |
- /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) && email.length <= EMAIL_MAX_LENGTH |
|
| 154 |
- ); |
|
| 176 |
+/** 저장된 번호에서 숫자만 남긴다(수정 팝업의 초기값). */ |
|
| 177 |
+export function toPhoneDigits(phoneNumber: string | null): string {
|
|
| 178 |
+ return (phoneNumber ?? '').replace(/\D/g, ''); |
|
| 155 | 179 |
} |
| 156 | 180 |
|
| 157 | 181 |
/** |
| 158 | 182 |
* 등록·수정 공통 항목 검증. 오류는 넘겨받은 객체에 채워 넣고, 정규화된 값을 돌려준다. |
| 159 | 183 |
* |
| 160 |
- * 비밀번호는 등록·수정 모두 필수다. 빈 값일 때의 안내 문구만 호출부가 정한다 — 등록은 형식 안내, |
|
| 161 |
- * 수정은 "비우면 로그인 불가"라는 백엔드 제약을 알려야 하기 때문이다 |
|
| 162 |
- * (`ADMIN_PASSWORD_REQUIRED_ON_UPDATE_TEXT` 주석 참조). |
|
| 184 |
+ * **비밀번호·이메일은 등록에서만 필수다.** 수정에서 비우면 "바꾸지 않음"이 된다 — 백엔드 UPDATE의 |
|
| 185 |
+ * `LOGIN_PW`가 `<if test='loginPw != null and loginPw != ""'>`로 감싸여 있어 빈 값은 SET 절에서 |
|
| 186 |
+ * 빠진다(예전에는 조건 없이 덮어써서, 빈 값을 보내면 그 계정이 로그인 불가가 됐다). 값이 있으면 |
|
| 187 |
+ * 등록·수정 모두 같은 형식 규칙을 통과해야 한다. |
|
| 163 | 188 |
*/ |
| 164 | 189 |
function validateEditableValues( |
| 165 | 190 |
values: AdminMemberEditableValues, |
| 166 | 191 |
errors: AdminMemberFormErrors, |
| 167 |
- options: { emptyPasswordMessage: string }
|
|
| 192 |
+ options: { passwordRequired: boolean; emailRequired: boolean }
|
|
| 168 | 193 |
): AdminMemberEditableValues {
|
| 169 | 194 |
const password = values.password; |
| 170 | 195 |
const phoneNumber = values.phoneNumber.trim(); |
| 171 | 196 |
const email = values.email.trim(); |
| 172 | 197 |
|
| 173 | 198 |
if (!password) {
|
| 174 |
- errors.password = options.emptyPasswordMessage; |
|
| 199 |
+ if (options.passwordRequired) {
|
|
| 200 |
+ errors.password = ADMIN_PASSWORD_HELP_TEXT; |
|
| 201 |
+ } |
|
| 175 | 202 |
} else if ( |
| 176 |
- password.length < PASSWORD_MIN_LENGTH || |
|
| 177 |
- countCharacterKinds(password) < 2 |
|
| 203 |
+ password.length < ADMIN_PASSWORD_POLICY.minLength || |
|
| 204 |
+ countCharacterKinds(password) < ADMIN_PASSWORD_POLICY.kinds |
|
| 178 | 205 |
) {
|
| 179 | 206 |
errors.password = ADMIN_PASSWORD_HELP_TEXT; |
| 180 | 207 |
} |
... | ... | @@ -183,21 +210,24 @@ |
| 183 | 210 |
errors.phoneNumber = '휴대전화 번호를 정확히 입력해 주세요.'; |
| 184 | 211 |
} |
| 185 | 212 |
|
| 186 |
- // 이메일은 선택 항목이라 비어 있는 것 자체는 오류가 아니다(시안에 필수 표시가 없다). |
|
| 187 |
- if (email && !isValidEmail(email)) {
|
|
| 188 |
- errors.email = '이메일 형식이 올바르지 않습니다.'; |
|
| 213 |
+ const emailError = foxValidationMessage( |
|
| 214 |
+ foxValidators.compose(adminEmailValidators(options.emailRequired))(email), |
|
| 215 |
+ ADMIN_EMAIL_MESSAGES |
|
| 216 |
+ ); |
|
| 217 |
+ if (emailError) {
|
|
| 218 |
+ errors.email = emailError; |
|
| 189 | 219 |
} |
| 190 | 220 |
|
| 191 | 221 |
if (!ADMIN_ROLE_OPTIONS.some((option) => option.value === values.roleCode)) {
|
| 192 | 222 |
errors.roleCode = '역할을 선택해 주세요.'; |
| 193 | 223 |
} |
| 194 | 224 |
|
| 225 |
+ // 메뉴 선택은 **필수가 아니다**(사용자 확정) — 백엔드에 관리자별 메뉴 권한이 없어 고른 값이 |
|
| 226 |
+ // 저장되지 않는데, 저장되지도 않는 값 때문에 등록이 막히면 안 된다. 허용 목록 밖의 코드만 |
|
| 227 |
+ // 걸러 둔다(권한 API가 생기면 여기에 필수 규칙을 되살린다). |
|
| 195 | 228 |
const menuCodes = values.menuCodes.filter((code) => |
| 196 | 229 |
ADMIN_MENU_OPTIONS.some((option) => option.value === code) |
| 197 | 230 |
); |
| 198 |
- if (menuCodes.length === 0) {
|
|
| 199 |
- errors.menuCodes = '메뉴를 1개 이상 선택해 주세요.'; |
|
| 200 |
- } |
|
| 201 | 231 |
|
| 202 | 232 |
return { password, phoneNumber, email, roleCode: values.roleCode, menuCodes };
|
| 203 | 233 |
} |
... | ... | @@ -221,7 +251,8 @@ |
| 221 | 251 |
} |
| 222 | 252 |
|
| 223 | 253 |
const editable = validateEditableValues(values, errors, {
|
| 224 |
- emptyPasswordMessage: ADMIN_PASSWORD_HELP_TEXT, |
|
| 254 |
+ passwordRequired: true, |
|
| 255 |
+ emailRequired: true, |
|
| 225 | 256 |
}); |
| 226 | 257 |
|
| 227 | 258 |
if (Object.keys(errors).length > 0) {
|
... | ... | @@ -241,7 +272,8 @@ |
| 241 | 272 |
const errors: AdminMemberFormErrors = {};
|
| 242 | 273 |
|
| 243 | 274 |
const editable = validateEditableValues(values, errors, {
|
| 244 |
- emptyPasswordMessage: ADMIN_PASSWORD_REQUIRED_ON_UPDATE_TEXT, |
|
| 275 |
+ passwordRequired: false, |
|
| 276 |
+ emailRequired: false, |
|
| 245 | 277 |
}); |
| 246 | 278 |
|
| 247 | 279 |
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?