merge: 주석을 TODO·이슈사항만 남김
관리자 등록 팝업은 hub가 `FoxEmail`로 되돌린 최신본을 취하고 거기서 주석만 걷어냈다 — 브랜치 쪽은 FoxEmail을 우회하던 이전 버전이라 그대로 쓰면 되돌리는 셈이 된다. Co-Authored-By: Claude Opus 5
@ac903c332097652b597e3288ba7b1c56af827ff9
--- @fox/core/components/fox-chip-area/fox-chip-area.tsx
+++ @fox/core/components/fox-chip-area/fox-chip-area.tsx
... | ... | @@ -24,7 +24,6 @@ |
| 24 | 24 |
label?: string; |
| 25 | 25 |
/** 묶음 이름 역할을 하는 요소의 id. `label`보다 우선한다 — 화면의 글자와 어긋나지 않는다. */ |
| 26 | 26 |
labelledBy?: string; |
| 27 |
- /** 칩이 한 줄에 다 들어가지 않으면 다음 줄로 흘린다(`FoxButtonGroup`과 같은 규약). */ |
|
| 28 | 27 |
wrap?: boolean; |
| 29 | 28 |
/** `FoxChip`들. */ |
| 30 | 29 |
children?: ReactNode; |
--- @fox/core/components/fox-file-upload/fox-file-upload.tsx
+++ @fox/core/components/fox-file-upload/fox-file-upload.tsx
... | ... | @@ -156,7 +156,6 @@ |
| 156 | 156 |
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
|
| 157 | 157 |
} |
| 158 | 158 |
|
| 159 |
-/** 목록 항목의 id. 삭제할 때 같은 규칙으로 원본 `File`을 되찾으므로 한곳에 둔다. */ |
|
| 160 | 159 |
function fileKey(file: File): string {
|
| 161 | 160 |
return `${file.name}:${file.size}:${file.lastModified}`;
|
| 162 | 161 |
} |
... | ... | @@ -220,7 +219,6 @@ |
| 220 | 219 |
const autoId = useId(); |
| 221 | 220 |
const pickerRef = useRef<HTMLInputElement>(null); |
| 222 | 221 |
const objectUrls = useRef<string[]>([]); |
| 223 |
- /** 지금 고른 원본 파일들. 네이티브 입력의 `files`를 다시 세우는 근거다(아래 `syncPicker`). */ |
|
| 224 | 222 |
const picked = useRef<File[]>([]); |
| 225 | 223 |
const [inner, setInner] = useState<FoxFileItem[]>(defaultFiles ?? []); |
| 226 | 224 |
const [over, setOver] = useState(false); |
... | ... | @@ -249,18 +247,8 @@ |
| 249 | 247 |
pickerRef.current?.click(); |
| 250 | 248 |
}; |
| 251 | 249 |
|
| 252 |
- /** |
|
| 253 |
- * 고른 파일을 네이티브 입력에 **다시 심는다.** |
|
| 254 |
- * |
|
| 255 |
- * 폼 제출에 실리는 것은 React 상태가 아니라 이 입력의 `files`다. 끌어다 놓은 파일은 애초에 |
|
| 256 |
- * 입력을 거치지 않고, 고른 파일도 예전에는 "같은 파일을 다시 골라도 이벤트가 오게" 바로 |
|
| 257 |
- * 비웠다 — 그래서 `name`을 줘도 폼에는 아무것도 실리지 않았다(그 prop의 설명과 반대였다). |
|
| 258 |
- * 비우는 대신 지금 목록으로 덮으면 두 가지가 같이 해결된다: 제출에 실리고, 목록을 비웠을 때는 |
|
| 259 |
- * 입력도 비어 같은 파일을 다시 고를 수 있다. |
|
| 260 |
- */ |
|
| 261 | 250 |
const syncPicker = (nextFiles: File[]) => {
|
| 262 | 251 |
const picker = pickerRef.current; |
| 263 |
- // jsdom 등 DataTransfer가 없는 환경에서는 조용히 건너뛴다 — 화면 동작은 그대로다. |
|
| 264 | 252 |
if (!picker || typeof DataTransfer === "undefined") {
|
| 265 | 253 |
return; |
| 266 | 254 |
} |
... | ... | @@ -275,8 +263,6 @@ |
| 275 | 263 |
} |
| 276 | 264 |
onSelect?.(chosen); |
| 277 | 265 |
|
| 278 |
- // 제어·비제어를 가리지 않고 입력은 늘 맞춘다 — `name`은 목록을 누가 들고 있든 "고른 파일이 |
|
| 279 |
- // 폼에 실린다"는 약속이다. |
|
| 280 | 266 |
picked.current = multiple ? [...picked.current, ...chosen] : chosen.slice(0, 1); |
| 281 | 267 |
syncPicker(picked.current); |
| 282 | 268 |
|
... | ... | @@ -303,7 +289,6 @@ |
| 303 | 289 |
const handleRemove = (id: string) => {
|
| 304 | 290 |
onRemove?.(id); |
| 305 | 291 |
|
| 306 |
- // 밖에서 받은 기존 파일(수정 화면의 등록된 이미지)은 여기 없어 그대로 지나간다. |
|
| 307 | 292 |
picked.current = picked.current.filter((file) => fileKey(file) !== id); |
| 308 | 293 |
syncPicker(picked.current); |
| 309 | 294 |
|
--- @fox/core/components/fox-input/fox-input.tsx
+++ @fox/core/components/fox-input/fox-input.tsx
... | ... | @@ -40,7 +40,6 @@ |
| 40 | 40 |
onChange?: (value: string, event?: ChangeEvent<HTMLInputElement>) => void; |
| 41 | 41 |
onInput?: (value: string, event: FormEvent<HTMLInputElement>) => void; |
| 42 | 42 |
label?: string; |
| 43 |
- /** 라벨 뒤 필수·선택 표시. `label`이 없으면 의미 없다(`FoxSelect`와 같은 규약). */ |
|
| 44 | 43 |
requirement?: FoxFormLabelRequirement; |
| 45 | 44 |
/** 헬퍼 메시지. 색은 상태를 따라간다. */ |
| 46 | 45 |
message?: string; |
... | ... | @@ -58,22 +57,9 @@ |
| 58 | 57 |
/** 주면 모양이 그 상태로 고정된다. 없으면 포커스·값·비활성으로 브라우저가 판단한다. */ |
| 59 | 58 |
state?: FoxInputState; |
| 60 | 59 |
|
| 61 |
- /** |
|
| 62 |
- * 값 검증기. 여럿이면 오류가 합쳐지고(`foxValidators.compose`와 같다) 그중 하나가 문구가 된다. |
|
| 63 |
- * `type="password"`에는 `foxPasswordValidator(...)`를 그대로 얹으면 된다. |
|
| 64 |
- * |
|
| 65 |
- * 화면 검증은 **안내일 뿐 신뢰 경계가 아니다** — 저장 직전의 서버 검증이 최종 판정이다. |
|
| 66 |
- */ |
|
| 67 | 60 |
validators?: FoxValidator[]; |
| 68 |
- /** 오류 키별 문구 덮어쓰기. 주지 않으면 @fox의 기본 문구가 나온다. */ |
|
| 69 | 61 |
validationMessages?: FoxValidationMessages; |
| 70 |
- /** |
|
| 71 |
- * 언제부터 오류를 보여줄지. 기본 `blur` — 한 글자 쳤을 때 "10자 이상"이 뜨면 안내가 아니라 |
|
| 72 |
- * 방해가 된다. 한 번 벗어난 뒤로는 두 모드 모두 입력할 때마다 갱신된다(Angular의 `touched`와 |
|
| 73 |
- * 같은 판단이다. 다만 Angular의 `updateOn` 기본값은 `change`라 그 점만 다르다). |
|
| 74 |
- */ |
|
| 75 | 62 |
validateOn?: "blur" | "change"; |
| 76 |
- /** 검증 결과가 바뀔 때. 호출부가 저장 버튼을 잠그는 데 쓴다. */ |
|
| 77 | 63 |
onValidationChange?: (errors: FoxValidationErrors | null) => void; |
| 78 | 64 |
/** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ |
| 79 | 65 |
hidden?: boolean; |
... | ... | @@ -96,7 +82,6 @@ |
| 96 | 82 |
* ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` |
| 97 | 83 |
* (또는 개별 파티셜)로 한 번 불러와야 한다. |
| 98 | 84 |
*/ |
| 99 |
-/** 오류 객체를 비교 가능한 문자열로. 매 렌더 새 객체가 나와 참조로는 견줄 수 없다. */ |
|
| 100 | 85 |
function errorKey(errors: FoxValidationErrors | null): string {
|
| 101 | 86 |
return errors ? Object.keys(errors).sort().join("|") : "";
|
| 102 | 87 |
} |
... | ... | @@ -133,7 +118,6 @@ |
| 133 | 118 |
const [typedIn, setTypedIn] = useState(() => String(defaultValue ?? "").length > 0); |
| 134 | 119 |
const fieldRef = useRef<HTMLInputElement>(null); |
| 135 | 120 |
|
| 136 |
- // 값을 밖에서 들지 않는(비제어) 호출부도 있어 검증할 값을 직접 따라간다. |
|
| 137 | 121 |
const [draft, setDraft] = useState(() => String(defaultValue ?? "")); |
| 138 | 122 |
const [touched, setTouched] = useState(false); |
| 139 | 123 |
|
... | ... | @@ -144,8 +128,6 @@ |
| 144 | 128 |
) |
| 145 | 129 |
: null; |
| 146 | 130 |
|
| 147 |
- // 알림은 렌더가 끝난 뒤에 낸다 — 렌더 도중 부모 상태를 바꾸면 React가 경고한다. |
|
| 148 |
- // 매 렌더 새 객체가 나오므로 의존은 오류 **키 목록**으로 잡는다(값 자체는 ref로 읽는다). |
|
| 149 | 131 |
const errorsKey = errorKey(validationErrors); |
| 150 | 132 |
const latestRef = useRef({ errors: validationErrors, notify: onValidationChange });
|
| 151 | 133 |
useEffect(() => {
|
... | ... | @@ -168,10 +150,6 @@ |
| 168 | 150 |
? foxValidationMessage(validationErrors, validationMessages) |
| 169 | 151 |
: undefined; |
| 170 | 152 |
|
| 171 |
- // `message`는 평소엔 **헬퍼**이고 검증이 걸리면 그 문구에 자리를 내준다(시안이 그 자리에 |
|
| 172 |
- // 규칙 안내를 두고, 규칙을 어기면 같은 자리가 사유로 바뀐다). |
|
| 173 |
- // 다만 호출부가 `invalid`를 직접 켰으면 그쪽이 이긴다 — 서버가 돌려준 사유는 화면 규칙이 |
|
| 174 |
- // 알 수 없는 것이라(중복·권한 등) 덮이면 안 된다. |
|
| 175 | 153 |
const callerErrored = invalid || state === "error"; |
| 176 | 154 |
const shownMessage = callerErrored ? message : (validationText ?? message); |
| 177 | 155 |
const errored = callerErrored || validationText !== undefined; |
--- @fox/core/components/fox-list-container/fox-list-container.tsx
+++ @fox/core/components/fox-list-container/fox-list-container.tsx
... | ... | @@ -69,7 +69,6 @@ |
| 69 | 69 |
|
| 70 | 70 |
/** 검색 대상 목록. 주면 검색 상자가 나온다. 대상이 하나뿐이면 빈 배열로 두면 된다. */ |
| 71 | 71 |
searchFields?: FoxSelectItem[]; |
| 72 |
- /** 지금 **적용된** 검색 조건이다(입력 중인 값이 아니다). 초안은 컨테이너가 따로 든다. */ |
|
| 73 | 72 |
searchField?: string; |
| 74 | 73 |
keyword?: string; |
| 75 | 74 |
searchPlaceholder?: string; |
... | ... | @@ -136,16 +135,10 @@ |
| 136 | 135 |
hidden = false, |
| 137 | 136 |
className, |
| 138 | 137 |
}: FoxListContainerProps<T>) {
|
| 139 |
- // 검색 상자는 **누르기 전까지의 입력 초안**을 여기서 든다. `keyword`·`searchField`는 이미 |
|
| 140 |
- // 검색이 반영된 결과(주소·서버 응답)라, 그대로 FoxListSearch에 제어 값으로 내려보내면 |
|
| 141 |
- // 변경 핸들러가 없어 글자도 검색 대상도 바뀌지 않는다. |
|
| 142 | 138 |
const applied = { keyword: keyword ?? "", field: searchField };
|
| 143 | 139 |
const [draft, setDraft] = useState(applied); |
| 144 | 140 |
const [lastApplied, setLastApplied] = useState(applied); |
| 145 | 141 |
|
| 146 |
- // 검색이 끝나 밖의 값이 바뀌면 초안을 그 값으로 되돌린다 — 뒤로가기·초기화처럼 화면이 아니라 |
|
| 147 |
- // 주소가 검색어를 바꾸는 경로에서도 입력창이 따라간다. effect가 아니라 렌더 중에 맞추는 것은 |
|
| 148 |
- // React가 권하는 방식이다(한 번 더 그리지 않고 이 렌더에 반영된다). |
|
| 149 | 142 |
if ( |
| 150 | 143 |
lastApplied.keyword !== applied.keyword || |
| 151 | 144 |
lastApplied.field !== applied.field |
--- @fox/core/validation/fox-validation-messages.ts
+++ @fox/core/validation/fox-validation-messages.ts
... | ... | @@ -1,17 +1,5 @@ |
| 1 |
-/** |
|
| 2 |
- * 오류 객체 → 화면 문구. |
|
| 3 |
- * |
|
| 4 |
- * 검증기와 분리한 이유는 Angular가 `Validators`에 문구를 두지 않은 이유와 같다 — 같은 규칙도 |
|
| 5 |
- * 자리에 따라 다른 말로 안내해야 한다("10자 이상"과 "비밀번호는 10자 이상"). 검증기는 무엇이
|
|
| 6 |
- * 틀렸는지만 말하고, 그것을 무슨 말로 옮길지는 여기서(또는 호출부의 `overrides`로) 정한다. |
|
| 7 |
- * |
|
| 8 |
- * 한 번에 **한 문구만** 낸다 — 입력 칸 아래 헬퍼 자리가 한 줄이고, 오류를 쌓아 보여주면 어느 |
|
| 9 |
- * 것부터 고쳐야 할지 알기 어렵다. 순서는 `MESSAGE_ORDER`가 정한다(비어 있음 → 길이 → 조합). |
|
| 10 |
- */ |
|
| 11 |
- |
|
| 12 | 1 |
import type { FoxValidationErrors } from './fox-validators';
|
| 13 | 2 |
|
| 14 |
-/** 오류 키별 기본 문구. 값이 필요한 것은 오류 객체의 맥락을 받아 만든다. */ |
|
| 15 | 3 |
type MessageFactory = (detail: unknown) => string; |
| 16 | 4 |
|
| 17 | 5 |
function detailOf<T>(detail: unknown): T {
|
... | ... | @@ -41,7 +29,6 @@ |
| 41 | 29 |
equalTo: () => '값이 일치하지 않습니다.', |
| 42 | 30 |
}; |
| 43 | 31 |
|
| 44 |
-/** 먼저 고쳐야 하는 것부터. 여기 없는 키는 뒤에 남은 순서대로 본다. */ |
|
| 45 | 32 |
const MESSAGE_ORDER = [ |
| 46 | 33 |
'required', |
| 47 | 34 |
'minlength', |
... | ... | @@ -55,17 +42,11 @@ |
| 55 | 42 |
'equalTo', |
| 56 | 43 |
]; |
| 57 | 44 |
|
| 58 |
-/** 오류 키 → 문구 덮어쓰기. 문자열이면 그대로, 함수면 맥락을 받아 만든다. */ |
|
| 59 | 45 |
export type FoxValidationMessages = Record< |
| 60 | 46 |
string, |
| 61 | 47 |
string | ((detail: unknown) => string) |
| 62 | 48 |
>; |
| 63 | 49 |
|
| 64 |
-/** |
|
| 65 |
- * 오류 객체에서 보여줄 문구 하나를 고른다. 오류가 없으면 `undefined`. |
|
| 66 |
- * 덮어쓰기에 없는 키는 기본 문구로 떨어지고, 기본 문구에도 없으면 키를 그대로 쓰지 않고 |
|
| 67 |
- * `undefined`를 돌려준다 — 정체불명의 영문 키를 사용자에게 보이지 않기 위해서다. |
|
| 68 |
- */ |
|
| 69 | 50 |
export function foxValidationMessage( |
| 70 | 51 |
errors: FoxValidationErrors | null, |
| 71 | 52 |
overrides: FoxValidationMessages = {}
|
--- @fox/core/validation/fox-validators.ts
+++ @fox/core/validation/fox-validators.ts
... | ... | @@ -1,29 +1,11 @@ |
| 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 | 1 |
export type FoxValidationErrors = Record<string, unknown>; |
| 18 | 2 |
|
| 19 |
-/** 통과면 `null`, 아니면 오류 객체. */ |
|
| 20 | 3 |
export type FoxValidator = (value: string) => FoxValidationErrors | null; |
| 21 | 4 |
|
| 22 | 5 |
function isEmpty(value: string): boolean {
|
| 23 | 6 |
return value.trim().length === 0; |
| 24 | 7 |
} |
| 25 | 8 |
|
| 26 |
-/** 값이 비어 있으면 `{ required: true }`. */
|
|
| 27 | 9 |
function required(value: string): FoxValidationErrors | null {
|
| 28 | 10 |
return isEmpty(value) ? { required: true } : null;
|
| 29 | 11 |
} |
... | ... | @@ -55,7 +37,6 @@ |
| 55 | 37 |
}; |
| 56 | 38 |
} |
| 57 | 39 |
|
| 58 |
-/** 오탈자를 걸러내는 수준만 본다 — 이메일의 완전한 문법은 정규식으로 판정할 수 없다. */ |
|
| 59 | 40 |
function email(value: string): FoxValidationErrors | null {
|
| 60 | 41 |
if (isEmpty(value) || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
| 61 | 42 |
return null; |
... | ... | @@ -63,7 +44,6 @@ |
| 63 | 44 |
return { email: true };
|
| 64 | 45 |
} |
| 65 | 46 |
|
| 66 |
-/** 비밀번호 조합에 쓰는 문자 종류. 대문자는 소문자와 한 종류로 센다(시안이 그렇게 묻는다). */ |
|
| 67 | 47 |
const CHARACTER_KIND_PATTERNS = {
|
| 68 | 48 |
letter: /[a-zA-Z]/, |
| 69 | 49 |
digit: /\d/, |
... | ... | @@ -72,14 +52,12 @@ |
| 72 | 52 |
|
| 73 | 53 |
export type FoxCharacterKind = keyof typeof CHARACTER_KIND_PATTERNS; |
| 74 | 54 |
|
| 75 |
-/** 영문·숫자·특수문자 중 몇 종류가 섞였는지 센다. */ |
|
| 76 | 55 |
function countCharacterKinds(value: string): FoxCharacterKind[] {
|
| 77 | 56 |
return (Object.keys(CHARACTER_KIND_PATTERNS) as FoxCharacterKind[]).filter( |
| 78 | 57 |
(kind) => CHARACTER_KIND_PATTERNS[kind].test(value) |
| 79 | 58 |
); |
| 80 | 59 |
} |
| 81 | 60 |
|
| 82 |
-/** "영문·숫자·특수문자 중 N종류 이상" — 비밀번호 규칙의 가장 흔한 형태다. */ |
|
| 83 | 61 |
function characterKinds(requiredKinds: number): FoxValidator {
|
| 84 | 62 |
return (value) => {
|
| 85 | 63 |
if (isEmpty(value)) {
|
... | ... | @@ -93,7 +71,6 @@ |
| 93 | 71 |
}; |
| 94 | 72 |
} |
| 95 | 73 |
|
| 96 |
-/** 같은 문자가 `max`회를 넘겨 연달아 오면 오류(`aaa`). */ |
|
| 97 | 74 |
function noRepeatedCharacters(max: number): FoxValidator {
|
| 98 | 75 |
return (value) => {
|
| 99 | 76 |
if (isEmpty(value)) {
|
... | ... | @@ -110,7 +87,6 @@ |
| 110 | 87 |
}; |
| 111 | 88 |
} |
| 112 | 89 |
|
| 113 |
-/** 코드값이 잇따르는 문자가 `max`회를 넘기면 오류(`abcd`·`4321`). 오름·내림 둘 다 본다. */ |
|
| 114 | 90 |
function noSequentialCharacters(max: number): FoxValidator {
|
| 115 | 91 |
return (value) => {
|
| 116 | 92 |
if (isEmpty(value)) {
|
... | ... | @@ -130,12 +106,6 @@ |
| 130 | 106 |
}; |
| 131 | 107 |
} |
| 132 | 108 |
|
| 133 |
-/** |
|
| 134 |
- * 지정한 문자열을 품고 있으면 오류. 비밀번호에 ID·이름을 넣지 못하게 하는 규칙이다. |
|
| 135 |
- * |
|
| 136 |
- * 값을 함수로도 받는 이유는 그 대상이 **다른 칸의 지금 값**이기 때문이다 — 배열로 굳혀 두면 |
|
| 137 |
- * ID를 고친 뒤에도 옛 ID로 검사한다. |
|
| 138 |
- */ |
|
| 139 | 109 |
function notContaining( |
| 140 | 110 |
forbidden: string[] | (() => string[]), |
| 141 | 111 |
options: { caseSensitive?: boolean } = {}
|
... | ... | @@ -157,16 +127,10 @@ |
| 157 | 127 |
}; |
| 158 | 128 |
} |
| 159 | 129 |
|
| 160 |
-/** 다른 칸과 값이 같아야 한다(비밀번호 확인). 대상이 바뀌므로 함수로 받는다. */ |
|
| 161 | 130 |
function equalTo(other: () => string): FoxValidator {
|
| 162 | 131 |
return (value) => (isEmpty(value) || value === other() ? null : { equalTo: true });
|
| 163 | 132 |
} |
| 164 | 133 |
|
| 165 |
-/** |
|
| 166 |
- * 여러 검증기를 하나로 합친다. **통과하지 못한 것들의 오류 객체를 전부 병합해** 돌려준다 |
|
| 167 |
- * (Angular의 `Validators.compose`와 같다) — 첫 오류에서 멈추지 않아야 "10자 이상"과 |
|
| 168 |
- * "2종류 이상"을 함께 안내할 수 있다. |
|
| 169 |
- */ |
|
| 170 | 134 |
function compose(validators: FoxValidator[]): FoxValidator {
|
| 171 | 135 |
return (value) => {
|
| 172 | 136 |
const merged = validators.reduce<FoxValidationErrors>((acc, validate) => {
|
... | ... | @@ -192,25 +156,15 @@ |
| 192 | 156 |
}; |
| 193 | 157 |
|
| 194 | 158 |
export interface FoxPasswordPolicy {
|
| 195 |
- /** 기본 8. 시안이 더 길게 요구하면 그 값을 준다. */ |
|
| 196 | 159 |
minLength?: number; |
| 197 | 160 |
maxLength?: number; |
| 198 |
- /** 영문·숫자·특수문자 중 최소 몇 종류를 섞을지. 기본 2. */ |
|
| 199 | 161 |
kinds?: number; |
| 200 |
- /** 같은 문자 연속 허용 횟수. 주지 않으면 검사하지 않는다. */ |
|
| 201 | 162 |
maxRepeated?: number; |
| 202 |
- /** 잇따르는 문자 허용 길이(`abc`). 주지 않으면 검사하지 않는다. */ |
|
| 203 | 163 |
maxSequential?: number; |
| 204 |
- /** 비밀번호에 들어가면 안 되는 값(보통 ID·이름). 지금 값을 읽도록 함수로 줄 수 있다. */ |
|
| 205 | 164 |
forbidden?: string[] | (() => string[]); |
| 206 |
- /** 참이면 비어 있는 것도 오류다. 수정 화면처럼 "비우면 유지"인 자리에서는 끈다. */ |
|
| 207 | 165 |
required?: boolean; |
| 208 | 166 |
} |
| 209 | 167 |
|
| 210 |
-/** |
|
| 211 |
- * 비밀번호 칸의 규칙 한 벌. `type="password"` 입력에 그대로 얹으라고 둔 조합이다 — |
|
| 212 |
- * 규칙 자체는 위 검증기들이고, 이 함수는 자주 쓰는 묶음에 이름을 붙인 것뿐이다. |
|
| 213 |
- */ |
|
| 214 | 168 |
export function foxPasswordValidator(policy: FoxPasswordPolicy = {}): FoxValidator {
|
| 215 | 169 |
const {
|
| 216 | 170 |
minLength: min = 8, |
--- @fox/styles/_fox-chip-area.scss
+++ @fox/styles/_fox-chip-area.scss
... | ... | @@ -14,9 +14,6 @@ |
| 14 | 14 |
// |
| 15 | 15 |
// `align-items: center`가 필요한 이유는 칩의 크기(높이)가 섞일 수 있어서다. `FoxChipArea`의 |
| 16 | 16 |
// `size`로 통일하면 섞이지 않지만, 통일하지 않고 쓰는 것도 막지 않는다. |
| 17 |
-// |
|
| 18 |
-// 기본은 한 줄이다 — 폭이 모자라면 칩이 눌리는 게 아니라 넘친다. 여러 줄로 흘러야 하는 자리는 |
|
| 19 |
-// `--wrap`을 건다(시안 ADM_ADM_102_p의 "메뉴 선택"이 칩을 두 줄로 감는다). |
|
| 20 | 17 |
|
| 21 | 18 |
@use "@fox/styles/abstracts" as fox; |
| 22 | 19 |
|
--- app/(protected)/(basic)/admins/_actions.ts
+++ app/(protected)/(basic)/admins/_actions.ts
... | ... | @@ -20,24 +20,6 @@ |
| 20 | 20 |
} from '@/lib/domain/admin-member-form'; |
| 21 | 21 |
import { ADMIN_MEMBERS_PATH } from '@/lib/domain/admin-member-query';
|
| 22 | 22 |
|
| 23 |
-/** |
|
| 24 |
- * 관리자 회원 등록/수정/삭제 Server Action. |
|
| 25 |
- * |
|
| 26 |
- * **모든 Action이 `verifySession()`으로 시작한다** — Server Action은 UI를 거치지 않고 직접 |
|
| 27 |
- * POST될 수 있어 이 확인이 유일한 최종 방어선이다(설계서 §8). |
|
| 28 |
- * |
|
| 29 |
- * 검증은 화면이 아니라 여기서 확정한다(`lib/domain/admin-member-form.ts`의 규칙을 호출) — |
|
| 30 |
- * 화면의 required 속성은 편의일 뿐 신뢰 경계가 아니다. |
|
| 31 |
- * |
|
| 32 |
- * 실제 저장은 Repository에 맡긴다 — 어느 항목이 백엔드로 가고 어느 것이 mock인지는 이 파일이 |
|
| 33 |
- * 알지 못한다(현재 삭제만 mock이다). |
|
| 34 |
- * |
|
| 35 |
- * `AdminMemberFormState` 타입과 그 초깃값(`INITIAL_ADMIN_MEMBER_FORM_STATE`)은 이 파일이 아니라 |
|
| 36 |
- * `lib/domain/admin-member-form.ts`에 있다 — Next.js가 `'use server'` 파일에서 함수가 아닌 값을 |
|
| 37 |
- * export하는 것을 런타임에 거부하기 때문이다(해당 타입 주석 참조). 타입만 이 파일에서 다시 쓰는 |
|
| 38 |
- * 것은 문제 없다 — 타입은 컴파일 시 지워져 런타임 export로 남지 않는다. |
|
| 39 |
- */ |
|
| 40 |
- |
|
| 41 | 23 |
const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.'; |
| 42 | 24 |
const SELF_DELETE_MESSAGE = '현재 로그인한 본인 계정은 삭제할 수 없습니다.'; |
| 43 | 25 |
const DUPLICATE_LOGIN_ID_MESSAGE = '이미 사용 중인 ID입니다.'; |
... | ... | @@ -49,7 +31,6 @@ |
| 49 | 31 |
return typeof value === 'string' ? value : ''; |
| 50 | 32 |
} |
| 51 | 33 |
|
| 52 |
-/** 체크박스처럼 같은 이름으로 여러 번 오는 값 — 허용 목록에 있는 것만 남긴다. */ |
|
| 53 | 34 |
function readMenuCodes(formData: FormData): string[] {
|
| 54 | 35 |
return formData |
| 55 | 36 |
.getAll('menuCodes')
|
... | ... | @@ -59,10 +40,6 @@ |
| 59 | 40 |
); |
| 60 | 41 |
} |
| 61 | 42 |
|
| 62 |
-/** |
|
| 63 |
- * 등록·수정이 공유하는 입력 항목을 읽는다. 휴대전화번호는 숫자만 담겨 오므로 여기서 저장 형식으로 |
|
| 64 |
- * 바꾼다 — 자릿수가 어긋나면 빈 문자열이 되어 검증에서 걸린다. |
|
| 65 |
- */ |
|
| 66 | 43 |
function readEditableValues(formData: FormData): AdminMemberEditableValues {
|
| 67 | 44 |
const phoneNumber = formatPhoneNumber(readString(formData, 'phoneNumber')); |
| 68 | 45 |
|
... | ... | @@ -75,13 +52,6 @@ |
| 75 | 52 |
}; |
| 76 | 53 |
} |
| 77 | 54 |
|
| 78 |
-/** |
|
| 79 |
- * 쓰기 호출의 백엔드 실패를 폼 상태로 바꾼다. 실패하면 그 상태를, 성공하면 null을 돌려준다. |
|
| 80 |
- * |
|
| 81 |
- * 백엔드가 주는 문구는 그대로 보여준다 — 등록 시 아이디 선점처럼 사용자가 조치할 수 있는 사유가 |
|
| 82 |
- * 이 경로로 온다("이미 등록된 아이디 입니다"). 통신 오류·타임아웃은 `backend-fetch`가 이미
|
|
| 83 |
- * 일반화된 문구로 바꿔 두므로 내부 사정이 새어 나가지 않는다. |
|
| 84 |
- */ |
|
| 85 | 55 |
async function runWrite( |
| 86 | 56 |
write: () => Promise<void> |
| 87 | 57 |
): Promise<AdminMemberFormState | null> {
|
... | ... | @@ -96,7 +66,6 @@ |
| 96 | 66 |
} |
| 97 | 67 |
} |
| 98 | 68 |
|
| 99 |
-/** 시안 ADM_ADM_102_p — 관리자 등록. */ |
|
| 100 | 69 |
export async function createAdminMemberAction( |
| 101 | 70 |
_prevState: AdminMemberFormState, |
| 102 | 71 |
formData: FormData |
... | ... | @@ -116,13 +85,10 @@ |
| 116 | 85 |
const { name, loginId, password, phoneNumber, email, roleCode } =
|
| 117 | 86 |
validation.values; |
| 118 | 87 |
|
| 119 |
- // 화면의 [중복 확인]은 편의 기능일 뿐이라 저장 직전에 다시 확인한다 — 확인을 누르지 않고 |
|
| 120 |
- // 직접 제출하는 경로가 열려 있고, 확인 후 저장까지의 사이에 선점될 수도 있다. |
|
| 121 | 88 |
if (await isAdminLoginIdTaken(loginId)) {
|
| 122 | 89 |
return { status: 'error', errors: { loginId: DUPLICATE_LOGIN_ID_MESSAGE } };
|
| 123 | 90 |
} |
| 124 | 91 |
|
| 125 |
- // 메뉴 선택은 넘기지 않는다 — 백엔드에 저장할 곳이 없다(Repository 주석 참조). |
|
| 126 | 92 |
const failure = await runWrite(() => |
| 127 | 93 |
createAdminMember({ name, loginId, password, phoneNumber, email, roleCode })
|
| 128 | 94 |
); |
... | ... | @@ -134,7 +100,6 @@ |
| 134 | 100 |
return { status: 'success' };
|
| 135 | 101 |
} |
| 136 | 102 |
|
| 137 |
-/** 시안 ADM_ADM_103_p — 관리자 수정. 이름·ID는 읽기 전용이라 변경 대상이 아니다. */ |
|
| 138 | 103 |
export async function updateAdminMemberAction( |
| 139 | 104 |
_prevState: AdminMemberFormState, |
| 140 | 105 |
formData: FormData |
... | ... | @@ -159,7 +124,6 @@ |
| 159 | 124 |
phoneNumber, |
| 160 | 125 |
email, |
| 161 | 126 |
roleCode, |
| 162 |
- // 시안 ③의 "잠김여부"는 활성/비활성으로 표기되고 활성이 곧 잠기지 않은 상태다. |
|
| 163 | 127 |
isLocked: readString(formData, 'isLocked') === 'true', |
| 164 | 128 |
}) |
| 165 | 129 |
); |
... | ... | @@ -171,12 +135,6 @@ |
| 171 | 135 |
return { status: 'success' };
|
| 172 | 136 |
} |
| 173 | 137 |
|
| 174 |
-/** |
|
| 175 |
- * 시안 ADM_ADM_101 ⑤ — 삭제. 확인 얼럿은 화면이 띄우고, 여기서는 권한과 자기 계정만 확인한다. |
|
| 176 |
- * |
|
| 177 |
- * 폼 제출이 아니라 얼럿의 [삭제] 클릭에 반응하는 단발 호출이라 `useActionState`의 |
|
| 178 |
- * (prevState, formData) 규약 대신 id를 직접 받는다. |
|
| 179 |
- */ |
|
| 180 | 138 |
export async function deleteAdminMemberAction( |
| 181 | 139 |
id: string |
| 182 | 140 |
): Promise<AdminMemberFormState> {
|
... | ... | @@ -186,10 +144,6 @@ |
| 186 | 144 |
return { status: 'error', message: INVALID_REQUEST_MESSAGE };
|
| 187 | 145 |
} |
| 188 | 146 |
|
| 189 |
- // 시안의 "최고관리자 본인 계정 삭제 방지". 세션의 adminId는 백엔드 accessToken의 `adminId` |
|
| 190 |
- // 클레임이고 그 값이 곧 목록의 `admUserId`라(EgovJwtTokenUtil.generateAccessToken) 그대로 |
|
| 191 |
- // 비교할 수 있다. 역할과 무관하게 "로그인한 본인"을 막는다 — 자기 계정을 지워 스스로 |
|
| 192 |
- // 로그인 불가 상태가 되는 것은 어떤 역할이든 사고이기 때문이다. |
|
| 193 | 147 |
if (id === admin.id) {
|
| 194 | 148 |
return { status: 'error', message: SELF_DELETE_MESSAGE };
|
| 195 | 149 |
} |
... | ... | @@ -203,22 +157,9 @@ |
| 203 | 157 |
export type LoginIdCheckResult = |
| 204 | 158 |
| { status: 'idle' }
|
| 205 | 159 |
| { status: 'available'; loginId: string }
|
| 206 |
- /** 쓸 수 없는 ID — 형식 오류이거나 이미 선점됐다. */ |
|
| 207 | 160 |
| { status: 'unavailable'; message: string }
|
| 208 |
- /** 확인 자체를 못 했다(통신·권한 등). 값의 판정이 아니라 **확인 실패**다. */ |
|
| 209 | 161 |
| { status: 'failed'; message: string };
|
| 210 | 162 |
|
| 211 |
-/** |
|
| 212 |
- * 시안 ADM_ADM_102_p ① — ID 중복 확인. |
|
| 213 |
- * |
|
| 214 |
- * 폼 제출이 아니라 버튼 클릭에 반응하는 단발 호출이라 `useActionState`의 (prevState, formData) |
|
| 215 |
- * 규약 대신 값을 직접 받는다 — 등록 폼 안에 또 다른 폼을 중첩할 수 없기 때문이다(HTML 제약). |
|
| 216 |
- * 클라이언트에서 일반 함수처럼 `await` 한다. |
|
| 217 |
- * |
|
| 218 |
- * 형식이 맞지 않는 ID는 중복 여부를 물을 필요도 없이 되돌린다. 확인에 성공하면 **검사한 ID를 함께** |
|
| 219 |
- * 돌려주는데, 화면이 "확인한 ID"와 "지금 입력창의 ID"를 비교해 확인 후 값을 고친 경우를 잡아내기 |
|
| 220 |
- * 위해서다. |
|
| 221 |
- */ |
|
| 222 | 163 |
export async function checkAdminLoginId( |
| 223 | 164 |
loginId: string |
| 224 | 165 |
): Promise<LoginIdCheckResult> {
|
... | ... | @@ -231,8 +172,6 @@ |
| 231 | 172 |
return { status: 'unavailable', message: formatError };
|
| 232 | 173 |
} |
| 233 | 174 |
|
| 234 |
- // 확인 호출이 실패하면 그 사유를 화면에 그대로 돌려준다 — 예외로 두면 클라이언트의 |
|
| 235 |
- // transition에서 처리되지 않은 rejection이 되어 버튼만 원복되고 아무 말도 남지 않는다. |
|
| 236 | 175 |
try {
|
| 237 | 176 |
if (await isAdminLoginIdTaken(normalized)) {
|
| 238 | 177 |
return { status: 'unavailable', message: DUPLICATE_LOGIN_ID_MESSAGE };
|
--- app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx
... | ... | @@ -45,7 +45,6 @@ |
| 45 | 45 |
onClose: () => void; |
| 46 | 46 |
} |
| 47 | 47 |
|
| 48 |
-/** 시안의 도메인 목록. 맨 아래 "직접입력" 항목은 `FoxEmail`이 스스로 더한다. */ |
|
| 49 | 48 |
const EMAIL_DOMAIN_OPTIONS = [ |
| 50 | 49 |
'naver.com', |
| 51 | 50 |
'gmail.com', |
... | ... | @@ -54,24 +53,6 @@ |
| 54 | 53 |
'nate.com', |
| 55 | 54 |
]; |
| 56 | 55 |
|
| 57 |
-/** |
|
| 58 |
- * 관리자 등록 팝업 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) ADM_ADM_102_p (5227:2035) |
|
| 59 |
- * |
|
| 60 |
- * **ID는 중복 확인을 통과해야 저장할 수 있다**(시안 ①). 확인은 등록 폼과 별개의 서버 호출인데, |
|
| 61 |
- * 폼 안에 폼을 중첩할 수 없어 `useActionState` 대신 값을 직접 넘기는 Server Action |
|
| 62 |
- * (`checkAdminLoginId`)을 `useTransition`으로 호출한다. 확인 후 사용자가 ID를 고칠 수 있으므로 |
|
| 63 |
- * "확인에 성공한 ID"와 "지금 입력창의 값"이 같을 때만 통과로 본다 — 확인만 받아 두고 다른 ID로 |
|
| 64 |
- * 바꿔 저장하는 경로를 막기 위해서다. 그래도 최종 방어선은 Server Action의 재확인이다. |
|
| 65 |
- * |
|
| 66 |
- * 저장 버튼은 `FoxModal`의 actions 슬롯에 있어 폼 **바깥**에 그려진다. `FoxButton`에는 네이티브 |
|
| 67 |
- * `form` 속성이 없으므로 폼을 ref로 잡아 `requestSubmit()`을 부른다 — `useActionState`의 폼 |
|
| 68 |
- * 액션이 그대로 타는 정식 제출이라 검증·상태 흐름이 동일하다. |
|
| 69 |
- * |
|
| 70 |
- * @fox의 제어 위젯(전화번호·이메일·역할·메뉴)은 값이 FormData에 실리지 않아 이 컴포넌트가 값을 |
|
| 71 |
- * 들고 hidden input으로 제출한다(`AdminMemberFormFields` 주석 참조). |
|
| 72 |
- * |
|
| 73 |
- * 비밀번호는 형식만 검증하고 백엔드가 SHA-256으로 암호화해 저장한다(`MngrAdminServiceImpl`). |
|
| 74 |
- */ |
|
| 75 | 56 |
export function AdminMemberCreateModal({
|
| 76 | 57 |
onClose, |
| 77 | 58 |
}: AdminMemberCreateModalProps) {
|
... | ... | @@ -88,13 +69,8 @@ |
| 88 | 69 |
}); |
| 89 | 70 |
const [isChecking, startChecking] = useTransition(); |
| 90 | 71 |
|
| 91 |
- // 앞자리는 고르지 않아도 되도록 기본값으로 시작한다(상수 주석 참조). |
|
| 92 | 72 |
const [phoneNumber, setPhoneNumber] = useState(DEFAULT_MOBILE_PHONE_PREFIX); |
| 93 |
- // `FoxEmail`이 아이디·도메인을 스스로 나눠 들고 합친 값 하나를 내보낸다. |
|
| 94 |
- // 도메인은 시안처럼 첫 항목이 골라진 채로 시작한다(아이디만 적으면 되게). |
|
| 95 | 73 |
const [emailValue, setEmailValue] = useState(`@${EMAIL_DOMAIN_OPTIONS[0]}`);
|
| 96 |
- // 이메일은 칸이 둘로 나뉘어 있어 검증 대상이 **합친 값**이다 — FoxInput 한 칸이 스스로 |
|
| 97 |
- // 판정할 수 없어 여기서 돌리고, `touched`도 직접 든다(FoxInput의 blur 규칙과 같은 시점). |
|
| 98 | 74 |
const [isEmailTouched, setIsEmailTouched] = useState(false); |
| 99 | 75 |
const [roleCode, setRoleCode] = useState<string>(DEFAULT_ADMIN_ROLE_CODE); |
| 100 | 76 |
const [menuCodes, setMenuCodes] = useState<string[]>([]); |
... | ... | @@ -111,9 +87,6 @@ |
| 111 | 87 |
|
| 112 | 88 |
const errors = state.status === 'error' ? (state.errors ?? {}) : {};
|
| 113 | 89 |
|
| 114 |
- // 둘 중 하나라도 비면 **빈 이메일**로 본다 — 도메인만 고른 상태(`@naver.com`)를 값으로 보내면 |
|
| 115 |
- // "형식이 올바르지 않습니다"가 뜨지만, 사용자가 한 일은 아무것도 입력하지 않은 것이다. |
|
| 116 |
- // (`FoxEmail`은 한쪽만 채워도 `@`를 붙여 내보내므로 여기서 다시 가른다.) |
|
| 117 | 90 |
const [emailLocal = '', emailDomain = ''] = emailValue.split('@');
|
| 118 | 91 |
const email = emailLocal && emailDomain ? emailValue : ''; |
| 119 | 92 |
|
... | ... | @@ -126,15 +99,10 @@ |
| 126 | 99 |
) |
| 127 | 100 |
: undefined); |
| 128 | 101 |
|
| 129 |
- // 중복 확인 결과는 화면 규칙보다 먼저 보여준다 — 방금 누른 버튼의 답이기 때문이다. |
|
| 130 |
- // 값이 나쁜 것(unavailable)과 확인을 못 한 것(failed)은 사유가 다르므로 둘 다 문구로 낸다. |
|
| 131 | 102 |
const loginIdFailure = |
| 132 | 103 |
checkResult.status === 'unavailable' || checkResult.status === 'failed' |
| 133 | 104 |
? checkResult.message |
| 134 | 105 |
: undefined; |
| 135 |
- // **서버 오류는 성공 문구를 이긴다** — 확인을 통과한 뒤에도 저장이 ID 때문에 거부될 수 있는데 |
|
| 136 |
- // (확인과 저장 사이의 선점, 형식 규칙 변경), '사용할 수 있는 ID입니다.'가 계속 떠 있으면 |
|
| 137 |
- // 거부된 사실이 화면에서 사라진다. |
|
| 138 | 106 |
const loginIdMessage = |
| 139 | 107 |
loginIdFailure ?? |
| 140 | 108 |
errors.loginId ?? |
... | ... | @@ -183,7 +151,6 @@ |
| 183 | 151 |
onChange={setLoginId}
|
| 184 | 152 |
message={loginIdMessage}
|
| 185 | 153 |
messageIcon={<FoxChatCenteredDotsIcon />}
|
| 186 |
- // 중복 확인 결과·서버 오류는 화면 규칙이 알 수 없는 사유라 이쪽이 이긴다. |
|
| 187 | 154 |
invalid={Boolean(loginIdFailure) || Boolean(errors.loginId)}
|
| 188 | 155 |
validators={ADMIN_LOGIN_ID_VALIDATORS}
|
| 189 | 156 |
validationMessages={ADMIN_LOGIN_ID_MESSAGES}
|
... | ... | @@ -200,8 +167,6 @@ |
| 200 | 167 |
try {
|
| 201 | 168 |
setCheckResult(await checkAdminLoginId(loginId)); |
| 202 | 169 |
} catch {
|
| 203 |
- // Server Action 호출 자체가 깨진 경우(네트워크 등). 조용히 끝나면 버튼만 |
|
| 204 |
- // 원복되고 아무 말도 남지 않아, 사유를 알 수 없어도 실패는 알린다. |
|
| 205 | 170 |
setCheckResult({
|
| 206 | 171 |
status: 'failed', |
| 207 | 172 |
message: '중복 확인에 실패했습니다. 잠시 후 다시 시도해 주세요.', |
... | ... | @@ -223,7 +188,6 @@ |
| 223 | 188 |
message={errors.password ?? ADMIN_PASSWORD_HELP_TEXT}
|
| 224 | 189 |
messageIcon={<FoxChatCenteredDotsIcon />}
|
| 225 | 190 |
invalid={Boolean(errors.password)}
|
| 226 |
- // 규칙은 도메인이 한 벌로 갖는다 — 저장 직전 서버 검증이 같은 값을 본다. |
|
| 227 | 191 |
validators={[foxPasswordValidator({ ...ADMIN_PASSWORD_POLICY, required: true })]}
|
| 228 | 192 |
validationMessages={{ minlength: ADMIN_PASSWORD_ERROR_TEXT }}
|
| 229 | 193 |
/> |
--- app/(protected)/(basic)/admins/_components/admin-member-edit-modal.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-edit-modal.tsx
... | ... | @@ -31,27 +31,6 @@ |
| 31 | 31 |
onClose: () => void; |
| 32 | 32 |
} |
| 33 | 33 |
|
| 34 |
-/** |
|
| 35 |
- * 관리자 수정 팝업 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) ADM_ADM_103_p (5227:3351) |
|
| 36 |
- * |
|
| 37 |
- * 이름·ID는 읽기 전용이다(시안 ①). 화면의 readOnly는 표시일 뿐이지만 Server Action도 두 값을 아예 |
|
| 38 |
- * 읽지 않고, 백엔드 수정 API 역시 받지 않는다(`MngrAdminUpdateRequestVo`에 필드가 없다) — 세 겹이 |
|
| 39 |
- * 같은 말을 한다. |
|
| 40 |
- * |
|
| 41 |
- * **비밀번호는 [비밀번호 변경]을 눌러야 입력할 수 있고, 비워 두면 바꾸지 않는다**(시안 ②). |
|
| 42 |
- * 백엔드 UPDATE의 `LOGIN_PW`가 `<if test='loginPw != null and loginPw != ""'>`로 감싸여 있어 |
|
| 43 |
- * 빈 값은 SET 절에서 빠진다. (예전에는 조건 없이 덮어써서 빈 값을 보내면 그 계정이 로그인 불가가 |
|
| 44 |
- * 됐고, 그래서 수정 시 비밀번호를 필수로 막아 두었다 — 그 제약은 이제 없다.) |
|
| 45 |
- * |
|
| 46 |
- * 이메일은 시안이 **단일 입력**이다(등록 팝업의 도메인 셀렉트가 없다). 두 시안의 차이를 그대로 |
|
| 47 |
- * 따른다 — 수정은 이미 있는 주소를 고치는 자리라 도메인만 고르는 형태가 맞지 않는다. |
|
| 48 |
- * |
|
| 49 |
- * 휴대전화번호·이메일·역할은 이제 목록 응답에 담겨 오므로 **기존 값으로 채운다**(백엔드가 |
|
| 50 |
- * `ADM_TEL_NO`·`ADM_EML_ADDR`을 select 목록에 넣었다). |
|
| 51 |
- * |
|
| 52 |
- * "잠김여부"는 시안이 활성/비활성 토글이고 백엔드 필드는 `acctLockYn`(잠김 여부)이라 의미가 |
|
| 53 |
- * 뒤집혀 있다 — 켜짐이 곧 "잠기지 않음"이다. |
|
| 54 |
- */ |
|
| 55 | 34 |
export function AdminMemberEditModal({
|
| 56 | 35 |
member, |
| 57 | 36 |
onClose, |
... | ... | @@ -64,7 +43,6 @@ |
| 64 | 43 |
const formRef = useRef<HTMLFormElement>(null); |
| 65 | 44 |
|
| 66 | 45 |
const [isPasswordEditable, setIsPasswordEditable] = useState(false); |
| 67 |
- // 저장된 번호가 없으면 등록 팝업과 같이 기본 앞자리로 시작한다. |
|
| 68 | 46 |
const [phoneNumber, setPhoneNumber] = useState( |
| 69 | 47 |
() => toPhoneDigits(member.phoneNumber) || DEFAULT_MOBILE_PHONE_PREFIX |
| 70 | 48 |
); |
... | ... | @@ -104,7 +82,6 @@ |
| 104 | 82 |
} |
| 105 | 83 |
> |
| 106 | 84 |
<form ref={formRef} onSubmit={(event) => submitFormAction(event, formAction)} className={styles.formList}>
|
| 107 |
- {/* 수정 대상을 가리키는 유일한 입력. 이름·ID는 읽기 전용이라 아예 제출하지 않는다. */}
|
|
| 108 | 85 |
<input type="hidden" name="id" value={member.id} />
|
| 109 | 86 |
|
| 110 | 87 |
<FoxInput |
... | ... | @@ -141,7 +118,6 @@ |
| 141 | 118 |
isPasswordEditable ? <FoxChatCenteredDotsIcon /> : undefined |
| 142 | 119 |
} |
| 143 | 120 |
invalid={Boolean(errors.password)}
|
| 144 |
- // 비우면 "바꾸지 않음"이라 required를 걸지 않는다(등록 팝업과 다른 점). |
|
| 145 | 121 |
validators={[foxPasswordValidator(ADMIN_PASSWORD_POLICY)]}
|
| 146 | 122 |
validationMessages={{ minlength: ADMIN_PASSWORD_ERROR_TEXT }}
|
| 147 | 123 |
/> |
... | ... | @@ -174,7 +150,6 @@ |
| 174 | 150 |
onChange={setEmail}
|
| 175 | 151 |
message={errors.email}
|
| 176 | 152 |
invalid={Boolean(errors.email)}
|
| 177 |
- // 수정 시안에는 `*`가 없다 — 비우면 이메일을 지우는 것이고 오류가 아니다. |
|
| 178 | 153 |
validators={adminEmailValidators(false)}
|
| 179 | 154 |
validationMessages={ADMIN_EMAIL_MESSAGES}
|
| 180 | 155 |
/> |
... | ... | @@ -190,7 +165,6 @@ |
| 190 | 165 |
onChange={setIsActive}
|
| 191 | 166 |
/> |
| 192 | 167 |
</div> |
| 193 |
- {/* 화면은 활성(잠기지 않음)을 보여주고 서버는 잠김 여부를 받는다 — 여기서 뒤집는다. */}
|
|
| 194 | 168 |
<input type="hidden" name="isLocked" value={String(!isActive)} />
|
| 195 | 169 |
|
| 196 | 170 |
{state.status === 'error' && state.message && (
|
--- app/(protected)/(basic)/admins/_components/admin-member-form-fields.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-form-fields.tsx
... | ... | @@ -15,36 +15,16 @@ |
| 15 | 15 |
import styles from './admin-member-modal.module.scss'; |
| 16 | 16 |
|
| 17 | 17 |
interface AdminMemberFormFieldsProps {
|
| 18 |
- /** 숫자만 담긴 휴대전화번호. 하이픈은 화면이 그리고 값에는 넣지 않는다. */ |
|
| 19 | 18 |
phoneNumber: string; |
| 20 | 19 |
onPhoneNumberChange: (phoneNumber: string) => void; |
| 21 | 20 |
roleCode: string; |
| 22 | 21 |
onRoleCodeChange: (roleCode: string) => void; |
| 23 | 22 |
menuCodes: string[]; |
| 24 | 23 |
onMenuCodesChange: (menuCodes: string[]) => void; |
| 25 |
- /** |
|
| 26 |
- * 휴대전화 번호와 역할 선택 **사이**에 들어가는 이메일 칸. 두 시안이 이 자리에 서로 다른 것을 |
|
| 27 |
- * 놓아(등록은 도메인 셀렉트가 붙은 form-email, 수정은 단일 input) 슬롯으로 받는다 — 자리 순서는 |
|
| 28 |
- * 두 시안이 같으므로 여기가 갖고, 무엇을 놓을지는 각 팝업이 정한다. |
|
| 29 |
- */ |
|
| 30 | 24 |
emailField: ReactNode; |
| 31 | 25 |
errors: AdminMemberFormErrors; |
| 32 | 26 |
} |
| 33 | 27 |
|
| 34 |
-/** |
|
| 35 |
- * 등록·수정 팝업이 공유하는 입력 항목 — 휴대전화 번호 / (이메일 슬롯) / 역할 선택 / 메뉴 선택. |
|
| 36 |
- * 두 시안(ADM_ADM_102_p 5227:2035 / ADM_ADM_103_p 5227:3351)에서 자리와 순서가 같은 부분이다. |
|
| 37 |
- * 이름·ID·비밀번호·잠김여부는 두 시안이 서로 달라 각 팝업이 직접 그린다. |
|
| 38 |
- * |
|
| 39 |
- * 세 칸 모두 @fox의 제어 위젯이라 값이 FormData에 실리지 않는다(`FoxSelect`는 네이티브 select가 |
|
| 40 |
- * 아니라 버튼+리스트박스, `FoxPhoneNumber`·칩도 마찬가지다). 그래서 값은 호출부가 들고, 제출용 |
|
| 41 |
- * hidden input을 여기서 함께 낸다 — 폼 제출 규약(Server Action + FormData)을 그대로 두기 위해서다. |
|
| 42 |
- * |
|
| 43 |
- * ⚠️ **메뉴 선택은 저장되지 않는다.** 백엔드에 관리자별 메뉴 권한이 테이블·VO·SQL 어디에도 없다 |
|
| 44 |
- * (`/api/v1/common/menu`는 개인 북마크용이다). 시안대로 자리는 유지하되 **필수 검증은 걸지 않는다** |
|
| 45 |
- * (사용자 확정) — 저장되지도 않는 값 때문에 등록이 막히면 안 되기 때문이다. 권한 API가 생기면 |
|
| 46 |
- * 여기는 그대로 두고 Server Action이 값을 넘기기만 하면 된다. |
|
| 47 |
- */ |
|
| 48 | 28 |
export function AdminMemberFormFields({
|
| 49 | 29 |
phoneNumber, |
| 50 | 30 |
onPhoneNumberChange, |
... | ... | @@ -71,15 +51,12 @@ |
| 71 | 51 |
|
| 72 | 52 |
return ( |
| 73 | 53 |
<> |
| 74 |
- {/* 칸이 셋으로 나뉘어 있어 `<label htmlFor>`가 가리킬 대상이 하나가 아니다 — 이름 연결은
|
|
| 75 |
- 감싼 group이 aria-labelledby로 한다(FoxFormLabel의 `as="span"`이 그 용도다). */} |
|
| 76 | 54 |
<div className={styles.field} role="group" aria-labelledby={phoneLabelId}>
|
| 77 | 55 |
<FoxFormLabel as="span" id={phoneLabelId} requirement="required">
|
| 78 | 56 |
휴대전화 번호 |
| 79 | 57 |
</FoxFormLabel> |
| 80 | 58 |
<FoxPhoneNumber |
| 81 | 59 |
type="unit" |
| 82 |
- // 넘기지 않으면 앞자리 셀렉트가 빈 목록이라 아무것도 고를 수 없다(상수 주석 참조). |
|
| 83 | 60 |
prefixOptions={[...MOBILE_PHONE_PREFIXES]}
|
| 84 | 61 |
value={phoneNumber}
|
| 85 | 62 |
onChange={onPhoneNumberChange}
|
... | ... | @@ -110,7 +87,6 @@ |
| 110 | 87 |
메뉴 선택 |
| 111 | 88 |
</FoxFormLabel> |
| 112 | 89 |
<FoxChipArea size="md" wrap labelledBy={menuLabelId}>
|
| 113 |
- {/* 시안의 첫 칩. 개별 메뉴가 아니라 나머지를 한 번에 켜고 끄는 조각이다. */}
|
|
| 114 | 90 |
<FoxChip |
| 115 | 91 |
type="check" |
| 116 | 92 |
label="전체" |
--- app/(protected)/(basic)/admins/_components/admin-member-list.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-list.tsx
... | ... | @@ -37,42 +37,19 @@ |
| 37 | 37 |
interface AdminMemberListProps {
|
| 38 | 38 |
items: AdminMember[]; |
| 39 | 39 |
query: AdminMemberQuery; |
| 40 |
- /** 화면이 실제로 보여주는 페이지 — 순번 계산의 기준이다. */ |
|
| 41 | 40 |
currentPage: number; |
| 42 | 41 |
totalPages: number; |
| 43 | 42 |
totalCount: number; |
| 44 |
- /** 현재 로그인한 관리자의 id — 본인 행의 삭제 버튼을 막는 데 쓴다. */ |
|
| 45 | 43 |
currentAdminId: string; |
| 46 | 44 |
} |
| 47 | 45 |
|
| 48 |
-/** 폭이 고정되지 않은 세 열이 나눠 갖는 몫 — 시안 1552px 기준 (1552 - 고정열 720) / 3. */ |
|
| 49 | 46 |
const FILL_COLUMN_WIDTH = 277; |
| 50 | 47 |
|
| 51 |
-/** |
|
| 52 |
- * 역할 배지의 색. 시안이 최고관리자는 accent(붉은 계열), 그 외는 primary(파란 계열)로 |
|
| 53 |
- * 구분한다 — 색은 시각 결정이라 도메인(`admin-member.ts`)이 아니라 여기가 갖는다. |
|
| 54 |
- * 모르는 코드는 이름을 지어내지 않듯 색도 입히지 않는다(neutral). |
|
| 55 |
- */ |
|
| 56 | 48 |
const BADGE_COLOR_BY_ROLE: Record<string, FoxBadgeColor> = {
|
| 57 | 49 |
ROLE_SYSTEM: 'accent', |
| 58 | 50 |
ROLE_ADMIN: 'primary', |
| 59 | 51 |
}; |
| 60 | 52 |
|
| 61 |
-/** |
|
| 62 |
- * 관리자 회원 목록 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) ADM_ADM_101 (5227:1545) |
|
| 63 |
- * |
|
| 64 |
- * 뼈대는 `FoxListContainer`가 전부 그린다(학생 회원 목록과 같은 구성). 이 파일이 갖는 것은 |
|
| 65 |
- * 이 화면 고유의 것뿐이다 — 8개 열의 정의, 정렬·페이지 크기 셀렉트, 툴바 버튼 두 개, |
|
| 66 |
- * 그리고 URL을 갱신하는 방법. |
|
| 67 |
- * |
|
| 68 |
- * 이동은 두 갈래로 나뉜다. 페이지네이션은 `buildHref`로 **링크**가 되고(주소가 곧 상태라 |
|
| 69 |
- * 새로고침·뒤로가기·공유가 그대로 동작한다), 검색·정렬·페이지 크기는 컨트롤이 눌린 순간 |
|
| 70 |
- * `router.replace`로 같은 주소 규칙을 태운다. 어느 쪽이든 목록을 다시 그리는 것은 서버다. |
|
| 71 |
- * |
|
| 72 |
- * [엑셀 다운로드]는 네이티브 GET 폼이다 — 응답이 첨부파일(Content-Disposition)이라 라우터 |
|
| 73 |
- * 내비게이션으로는 처리할 수 없고, 브라우저가 화면을 둔 채 파일만 내려받는다. 검색·페이징 값을 |
|
| 74 |
- * 싣지 않는 것이 사양이다(파일은 항상 전체 데이터). |
|
| 75 |
- */ |
|
| 76 | 53 |
export function AdminMemberList({
|
| 77 | 54 |
items, |
| 78 | 55 |
query, |
... | ... | @@ -84,7 +61,6 @@ |
| 84 | 61 |
const router = useRouter(); |
| 85 | 62 |
const [isCreateOpen, setIsCreateOpen] = useState(false); |
| 86 | 63 |
|
| 87 |
- /** 목록 조건이 바뀌면 늘 1페이지로 되돌린다 — 이전 페이지 번호는 새 조건에서 의미가 다르다. */ |
|
| 88 | 64 |
function go(patch: Partial<AdminMemberQuery>) {
|
| 89 | 65 |
router.replace(buildAdminMemberHref(query, { ...patch, page: 1 }));
|
| 90 | 66 |
} |
... | ... | @@ -96,12 +72,8 @@ |
| 96 | 72 |
key: 'no', |
| 97 | 73 |
header: '번호', |
| 98 | 74 |
width: 80, |
| 99 |
- // 저장된 값이 아니라 전체 건수에서 거꾸로 세는 표시 순번이다 — 기본 정렬이 생성일 |
|
| 100 |
- // 최신순이라 "가장 최근에 만든 계정이 가장 큰 번호"가 된다. |
|
| 101 | 75 |
render: (_row, index) => totalCount - offset - index, |
| 102 | 76 |
}, |
| 103 |
- // 이름·아이디·이메일은 시안에서 남는 폭을 똑같이 나눠 갖는다(1552 기준 각 277). 폭을 |
|
| 104 |
- // 비워 두면 표가 글자 길이대로 나눠 이메일 열만 넓어져 시안과 어긋난다. |
|
| 105 | 77 |
{ key: 'name', header: '이름', width: FILL_COLUMN_WIDTH },
|
| 106 | 78 |
{ key: 'loginId', header: '아이디', width: FILL_COLUMN_WIDTH },
|
| 107 | 79 |
{
|
--- app/(protected)/(basic)/admins/_components/admin-member-modal.module.scss
+++ app/(protected)/(basic)/admins/_components/admin-member-modal.module.scss
... | ... | @@ -1,12 +1,7 @@ |
| 1 |
-// 관리자 등록·수정 팝업의 배치 — 시안(ADM_ADM_102_p / 103_p)의 form-list. |
|
| 2 |
-// 값은 전부 @fox 토큰을 거치므로 없는 이름을 쓰면 빌드가 실패한다. |
|
| 3 |
- |
|
| 4 | 1 |
@use "@fox/styles/abstracts" as fox; |
| 5 | 2 |
@use "@fox/styles/form-field" as field; |
| 6 | 3 |
|
| 7 |
-/// 필드 한 벌씩 세로로 쌓는다. 라벨·상자·헬퍼 사이 간격은 각 @fox 컴포넌트가 이미 갖는다. |
|
| 8 |
-/// 모달 contents는 `align-items: flex-start`라 자식이 늘어나지 않는다 — 폭을 주지 않으면 폼이 |
|
| 9 |
-/// 내용만큼 넓어져(전화번호 칸이 가장 넓다) 모달 밖으로 터진다. |
|
| 4 |
+// 모달 contents가 `align-items: flex-start`라 폭을 주지 않으면 폼이 내용만큼 넓어져 넘친다. |
|
| 10 | 5 |
.formList {
|
| 11 | 6 |
display: flex; |
| 12 | 7 |
flex-direction: column; |
... | ... | @@ -15,16 +10,12 @@ |
| 15 | 10 |
min-inline-size: 0; |
| 16 | 11 |
} |
| 17 | 12 |
|
| 18 |
-/// 입력칸 바로 아래 전체폭 버튼이 붙는 칸([중복 확인]·[비밀번호 변경]). |
|
| 19 | 13 |
.fieldWithAction {
|
| 20 | 14 |
display: flex; |
| 21 | 15 |
flex-direction: column; |
| 22 | 16 |
gap: fox.gap(3); |
| 23 | 17 |
} |
| 24 | 18 |
|
| 25 |
-/// 시안(102_p)의 이메일 한 줄 — [아이디] @ [도메인]. @fox의 `FoxEmail`을 쓰지 않는다: |
|
| 26 |
-/// 그쪽은 도메인 셀렉트 옆에 잠긴 '직접입력' 상자를 하나 더 그리고(시안은 상자 둘뿐), |
|
| 27 |
-/// 칸마다 240px 최소폭이 박혀 있어 320px 안에서 세 줄로 쌓인다(사용자 확정). |
|
| 28 | 19 |
.emailRow {
|
| 29 | 20 |
display: flex; |
| 30 | 21 |
align-items: center; |
... | ... | @@ -32,15 +23,11 @@ |
| 32 | 23 |
inline-size: 100%; |
| 33 | 24 |
} |
| 34 | 25 |
|
| 35 |
-/// 두 칸이 남는 폭을 나눠 갖는다. `min-inline-size: 0`이 없으면 내용만큼 넓어져 줄이 넘어간다. |
|
| 36 | 26 |
.emailPart {
|
| 37 | 27 |
flex: 1 1 0; |
| 38 | 28 |
min-inline-size: 0; |
| 39 | 29 |
} |
| 40 | 30 |
|
| 41 |
-/// 라벨 + 컨트롤 한 벌. @fox 입력 컴포넌트는 라벨을 스스로 갖지만, 여러 칸을 묶은 필드 |
|
| 42 |
-/// (전화번호·이메일·메뉴·잠김여부)는 라벨을 밖에서 얹으므로 그 간격을 여기서 준다. |
|
| 43 |
-/// `FoxFormLabel`은 인라인이라 이 틀이 없으면 토글 같은 인라인 컨트롤과 한 줄에 붙는다. |
|
| 44 | 31 |
.field {
|
| 45 | 32 |
display: flex; |
| 46 | 33 |
flex-direction: column; |
... | ... | @@ -49,8 +36,6 @@ |
| 49 | 36 |
inline-size: 100%; |
| 50 | 37 |
} |
| 51 | 38 |
|
| 52 |
-/// 칸 여럿을 묶은 필드(이메일)의 오류 문구. FoxInput은 자기 칸의 값만 판정할 수 있어 |
|
| 53 |
-/// 합친 값의 사유는 바깥에서 그린다 — 모양은 입력 헬퍼와 같은 조각을 쓴다. |
|
| 54 | 39 |
.fieldError {
|
| 55 | 40 |
@include field.message; |
| 56 | 41 |
|
--- app/(protected)/(basic)/admins/_components/admin-member-row-actions.module.scss
+++ app/(protected)/(basic)/admins/_components/admin-member-row-actions.module.scss
... | ... | @@ -1,6 +1,6 @@ |
| 1 |
-// 비활성 삭제 버튼을 감싸 네이티브 툴팁을 다는 자리(그 이유는 컴포넌트 주석 참조). |
|
| 2 |
-// 감싸기만 하면 안쪽 버튼이 글자 기준선에 앉아 2px가 더 붙고, 그만큼 행이 시안(44px)보다 |
|
| 3 |
-// 높아진다. 래퍼를 flex로 두어 버튼 높이가 그대로 래퍼 높이가 되게 한다. |
|
| 1 |
+@use "@fox/styles/abstracts" as fox; |
|
| 2 |
+ |
|
| 3 |
+// 감싸기만 하면 안쪽 버튼이 글자 기준선에 앉아 행이 2px 높아진다. |
|
| 4 | 4 |
.hint {
|
| 5 | 5 |
display: flex; |
| 6 | 6 |
} |
--- app/(protected)/(basic)/admins/_components/admin-member-row-actions.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-row-actions.tsx
... | ... | @@ -13,29 +13,11 @@ |
| 13 | 13 |
|
| 14 | 14 |
interface AdminMemberRowActionsProps {
|
| 15 | 15 |
member: AdminMember; |
| 16 |
- /** 현재 로그인한 본인의 행인가 — 삭제를 막는다. */ |
|
| 17 | 16 |
isSelf: boolean; |
| 18 | 17 |
} |
| 19 | 18 |
|
| 20 | 19 |
const SELF_DELETE_HINT = '현재 로그인한 본인 계정은 삭제할 수 없습니다.'; |
| 21 | 20 |
|
| 22 |
-/** |
|
| 23 |
- * 목록 행의 "관리" 셀(시안 ADM_ADM_101 ⑤) — 수정 팝업 열림 상태만 소유하는 최말단 상호작용 |
|
| 24 |
- * 경계다(테이블 전체를 클라이언트로 내리지 않기 위해 이 셀만 분리했다). |
|
| 25 |
- * |
|
| 26 |
- * 삭제는 시안대로 **확인 얼럿을 거친 뒤** 실행한다. 전역 얼럿(FeedbackProvider)의 actions 슬롯에 |
|
| 27 |
- * 직접 버튼을 넣어 확인/취소를 구성한다. |
|
| 28 |
- * |
|
| 29 |
- * **본인 계정 삭제는 막는다**(시안 ⑤). 화면에서 버튼을 비활성으로 두되, 실제 차단은 Server |
|
| 30 |
- * Action이 세션의 adminId와 대조해 수행한다 — 화면 비활성은 안내일 뿐 신뢰 경계가 아니다. |
|
| 31 |
- * |
|
| 32 |
- * 시안의 연필·휴지통 아이콘 버튼이다. `FoxIconButton`의 default·sm이 시안 값과 그대로 맞는다 |
|
| 33 |
- * (form/height/sm=28, form/radius/sm=4, 테두리 #b1b8be). 두 버튼의 4px 간격도 시안이 btn-group |
|
| 34 |
- * 인스턴스라 `FoxButtonGroup`(gap/2=4)이 갖는다 — 여기서 직접 여백을 주지 않는다. |
|
| 35 |
- * |
|
| 36 |
- * 본인 계정 안내는 `<span title>`로 감싸 남긴다 — FoxIconButton은 `label`을 `aria-label`로만 |
|
| 37 |
- * 쓰고 네이티브 툴팁을 렌더하지 않으며, 비활성 버튼은 스스로 hover 이벤트를 받지 못한다. |
|
| 38 |
- */ |
|
| 39 | 21 |
export function AdminMemberRowActions({
|
| 40 | 22 |
member, |
| 41 | 23 |
isSelf, |
--- app/(protected)/(basic)/admins/excel/route.ts
+++ app/(protected)/(basic)/admins/excel/route.ts
... | ... | @@ -1,29 +1,10 @@ |
| 1 | 1 |
import { getSessionAccessToken, verifySession } from '@/lib/auth/dal';
|
| 2 | 2 |
import { backendFetchStream } from '@/lib/http/backend-fetch';
|
| 3 | 3 |
|
| 4 |
-/** |
|
| 5 |
- * 관리자 회원 목록 엑셀 다운로드 — 백엔드가 만든 xlsx를 브라우저로 중계한다. |
|
| 6 |
- * |
|
| 7 |
- * 라우트 핸들러인 이유는 학생 목록(`students/excel/route.ts`)과 같다 — 브라우저는 백엔드를 직접 |
|
| 8 |
- * 호출하지 않고, 백엔드 엑셀 API가 요구하는 ROLE_ADMIN 토큰은 httpOnly 세션 안에만 있다. 본문은 |
|
| 9 |
- * 파싱하지 않고 업스트림 스트림을 그대로 흘려보낸다. |
|
| 10 |
- * |
|
| 11 |
- * 파일에 담기는 열은 백엔드가 정한다 — 번호·이름·아이디·휴대폰번호·이메일·생성일 |
|
| 12 |
- * (`MngrAdminApiController.downloadExcel`). 화면의 "역할" 열은 파일에 없다. |
|
| 13 |
- */ |
|
| 14 |
- |
|
| 15 | 4 |
const ADMIN_MEMBER_EXCEL_PATH = '/api/v1/mngr/admin/excel/download'; |
| 16 | 5 |
|
| 17 |
-/** |
|
| 18 |
- * 파일은 화면의 검색·페이징과 무관하게 **항상 전체**를 담는다(학생 목록과 같은 사양). |
|
| 19 |
- * |
|
| 20 |
- * 백엔드 엑셀 API가 목록과 같은 `selectPagination`을 그대로 쓰므로 페이징이 걸린다 — 파라미터를 |
|
| 21 |
- * 생략하면 기본값(`recordCountPerPage=10`)이 적용돼 10건만 담긴 파일이 나온다. "전체" 모드가 따로 |
|
| 22 |
- * 없어 충분히 큰 상한을 명시해 1페이지로 전부 받는다. 이 상한을 넘으면 파일이 조용히 잘린다. |
|
| 23 |
- */ |
|
| 24 | 6 |
const EXCEL_ROW_LIMIT = 100_000; |
| 25 | 7 |
|
| 26 |
-/** 전체 행을 모아 워크북을 만드는 시간이 있어 일반 조회보다 넉넉히 잡는다. */ |
|
| 27 | 8 |
const EXCEL_TIMEOUT_MS = 60_000; |
| 28 | 9 |
|
| 29 | 10 |
const DOWNLOAD_FAILED_MESSAGE = |
... | ... | @@ -33,8 +14,6 @@ |
| 33 | 14 |
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; |
| 34 | 15 |
|
| 35 | 16 |
export async function GET() {
|
| 36 |
- // 라우트 핸들러는 UI를 거치지 않고 직접 호출될 수 있으므로 여기서 직접 인증을 확인한다 |
|
| 37 |
- // (proxy의 쿠키 존재 확인은 낙관적 필터일 뿐이다 — 설계서 §8). |
|
| 38 | 17 |
await verifySession(); |
| 39 | 18 |
|
| 40 | 19 |
const accessToken = await getSessionAccessToken(); |
... | ... | @@ -46,7 +25,6 @@ |
| 46 | 25 |
}); |
| 47 | 26 |
|
| 48 | 27 |
if (!result.ok) {
|
| 49 |
- // 실패 사유(코드·백엔드 메시지)는 backendFetchStream이 서버 콘솔에 남긴다. |
|
| 50 | 28 |
return new Response(DOWNLOAD_FAILED_MESSAGE, {
|
| 51 | 29 |
status: 502, |
| 52 | 30 |
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
--- app/(protected)/(basic)/admins/page.tsx
+++ app/(protected)/(basic)/admins/page.tsx
... | ... | @@ -12,15 +12,6 @@ |
| 12 | 12 |
searchParams: Promise<Record<string, string | string[] | undefined>>; |
| 13 | 13 |
} |
| 14 | 14 |
|
| 15 |
-/** |
|
| 16 |
- * 관리자 회원 목록(시안 ADM_ADM_101). |
|
| 17 |
- * |
|
| 18 |
- * 학생 회원 목록과 달리 전체 건수가 **확정값**이라 페이지 수를 그대로 계산한다 — Repository가 |
|
| 19 |
- * 백엔드 페이징을 쓰지 않고 전체를 받아 직접 세기 때문이다(그 이유는 Repository 주석 참조). |
|
| 20 |
- * |
|
| 21 |
- * `verifySession()`의 반환값을 목록까지 내려보내는 이유는 본인 계정의 삭제 버튼을 막기 위해서다 |
|
| 22 |
- * (시안 ⑤). 실제 차단은 Server Action이 다시 확인한다. |
|
| 23 |
- */ |
|
| 24 | 15 |
export default async function Page({ searchParams }: PageProps) {
|
| 25 | 16 |
const admin = await verifySession(); |
| 26 | 17 |
|
... | ... | @@ -28,8 +19,6 @@ |
| 28 | 19 |
const { items, totalCount } = await fetchAdminMembers(query);
|
| 29 | 20 |
|
| 30 | 21 |
const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize)); |
| 31 |
- // 요청 페이지가 범위를 벗어나면(예: 삭제로 마지막 페이지가 사라짐) 마지막 페이지로 맞춘다 — |
|
| 32 |
- // 표의 순번 계산도 이 값을 기준으로 해야 페이지네이션의 현재 위치와 어긋나지 않는다. |
|
| 33 | 22 |
const currentPage = Math.min(query.page, totalPages); |
| 34 | 23 |
|
| 35 | 24 |
return ( |
--- app/_hooks/submit-form-action.ts
+++ app/_hooks/submit-form-action.ts
... | ... | @@ -2,18 +2,8 @@ |
| 2 | 2 |
|
| 3 | 3 |
import { startTransition, type FormEvent } from 'react';
|
| 4 | 4 |
|
| 5 |
-/** |
|
| 6 |
- * `<form action={formAction}>` 대신 쓰는 제출 핸들러.
|
|
| 7 |
- * |
|
| 8 |
- * **React는 폼 액션이 끝나면 성공·실패를 가리지 않고 폼을 리셋한다.** 그래서 검증 오류가 하나만 |
|
| 9 |
- * 나도 사용자가 채운 값이 전부 날아가고, 특히 `type="file"` 입력은 프로그램으로 되채울 수 없어 |
|
| 10 |
- * 다음 제출에서는 "이미지를 등록해 주세요"만 반복된다(꾸미기 아이템 등록에서 실제로 그랬다). |
|
| 11 |
- * |
|
| 12 |
- * 액션을 `action` 프롭에 맡기지 않고 transition 안에서 직접 부르면 그 리셋이 일어나지 않는다. |
|
| 13 |
- * 확인: 같은 액션을 두 방식으로 제출했을 때 `action={...}` 쪽만 입력값이 빈 문자열이 됐다.
|
|
| 14 |
- * |
|
| 15 |
- * 성공 후 폼을 비워야 하는 화면은 각자 팝업을 닫으므로(다음에 열 때 새 폼이다) 리셋이 필요 없다. |
|
| 16 |
- */ |
|
| 5 |
+// 이슈: `<form action={...}>`은 액션이 끝나면 성공·실패를 가리지 않고 폼을 리셋한다.
|
|
| 6 |
+// 되돌릴 수 없는 파일 선택까지 지워지므로 액션을 직접 transition으로 부른다. |
|
| 17 | 7 |
export function submitFormAction( |
| 18 | 8 |
event: FormEvent<HTMLFormElement>, |
| 19 | 9 |
formAction: (formData: FormData) => void |
--- lib/data/repositories/admin-member-repository.ts
+++ lib/data/repositories/admin-member-repository.ts
... | ... | @@ -7,57 +7,11 @@ |
| 7 | 7 |
AdminMemberSearchField, |
| 8 | 8 |
} from '@/lib/domain/admin-member-query'; |
| 9 | 9 |
|
| 10 |
-/** |
|
| 11 |
- * 관리자 회원 Repository — 이 도메인을 백엔드에서 "어떻게 읽고 쓰는지"만 안다(엔드포인트·파라미터· |
|
| 12 |
- * 응답 매핑). 백엔드와 말하는 공통 규약(URL·헤더·응답 봉투·실패 정규화)은 |
|
| 13 |
- * `lib/http/backend-fetch.ts`가, 토큰 보관·검증은 `lib/auth`가 소유하므로 여기에 들어오지 않는다. |
|
| 14 |
- * |
|
| 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} 삭제
|
|
| 21 |
- * |
|
| 22 |
- * 아래는 백엔드 저장소(edupay-backend, develop 4d98756)의 실제 구현을 확인한 것이다. |
|
| 23 |
- * |
|
| 24 |
- * - **등록과 수정의 본문 형식이 서로 다르다.** 등록은 `@RequestBody` 없이 `@ParameterObject`라 |
|
| 25 |
- * query/form으로만 바인딩되고(→ `form`), 수정은 `@RequestBody`라 JSON이다(→ `body`). |
|
| 26 |
- * 같은 도메인인데 갈린 것이라 헷갈리기 쉽다 — 한쪽 방식으로 통일해 보내면 값이 조용히 비어 저장된다. |
|
| 27 |
- * - **이름(`admNm`)은 수정 대상이 아니다** — `MngrAdminUpdateRequestVo`에 필드가 없다. |
|
| 28 |
- * - **정렬 파라미터가 없다** — 목록 SQL의 `ORDER BY RNUM DESC`가 하드코딩돼 있다. rnum이 |
|
| 29 |
- * `ROW_NUMBER() OVER (ORDER BY frst_reg_dt, adm_nm DESC)`, 즉 최초등록일시 오름차순 순번이라 |
|
| 30 |
- * 그것을 뒤집은 고정 순서가 곧 **생성일 최신순**이다. 그래서 생성일순 정렬은 "백엔드가 준 순서를 |
|
| 31 |
- * 그대로 쓴다"는 뜻이고, 이름순만 우리가 정렬한다. |
|
| 32 |
- * - **`totalCount`가 전체 건수가 아니다.** count 쿼리가 없어 `PaginationUtil.execute`가 |
|
| 33 |
- * `list.size()`(= 그 페이지의 행 수)를 총건수로 그대로 쓴다. 그래서 이 값은 신뢰하지 않는다. |
|
| 34 |
- * - **삭제는 soft delete다**(`DEL_YN='Y'`). 조회 SQL이 모두 `DEL_YN != 'Y'`로 거르므로 삭제한 행은 |
|
| 35 |
- * 목록·단건에서 함께 사라진다. |
|
| 36 |
- * |
|
| 37 |
- * 인증: `/api/v1/mngr/**`는 ROLE_ADMIN 전용이다. 세션에 보관된 백엔드 accessToken을 DAL에서 |
|
| 38 |
- * 꺼내 Bearer로 붙인다. |
|
| 39 |
- * |
|
| 40 |
- * 캐시: `no-store` — 개인정보 목록이고 검색 조건이 매 요청 다르다. |
|
| 41 |
- */ |
|
| 42 |
- |
|
| 43 | 10 |
const ADMIN_MEMBER_BASE_PATH = '/api/v1/mngr/admin'; |
| 44 | 11 |
const ADMIN_MEMBER_PAGINATION_PATH = `${ADMIN_MEMBER_BASE_PATH}/pagination`;
|
| 45 | 12 |
|
| 46 |
-/** |
|
| 47 |
- * 한 번에 받아올 최대 행 수. **이 화면은 백엔드 페이징을 쓰지 않고 전체를 받아 여기서 자른다.** |
|
| 48 |
- * 이유가 세 가지 겹친다: |
|
| 49 |
- * |
|
| 50 |
- * 1. **총건수가 없다.** 위에서 적었듯 `totalCount`가 현재 페이지 행 수라, 그 값을 믿으면 |
|
| 51 |
- * 페이지가 가득 찰 때마다 `totalPages`가 1로 계산돼 2페이지 이후에 영원히 닿을 수 없다. |
|
| 52 |
- * 시안(ADM_ADM_101)은 "총 N명 | 현재페이지 1/1"과 번호 열(총건수에서 거꾸로 세는 순번)을 |
|
| 53 |
- * 요구하는데, 둘 다 정확한 전체 건수를 전제한다. |
|
| 54 |
- * 2. **이름순 정렬을 백엔드에 맡길 수 없다.** 정렬 파라미터가 없고 목록 SQL의 ORDER BY가 |
|
| 55 |
- * 하드코딩돼 있다. |
|
| 56 |
- * |
|
| 57 |
- * 관리자 계정은 본래 수십 건 규모라 전체를 받아도 부담이 없다. 이 전제가 깨질 정도로 늘면 |
|
| 58 |
- * 백엔드에 count·정렬·검색 파라미터가 필요하다 — 상한 인상은 임시방편일 뿐이다. |
|
| 59 |
- * (같은 이유로 학생 목록의 이름순 정렬과 엑셀 다운로드도 이미 같은 방식을 쓴다.) |
|
| 60 |
- */ |
|
| 13 |
+// 이슈: 백엔드 `totalCount`가 전체가 아니라 현재 페이지 행 수라 페이징을 맡길 수 없다. |
|
| 14 |
+// 이름순 정렬 파라미터도 없어 전체를 받아 서버에서 자른다. |
|
| 61 | 15 |
const ADMIN_MEMBER_FETCH_LIMIT = 10_000; |
| 62 | 16 |
|
| 63 | 17 |
function isRecord(value: unknown): value is Record<string, unknown> {
|
... | ... | @@ -83,22 +37,12 @@ |
| 83 | 37 |
return typeof value === 'string' && value.length > 0 ? value : null; |
| 84 | 38 |
} |
| 85 | 39 |
|
| 86 |
-/** 백엔드의 Y/N 플래그 → boolean. 값이 없거나 Y/N이 아니면 "모름"(null)이다. */ |
|
| 87 | 40 |
function parseYesNo(value: unknown): boolean | null {
|
| 88 | 41 |
if (value === 'Y') return true; |
| 89 | 42 |
if (value === 'N') return false; |
| 90 | 43 |
return null; |
| 91 | 44 |
} |
| 92 | 45 |
|
| 93 |
-/** |
|
| 94 |
- * 백엔드 응답 1건 → 도메인 타입. 백엔드가 주지 않는 항목은 `null`(목록은 빈 배열)로 둔다. |
|
| 95 |
- * |
|
| 96 |
- * `rnum`은 담지 않는다 — 표의 "번호"는 백엔드의 행 번호가 아니라 전체 건수 기준 역순 순번이고 |
|
| 97 |
- * (시안이 6·5·4처럼 내림차순으로 표기한다), 그 계산은 표 컴포넌트가 한다. |
|
| 98 |
- * |
|
| 99 |
- * 식별자·이름·ID 세 필드는 없으면 예외로 끊는다(fail-fast) — 목록의 존재 이유인 값이라 |
|
| 100 |
- * 빈 화면을 조용히 보여주는 것보다 계약 위반을 즉시 드러내는 편이 낫다. |
|
| 101 |
- */ |
|
| 102 | 46 |
function toAdminMember(raw: unknown): AdminMember {
|
| 103 | 47 |
if (!isRecord(raw)) {
|
| 104 | 48 |
throw new Error('관리자 회원 응답 항목의 형식이 올바르지 않습니다.');
|
... | ... | @@ -113,9 +57,8 @@ |
| 113 | 57 |
phoneNumber: readOptionalString(raw, 'admTelNo'), |
| 114 | 58 |
email: readOptionalString(raw, 'admEmlAddr'), |
| 115 | 59 |
roleCode: readOptionalString(raw, 'admRoleCd') ?? '', |
| 116 |
- // 백엔드에 관리자별 메뉴 권한 개념이 없다 — 저장도 조회도 되지 않는다(쓰기 경로 주석 참조). |
|
| 60 |
+ // 이슈: 백엔드에 관리자별 메뉴 권한이 없어 저장도 조회도 되지 않는다. |
|
| 117 | 61 |
menuCodes: [], |
| 118 |
- // `DATE_FORMAT(FRST_REG_DT, '%Y-%m-%d')`라 이미 화면 표기 형식이다. |
|
| 119 | 62 |
createdAt: readOptionalString(raw, 'frstRegDtStr'), |
| 120 | 63 |
isLocked: parseYesNo(raw.acctLockYn), |
| 121 | 64 |
isActive: parseYesNo(raw.useYn), |
... | ... | @@ -123,20 +66,11 @@ |
| 123 | 66 |
}; |
| 124 | 67 |
} |
| 125 | 68 |
|
| 126 |
-/** |
|
| 127 |
- * 백엔드 목록 전체를 한 번에 받아온다. |
|
| 128 |
- * |
|
| 129 |
- * 검색 파라미터(`searchCondition`/`searchKeyword`)를 **의도적으로 보내지 않는다** — 백엔드에 |
|
| 130 |
- * 맡기면 페이징도 함께 백엔드가 하게 되는데 그 총건수를 믿을 수 없다(위 상수 주석). 어차피 전체를 |
|
| 131 |
- * 손에 쥐므로 `filterByKeyword`가 같은 조건으로 거른다. |
|
| 132 |
- */ |
|
| 133 | 69 |
async function fetchAllAdminMembers(): Promise<AdminMember[]> {
|
| 134 | 70 |
const accessToken = await getSessionAccessToken(); |
| 135 | 71 |
|
| 136 | 72 |
const result = await backendFetch<unknown>(ADMIN_MEMBER_PAGINATION_PATH, {
|
| 137 | 73 |
method: 'GET', |
| 138 |
- // `PaginationUtil`이 offset을 직접 계산해 firstIndex를 덮어쓰므로 실제로 쓰이는 파라미터는 |
|
| 139 |
- // 이 둘뿐이다 — 나머지(pageUnit/pageSize/firstIndex/lastIndex)는 읽히지 않아 보내지 않는다. |
|
| 140 | 74 |
query: { pageIndex: 1, recordCountPerPage: ADMIN_MEMBER_FETCH_LIMIT },
|
| 141 | 75 |
accessToken: accessToken ?? undefined, |
| 142 | 76 |
cache: 'no-store', |
... | ... | @@ -154,7 +88,6 @@ |
| 154 | 88 |
return data.list.map(toAdminMember); |
| 155 | 89 |
} |
| 156 | 90 |
|
| 157 |
-/** 검색 대상 → 비교할 값. 목록에 실제로 보이는 값으로만 거른다. */ |
|
| 158 | 91 |
const SEARCH_VALUE_BY_FIELD: Record< |
| 159 | 92 |
AdminMemberSearchField, |
| 160 | 93 |
(member: AdminMember) => string |
... | ... | @@ -177,10 +110,6 @@ |
| 177 | 110 |
return items.filter((item) => readValue(item).toLowerCase().includes(keyword)); |
| 178 | 111 |
} |
| 179 | 112 |
|
| 180 |
-/** |
|
| 181 |
- * 정렬. 생성일순은 **정렬하지 않는다** — 백엔드의 고정 `ORDER BY rnum DESC`가 곧 생성일 |
|
| 182 |
- * 최신순이라 받은 순서가 이미 답이다(rnum이 `frst_reg_dt` 오름차순 순번이다). |
|
| 183 |
- */ |
|
| 184 | 113 |
function sortItems( |
| 185 | 114 |
items: AdminMember[], |
| 186 | 115 |
query: AdminMemberQuery |
... | ... | @@ -193,11 +122,9 @@ |
| 193 | 122 |
|
| 194 | 123 |
export type AdminMemberPage = {
|
| 195 | 124 |
items: AdminMember[]; |
| 196 |
- /** 검색 조건을 적용한 전체 건수. 전체를 손에 쥐고 세므로 확정값이다. */ |
|
| 197 | 125 |
totalCount: number; |
| 198 | 126 |
}; |
| 199 | 127 |
|
| 200 |
-/** 검색·정렬·페이징이 적용된 관리자 회원 목록을 조회한다. */ |
|
| 201 | 128 |
export async function fetchAdminMembers( |
| 202 | 129 |
query: AdminMemberQuery |
| 203 | 130 |
): Promise<AdminMemberPage> {
|
... | ... | @@ -212,7 +139,6 @@ |
| 212 | 139 |
}; |
| 213 | 140 |
} |
| 214 | 141 |
|
| 215 |
-/** 단건 조회 — 수정 팝업이 쓰는 진입점. */ |
|
| 216 | 142 |
export async function findAdminMemberById( |
| 217 | 143 |
id: string |
| 218 | 144 |
): Promise<AdminMember | null> {
|
... | ... | @@ -223,7 +149,6 @@ |
| 223 | 149 |
method: 'GET', |
| 224 | 150 |
accessToken: accessToken ?? undefined, |
| 225 | 151 |
cache: 'no-store', |
| 226 |
- // 없는 id면 `{success:true, data:null}`이 온다 — 실패가 아니라 "없음"이다.
|
|
| 227 | 152 |
canHaveNullData: true, |
| 228 | 153 |
} |
| 229 | 154 |
); |
... | ... | @@ -235,13 +160,6 @@ |
| 235 | 160 |
return result.data === null ? null : toAdminMember(result.data); |
| 236 | 161 |
} |
| 237 | 162 |
|
| 238 |
-/** |
|
| 239 |
- * 로그인 ID 중복 확인(시안 ADM_ADM_102_p ①). |
|
| 240 |
- * |
|
| 241 |
- * 이미 쓰는 ID면 해당 관리자 정보를, 아니면 `data: null`을 준다(실측). 확인 시점과 저장 시점 |
|
| 242 |
- * 사이에 다른 관리자가 같은 ID를 선점하는 경쟁 조건은 이 호출로 막을 수 없다 — 최종 유일성은 |
|
| 243 |
- * 등록 API가 저장 직전에 다시 검사한다. |
|
| 244 |
- */ |
|
| 245 | 163 |
export async function isAdminLoginIdTaken(loginId: string): Promise<boolean> {
|
| 246 | 164 |
const accessToken = await getSessionAccessToken(); |
| 247 | 165 |
|
... | ... | @@ -262,16 +180,6 @@ |
| 262 | 180 |
return result.data !== null; |
| 263 | 181 |
} |
| 264 | 182 |
|
| 265 |
-/* |
|
| 266 |
- * ─── 쓰기 경로 ──────────────────────────────────────────────────────────────── |
|
| 267 |
- * 등록·수정·삭제 모두 실제 API를 쓴다. 성공 응답은 `data: null`이라 `canHaveNullData`가 필요하다. |
|
| 268 |
- * |
|
| 269 |
- * **본문 형식이 등록과 수정에서 갈린다** — 등록은 form, 수정은 JSON이다(파일 상단 주석 참조). |
|
| 270 |
- * |
|
| 271 |
- * 메뉴 선택(`menuCodes`)은 **보내지 않는다** — 백엔드에 관리자별 메뉴 권한 개념이 없다 |
|
| 272 |
- * (`/api/v1/common/menu`는 개인 북마크용이고 등록·수정 VO에도 해당 필드가 없다). |
|
| 273 |
- */ |
|
| 274 |
- |
|
| 275 | 183 |
export type CreateAdminMemberInput = {
|
| 276 | 184 |
name: string; |
| 277 | 185 |
loginId: string; |
... | ... | @@ -282,11 +190,6 @@ |
| 282 | 190 |
}; |
| 283 | 191 |
|
| 284 | 192 |
export type UpdateAdminMemberInput = {
|
| 285 |
- /** |
|
| 286 |
- * 비우면 비밀번호를 바꾸지 않는다 — 백엔드 UPDATE의 `LOGIN_PW`가 |
|
| 287 |
- * `<if test='loginPw != null and loginPw != ""'>`로 감싸여 있어 빈 값은 SET 절에서 빠진다. |
|
| 288 |
- * (예전에는 조건 없이 덮어써서 빈 값을 보내면 그 계정이 로그인 불가가 됐다.) |
|
| 289 |
- */ |
|
| 290 | 193 |
password: string; |
| 291 | 194 |
phoneNumber: string; |
| 292 | 195 |
email: string; |
... | ... | @@ -294,7 +197,6 @@ |
| 294 | 197 |
isLocked: boolean; |
| 295 | 198 |
}; |
| 296 | 199 |
|
| 297 |
-/** Y/N 플래그로 변환. 백엔드는 `ACCT_LOCK_YN`에 이 문자열을 그대로 넣는다. */ |
|
| 298 | 200 |
function toYesNo(value: boolean): string {
|
| 299 | 201 |
return value ? 'Y' : 'N'; |
| 300 | 202 |
} |
... | ... | @@ -306,6 +208,7 @@ |
| 306 | 208 |
|
| 307 | 209 |
const result = await backendFetch<unknown>(ADMIN_MEMBER_BASE_PATH, {
|
| 308 | 210 |
method: 'POST', |
| 211 |
+ // 이슈: 등록은 form(@ParameterObject), 수정은 JSON(@RequestBody)로 갈려 있다. |
|
| 309 | 212 |
form: {
|
| 310 | 213 |
admNm: input.name, |
| 311 | 214 |
loginId: input.loginId, |
... | ... | @@ -319,7 +222,6 @@ |
| 319 | 222 |
}); |
| 320 | 223 |
|
| 321 | 224 |
if (!result.ok) {
|
| 322 |
- // 아이디 중복도 여기로 온다(code 300 "이미 등록된 아이디 입니다") — 호출부가 메시지를 살려 쓴다. |
|
| 323 | 225 |
throw new BackendRequestError(result); |
| 324 | 226 |
} |
| 325 | 227 |
} |
... | ... | @@ -334,8 +236,6 @@ |
| 334 | 236 |
`${ADMIN_MEMBER_BASE_PATH}/${encodeURIComponent(id)}`,
|
| 335 | 237 |
{
|
| 336 | 238 |
method: 'PUT', |
| 337 |
- // 수정만 `@RequestBody`라 JSON이다. form으로 보내면 모든 필드가 null로 들어가 |
|
| 338 |
- // 이메일·전화번호가 지워지고 역할이 비워진다. |
|
| 339 | 239 |
body: {
|
| 340 | 240 |
loginPw: input.password, |
| 341 | 241 |
admEmlAddr: input.email, |
... | ... | @@ -353,7 +253,6 @@ |
| 353 | 253 |
} |
| 354 | 254 |
} |
| 355 | 255 |
|
| 356 |
-/** 삭제(soft delete). 백엔드가 `DEL_YN='Y'`로 표시하고 조회 SQL이 그 행을 제외한다. */ |
|
| 357 | 256 |
export async function deleteAdminMember(id: string): Promise<void> {
|
| 358 | 257 |
const accessToken = await getSessionAccessToken(); |
| 359 | 258 |
|
--- lib/domain/admin-member-form.ts
+++ lib/domain/admin-member-form.ts
... | ... | @@ -1,23 +1,3 @@ |
| 1 |
-/** |
|
| 2 |
- * 관리자 등록/수정 입력 규칙 — 순수 검증 로직만 담는다(외부 의존 없음). |
|
| 3 |
- * |
|
| 4 |
- * 규칙은 시안(ADM_ADM_102_p / ADM_ADM_103_p)의 안내 문구를 그대로 옮긴 것이다: |
|
| 5 |
- * - ID : "영어 소문자, 숫자를 조합하여 입력 후 중복여부를 확인하세요." |
|
| 6 |
- * - 비밀번호: "영어 소문자, 숫자, 특수문자 중 2종류 이상 조합, 최소 10자리 이상" |
|
| 7 |
- * |
|
| 8 |
- * **이 파일이 검증의 단일 진실원천이다.** Server Action(`_actions.ts`)이 저장 직전에 여기를 |
|
| 9 |
- * 거치고, 화면의 안내 문구도 여기 상수를 그대로 쓴다 — 규칙이 화면과 서버에서 갈라지는 것을 막는다. |
|
| 10 |
- * 화면 입력 단계의 즉시 피드백이 아니라 **제출 시 서버 검증**이 최종 방어선이다(Server Action은 |
|
| 11 |
- * UI를 거치지 않고 직접 호출될 수 있다). |
|
| 12 |
- * |
|
| 13 |
- * **등록과 수정의 검증을 분리한 이유**: 수정 팝업에서 이름·ID는 읽기 전용이다(시안 103_p ①). |
|
| 14 |
- * 그 값에까지 등록용 형식 규칙을 다시 적용하면, 규칙이 생기기 전에 만들어졌거나 규칙 밖에서 |
|
| 15 |
- * 발급된 기존 계정(예: 숫자가 없는 ID)이 **자기 정보를 저장할 수 없게 된다** — 바꾸지도 않는 |
|
| 16 |
- * 필드 때문에. 그래서 수정은 실제로 바뀔 수 있는 항목만 검증한다. |
|
| 17 |
- * |
|
| 18 |
- * 형식 규칙(자릿수·조합)의 근거는 여전히 시안뿐이다 — 백엔드 등록·수정 API는 값을 그대로 받아 |
|
| 19 |
- * 저장할 뿐 형식을 검사하지 않는다(`MngrAdminApiController`). |
|
| 20 |
- */ |
|
| 21 | 1 |
|
| 22 | 2 |
import {
|
| 23 | 3 |
foxValidationMessage, |
... | ... | @@ -36,25 +16,12 @@ |
| 36 | 16 |
export const ADMIN_PASSWORD_HELP_TEXT = |
| 37 | 17 |
'영어, 숫자, 특수문자 중 2종류 이상 조합, 최소 10자리 이상'; |
| 38 | 18 |
|
| 39 |
-/** |
|
| 40 |
- * 비밀번호가 규칙에 걸렸을 때의 문구. **안내 문구와 글자가 달라야 한다** — 같으면 저장이 |
|
| 41 |
- * 거부돼도 화면의 글자가 하나도 바뀌지 않아 사용자가 실패한 줄 모른다(실제로 그랬다). |
|
| 42 |
- */ |
|
| 43 | 19 |
export const ADMIN_PASSWORD_ERROR_TEXT = |
| 44 | 20 |
'비밀번호가 규칙에 맞지 않습니다. 영어·숫자·특수문자 중 2종류 이상으로 10자리 이상 입력해 주세요.'; |
| 45 |
- |
|
| 46 | 21 |
|
| 47 | 22 |
const LOGIN_ID_MIN_LENGTH = 4; |
| 48 | 23 |
const LOGIN_ID_MAX_LENGTH = 20; |
| 49 | 24 |
|
| 50 |
-/** |
|
| 51 |
- * ID 규칙 — 검증기와 문구를 한 벌로 내보낸다. 등록 팝업의 입력 칸이 이것을 그대로 얹고, |
|
| 52 |
- * 아래 `validateAdminLoginId`(서버 검증·중복 확인)도 같은 배열을 돌린다. 규칙이 한 군데뿐이라 |
|
| 53 |
- * 화면은 통과시키는데 저장은 거부하는 상태가 생기지 않는다. |
|
| 54 |
- * |
|
| 55 |
- * "영어 소문자와 숫자를 조합"은 두 조각으로 나뉜다 — 허용 문자를 `pattern`이 소문자·숫자로 |
|
| 56 |
- * 묶고, 그 안에서 **두 종류가 다 있어야 한다**를 `characterKinds(2)`가 본다. |
|
| 57 |
- */ |
|
| 58 | 25 |
export const ADMIN_LOGIN_ID_VALIDATORS: FoxValidator[] = [ |
| 59 | 26 |
foxValidators.required, |
| 60 | 27 |
foxValidators.minLength(LOGIN_ID_MIN_LENGTH), |
... | ... | @@ -63,7 +30,6 @@ |
| 63 | 30 |
foxValidators.characterKinds(2), |
| 64 | 31 |
]; |
| 65 | 32 |
|
| 66 |
-/** 위 검증기의 오류를 시안 문구로 옮긴다. 길이 두 종류는 같은 말이라 한 문장으로 합친다. */ |
|
| 67 | 33 |
export const ADMIN_LOGIN_ID_MESSAGES: FoxValidationMessages = {
|
| 68 | 34 |
required: 'ID를 입력해 주세요.', |
| 69 | 35 |
minlength: `ID는 ${LOGIN_ID_MIN_LENGTH}~${LOGIN_ID_MAX_LENGTH}자로 입력해 주세요.`,
|
... | ... | @@ -72,11 +38,6 @@ |
| 72 | 38 |
characterKinds: ADMIN_LOGIN_ID_HELP_TEXT, |
| 73 | 39 |
}; |
| 74 | 40 |
|
| 75 |
-/** |
|
| 76 |
- * 비밀번호 규칙 — **화면과 서버가 같은 값을 본다.** 팝업은 이 값을 `foxPasswordValidator`에 |
|
| 77 |
- * 그대로 넘겨 입력 중에 안내하고, 서버 검증은 아래 `validateEditableValues`가 같은 값으로 판정한다. |
|
| 78 |
- * 한쪽만 고치면 화면은 통과시키고 저장은 거부하는 상태가 된다. |
|
| 79 |
- */ |
|
| 80 | 41 |
export const ADMIN_PASSWORD_POLICY = {
|
| 81 | 42 |
minLength: 10, |
| 82 | 43 |
kinds: 2, |
... | ... | @@ -84,10 +45,6 @@ |
| 84 | 45 |
const NAME_MAX_LENGTH = 50; |
| 85 | 46 |
const EMAIL_MAX_LENGTH = 100; |
| 86 | 47 |
|
| 87 |
-/** |
|
| 88 |
- * 이메일 규칙. **필수 여부만 두 시안이 다르다** — 등록(102_p)에는 `*`가 있고 수정(103_p)에는 |
|
| 89 |
- * 없어서, 그 하나만 인자로 받고 나머지는 공유한다. |
|
| 90 |
- */ |
|
| 91 | 48 |
export function adminEmailValidators(required: boolean): FoxValidator[] {
|
| 92 | 49 |
return [ |
| 93 | 50 |
...(required ? [foxValidators.required] : []), |
... | ... | @@ -102,10 +59,8 @@ |
| 102 | 59 |
maxlength: `이메일은 ${EMAIL_MAX_LENGTH}자 이내로 입력해 주세요.`,
|
| 103 | 60 |
}; |
| 104 | 61 |
|
| 105 |
-/** 앞 3자리 + 가운데 3~4자리 + 끝 4자리. 화면은 숫자만 다루고 하이픈은 여기서 붙인다. */ |
|
| 106 | 62 |
const PHONE_DIGITS_PATTERN = /^(\d{3})(\d{3,4})(\d{4})$/;
|
| 107 | 63 |
|
| 108 |
-/** 등록·수정 양쪽에서 실제로 바뀔 수 있는 항목. */ |
|
| 109 | 64 |
export type AdminMemberEditableValues = {
|
| 110 | 65 |
password: string; |
| 111 | 66 |
phoneNumber: string; |
... | ... | @@ -114,13 +69,11 @@ |
| 114 | 69 |
menuCodes: string[]; |
| 115 | 70 |
}; |
| 116 | 71 |
|
| 117 |
-/** 등록은 위 항목에 더해 이름·ID를 입력받는다(수정에서는 읽기 전용). */ |
|
| 118 | 72 |
export type AdminMemberCreateValues = AdminMemberEditableValues & {
|
| 119 | 73 |
name: string; |
| 120 | 74 |
loginId: string; |
| 121 | 75 |
}; |
| 122 | 76 |
|
| 123 |
-/** 필드별 오류 메시지 — 키는 폼 필드 이름과 일치시켜 화면이 그대로 붙여 쓸 수 있게 한다. */ |
|
| 124 | 77 |
export type AdminMemberFormErrors = Partial< |
| 125 | 78 |
Record<keyof AdminMemberCreateValues, string> |
| 126 | 79 |
>; |
... | ... | @@ -129,15 +82,6 @@ |
| 129 | 82 |
| { ok: true; values: T }
|
| 130 | 83 |
| { ok: false; errors: AdminMemberFormErrors };
|
| 131 | 84 |
|
| 132 |
-/** |
|
| 133 |
- * 등록/수정 Server Action의 `useActionState` 결과 상태. 원래는 `_actions.ts`(`'use server'` |
|
| 134 |
- * 파일)에 있었으나, Next.js는 **`'use server'` 파일이 async 함수 외의 값을 export하는 것을 |
|
| 135 |
- * 런타임에 거부한다**("A 'use server' file can only export async functions, found object" —
|
|
| 136 |
- * `INITIAL_ADMIN_MEMBER_FORM_STATE`처럼 일반 객체 상수를 함께 export하면 그 파일의 Server |
|
| 137 |
- * Action을 호출하는 즉시 500으로 깨진다. 빌드/타입체크는 통과하고 실제로 폼을 제출해야만 |
|
| 138 |
- * 드러나는 런타임 전용 제약이라 여기로 옮겼다 — domain 계층은 `'use server'`가 없어 값 export에 |
|
| 139 |
- * 제약이 없다(`decoration-item-form.ts`의 동일 패턴 참조). |
|
| 140 |
- */ |
|
| 141 | 85 |
export type AdminMemberFormState = |
| 142 | 86 |
| { status: 'idle' }
|
| 143 | 87 |
| { status: 'error'; message?: string; errors?: AdminMemberFormErrors }
|
... | ... | @@ -147,56 +91,25 @@ |
| 147 | 91 |
status: 'idle', |
| 148 | 92 |
}; |
| 149 | 93 |
|
| 150 |
-/** |
|
| 151 |
- * 비밀번호 조합 종류 수 — 영문 / 숫자 / 특수문자 중 몇 종류가 섞였는지 센다. |
|
| 152 |
- * |
|
| 153 |
- * **대문자도 영문 한 종류로 센다** — 화면이 쓰는 `@fox`의 `characterKinds`가 `/[a-zA-Z]/`로 |
|
| 154 |
- * 판정하므로 여기가 `[a-z]`면 규칙이 갈린다. 실제로 `PASSWORD123`처럼 영문자가 전부 대문자인 |
|
| 155 |
- * 값이 화면은 통과하고 저장만 거부되는 상태였다(오류 문구가 안내 문구와 같아 화면에 아무것도 |
|
| 156 |
- * 드러나지 않았다). |
|
| 157 |
- */ |
|
| 158 | 94 |
function countCharacterKinds(password: string): number {
|
| 159 | 95 |
const kinds = [/[a-zA-Z]/, /\d/, /[^a-zA-Z0-9]/]; |
| 160 | 96 |
return kinds.filter((pattern) => pattern.test(password)).length; |
| 161 | 97 |
} |
| 162 | 98 |
|
| 163 |
-/** |
|
| 164 |
- * ID 형식 검증 — 문제가 있으면 안내 문구, 없으면 null. |
|
| 165 |
- * |
|
| 166 |
- * 중복 확인(`checkAdminLoginId`)도 이 함수를 그대로 쓴다 — 중복을 묻기 전에 형식부터 봐야 하고, |
|
| 167 |
- * 그 판단 기준이 등록 시점과 달라지면 안 되기 때문이다. 화면이 얹는 검증기와 **같은 배열**을 |
|
| 168 |
- * 돌리므로 서버가 돌려주는 문구와 입력 중에 보이는 문구가 같다. |
|
| 169 |
- */ |
|
| 170 | 99 |
export function validateAdminLoginId(loginId: string): string | null {
|
| 171 | 100 |
const errors = foxValidators.compose(ADMIN_LOGIN_ID_VALIDATORS)(loginId.trim()); |
| 172 | 101 |
return foxValidationMessage(errors, ADMIN_LOGIN_ID_MESSAGES) ?? null; |
| 173 | 102 |
} |
| 174 | 103 |
|
| 175 |
-/** |
|
| 176 |
- * 화면이 넘긴 숫자열(`01012345678`)을 저장 형식(`010-1234-5678`)으로 바꾼다. 자릿수가 맞지 않으면 |
|
| 177 |
- * null — 부분적으로 채워진 번호를 저장하지 않기 위해서다. |
|
| 178 |
- * |
|
| 179 |
- * 화면(`FoxPhoneNumber`)은 하이픈을 그리기만 하고 값으로는 숫자만 내보낸다. 반대로 목록·엑셀은 |
|
| 180 |
- * 하이픈이 있는 형태로 보여주므로, 두 표현 사이의 변환을 이 한 쌍이 책임진다. |
|
| 181 |
- */ |
|
| 182 | 104 |
export function formatPhoneNumber(digits: string): string | null {
|
| 183 | 105 |
const matched = PHONE_DIGITS_PATTERN.exec(digits.trim()); |
| 184 | 106 |
return matched === null ? null : `${matched[1]}-${matched[2]}-${matched[3]}`;
|
| 185 | 107 |
} |
| 186 | 108 |
|
| 187 |
-/** 저장된 번호에서 숫자만 남긴다(수정 팝업의 초기값). */ |
|
| 188 | 109 |
export function toPhoneDigits(phoneNumber: string | null): string {
|
| 189 | 110 |
return (phoneNumber ?? '').replace(/\D/g, ''); |
| 190 | 111 |
} |
| 191 | 112 |
|
| 192 |
-/** |
|
| 193 |
- * 등록·수정 공통 항목 검증. 오류는 넘겨받은 객체에 채워 넣고, 정규화된 값을 돌려준다. |
|
| 194 |
- * |
|
| 195 |
- * **비밀번호·이메일은 등록에서만 필수다.** 수정에서 비우면 "바꾸지 않음"이 된다 — 백엔드 UPDATE의 |
|
| 196 |
- * `LOGIN_PW`가 `<if test='loginPw != null and loginPw != ""'>`로 감싸여 있어 빈 값은 SET 절에서 |
|
| 197 |
- * 빠진다(예전에는 조건 없이 덮어써서, 빈 값을 보내면 그 계정이 로그인 불가가 됐다). 값이 있으면 |
|
| 198 |
- * 등록·수정 모두 같은 형식 규칙을 통과해야 한다. |
|
| 199 |
- */ |
|
| 200 | 113 |
function validateEditableValues( |
| 201 | 114 |
values: AdminMemberEditableValues, |
| 202 | 115 |
errors: AdminMemberFormErrors, |
... | ... | @@ -233,9 +146,6 @@ |
| 233 | 146 |
errors.roleCode = '역할을 선택해 주세요.'; |
| 234 | 147 |
} |
| 235 | 148 |
|
| 236 |
- // 메뉴 선택은 **필수가 아니다**(사용자 확정) — 백엔드에 관리자별 메뉴 권한이 없어 고른 값이 |
|
| 237 |
- // 저장되지 않는데, 저장되지도 않는 값 때문에 등록이 막히면 안 된다. 허용 목록 밖의 코드만 |
|
| 238 |
- // 걸러 둔다(권한 API가 생기면 여기에 필수 규칙을 되살린다). |
|
| 239 | 149 |
const menuCodes = values.menuCodes.filter((code) => |
| 240 | 150 |
ADMIN_MENU_OPTIONS.some((option) => option.value === code) |
| 241 | 151 |
); |
... | ... | @@ -243,7 +153,6 @@ |
| 243 | 153 |
return { password, phoneNumber, email, roleCode: values.roleCode, menuCodes };
|
| 244 | 154 |
} |
| 245 | 155 |
|
| 246 |
-/** 시안 ADM_ADM_102_p — 등록 검증(이름·ID 포함). */ |
|
| 247 | 156 |
export function validateAdminMemberCreate( |
| 248 | 157 |
values: AdminMemberCreateValues |
| 249 | 158 |
): ValidationResult<AdminMemberCreateValues> {
|
... | ... | @@ -276,7 +185,6 @@ |
| 276 | 185 |
}; |
| 277 | 186 |
} |
| 278 | 187 |
|
| 279 |
-/** 시안 ADM_ADM_103_p — 수정 검증. 이름·ID는 읽기 전용이라 검증 대상이 아니다(파일 상단 주석). */ |
|
| 280 | 188 |
export function validateAdminMemberUpdate( |
| 281 | 189 |
values: AdminMemberEditableValues |
| 282 | 190 |
): ValidationResult<AdminMemberEditableValues> {
|
--- lib/domain/admin-member-query.ts
+++ lib/domain/admin-member-query.ts
... | ... | @@ -1,31 +1,8 @@ |
| 1 |
-/** |
|
| 2 |
- * 관리자 회원 목록의 검색·정렬·페이징 조건 — 순수 규칙(허용 값·기본값·URL 직렬화)만 담는다. |
|
| 3 |
- * next/react 의존이 없다(`URLSearchParams`는 서버·브라우저 양쪽에서 쓸 수 있는 표준 Web API). |
|
| 4 |
- * |
|
| 5 |
- * `app/(protected)/(basic)/admins/page.tsx`가 `searchParams`를 `parseAdminMemberQuery`로 |
|
| 6 |
- * 정규화하는 지점이자 단일 진실원천이며, 검색바·툴바·페이지네이션은 모두 `buildAdminMemberHref`로 |
|
| 7 |
- * 같은 규칙에 따라 URL을 만들어 파라미터 이름·기본값이 여러 파일에 흩어져 드리프트하는 것을 막는다. |
|
| 8 |
- * |
|
| 9 |
- * 학생 회원 목록(`student-member-query.ts`)과 구조가 같지만 파일을 합치지 않았다 — 두 화면의 |
|
| 10 |
- * 검색 대상·정렬 기준·기본값이 각자의 기획(ADM_MEM_101 / ADM_ADM_101)을 따라 서로 다르게 |
|
| 11 |
- * 움직이고, 한쪽 기획 변경이 다른 화면을 건드리게 되는 결합이 공통화의 이득보다 크다. |
|
| 12 |
- */ |
|
| 13 | 1 |
|
| 14 |
-/** 라우트 경로 — 이 파일 안에서만 하드코딩하고 나머지는 이 상수를 참조한다. */ |
|
| 15 | 2 |
export const ADMIN_MEMBERS_PATH = '/admins'; |
| 16 | 3 |
|
| 17 |
-/** 엑셀 다운로드 라우트 핸들러의 경로 — 목록 툴바의 폼이 이 주소로 GET 제출한다. */ |
|
| 18 | 4 |
export const ADMIN_MEMBERS_EXCEL_PATH = `${ADMIN_MEMBERS_PATH}/excel`;
|
| 19 | 5 |
|
| 20 |
-/** |
|
| 21 |
- * 검색 대상 — 시안(ADM_ADM_101 ①)의 회원명/ID/휴대전화번호 셋. |
|
| 22 |
- * |
|
| 23 |
- * 휴대전화번호는 한동안 빠져 있었다. 목록 응답에 그 값이 없어 결과를 눈으로 검증할 수 없었고, |
|
| 24 |
- * 백엔드의 해당 분기가 관리자 테이블에 없는 `USER_TELNO`를 참조해 SQL 오류가 날 상태였다. |
|
| 25 |
- * 백엔드가 `ADM_TEL_NO`를 select 목록과 검색 분기 양쪽에 넣으면서 두 이유가 모두 사라졌다. |
|
| 26 |
- * |
|
| 27 |
- * 실제 필터링은 Repository가 전체를 손에 쥐고 수행한다 — 이 값은 "어느 열로 거를지"만 정한다. |
|
| 28 |
- */ |
|
| 29 | 6 |
export type AdminMemberSearchField = 'name' | 'loginId' | 'phoneNumber'; |
| 30 | 7 |
|
| 31 | 8 |
export const ADMIN_MEMBER_SEARCH_FIELD_OPTIONS: ReadonlyArray<{
|
... | ... | @@ -37,13 +14,6 @@ |
| 37 | 14 |
{ value: 'phoneNumber', label: '휴대전화번호' },
|
| 38 | 15 |
]; |
| 39 | 16 |
|
| 40 |
-/** |
|
| 41 |
- * 정렬 기준 — 시안의 select는 "가입일순"이지만 관리자 회원의 해당 값은 생성일이라 이름을 맞췄다. |
|
| 42 |
- * |
|
| 43 |
- * 생성일순이 곧 "백엔드가 준 순서를 그대로 쓴다"는 뜻이다 — 목록 SQL의 고정 `ORDER BY rnum DESC`가 |
|
| 44 |
- * 생성일 최신순이기 때문이다(rnum은 `frst_reg_dt` 오름차순 행번호). 정렬 파라미터가 없어 이름순만 |
|
| 45 |
- * 우리가 정렬한다. |
|
| 46 |
- */ |
|
| 47 | 17 |
export type AdminMemberSortOption = 'createdAt' | 'name'; |
| 48 | 18 |
|
| 49 | 19 |
export const ADMIN_MEMBER_SORT_OPTIONS: ReadonlyArray<{
|
... | ... | @@ -72,8 +42,6 @@ |
| 72 | 42 |
pageSize: AdminMemberPageSize; |
| 73 | 43 |
}; |
| 74 | 44 |
|
| 75 |
-/** Next.js `page.tsx`의 `searchParams`가 리졸브하는 값 형태를 그대로 옮긴 구조 타입 — |
|
| 76 |
- * next 패키지를 import하지 않고도 같은 shape을 표현해 domain 계층의 무의존 규칙을 지킨다. */ |
|
| 77 | 45 |
type RawSearchParams = Record<string, string | string[] | undefined>; |
| 78 | 46 |
|
| 79 | 47 |
function readParam(params: RawSearchParams, key: string): string | undefined {
|
... | ... | @@ -103,11 +71,6 @@ |
| 103 | 71 |
return (ADMIN_MEMBER_PAGE_SIZE_OPTIONS as readonly number[]).includes(value); |
| 104 | 72 |
} |
| 105 | 73 |
|
| 106 |
-/** |
|
| 107 |
- * URL의 searchParams를 검증된 `AdminMemberQuery`로 정규화한다. 값이 없거나 허용 목록을 |
|
| 108 |
- * 벗어나면 기본값으로 fallback한다 — searchParams는 사용자가 임의로 조작 가능한 값이라 |
|
| 109 |
- * 신뢰하지 않는다. |
|
| 110 |
- */ |
|
| 111 | 74 |
export function parseAdminMemberQuery( |
| 112 | 75 |
searchParams: RawSearchParams |
| 113 | 76 |
): AdminMemberQuery {
|
... | ... | @@ -132,10 +95,6 @@ |
| 132 | 95 |
}; |
| 133 | 96 |
} |
| 134 | 97 |
|
| 135 |
-/** |
|
| 136 |
- * `AdminMemberQuery`(+ 부분 override)를 `/admins` 링크로 직렬화한다. `parseAdminMemberQuery`의 |
|
| 137 |
- * 역연산이며, 기본값과 같은 필드는 URL에서 생략해 링크를 짧게 유지한다. |
|
| 138 |
- */ |
|
| 139 | 98 |
export function buildAdminMemberHref( |
| 140 | 99 |
query: AdminMemberQuery, |
| 141 | 100 |
overrides: Partial<AdminMemberQuery> = {}
|
--- lib/domain/file-module.ts
+++ lib/domain/file-module.ts
... | ... | @@ -1,25 +1,8 @@ |
| 1 |
-/** |
|
| 2 |
- * 파일 업로드 API(`POST /api/v1/common/file/upload/{moduleId}`)가 받는 moduleId 모음.
|
|
| 3 |
- * |
|
| 4 |
- * **전부 임시값이다.** 이 값은 백엔드에서 파일 저장 설정(`FileStrgStngVo.strgStngId`)을 가리키고, |
|
| 5 |
- * 그 설정이 저장 폴더·허용 확장자·최대 크기·파일 개수 제한·원본 파일명 보존 여부를 정한다 |
|
| 6 |
- * (`EgovFileMngUtil.parseFileInf`). 그런데 백엔드 **코드**에 실제로 등장하는 모듈은 |
|
| 7 |
- * `MODULE_POCKET`·`MODULE_USER_PROFILE` 둘뿐이고, 아래 값들이 저장 설정 테이블에 있는지는 |
|
| 8 |
- * 저장소만 봐서는 알 수 없다(DB에만 있다). |
|
| 9 |
- * |
|
| 10 |
- * 틀린 값을 보내도 **업로드가 실패하지는 않는다** — 설정을 못 찾으면 기본 저장소(`FILE_STORAGE`)로 |
|
| 11 |
- * 폴백해 조용히 다른 곳에 저장된다. 그래서 잘못돼도 티가 나지 않는 것이 이 값의 위험한 점이다. |
|
| 12 |
- * (기본 저장소 설정마저 없으면 그때는 NPE로 500이 되어 화면에 업로드 실패로 뜬다.) |
|
| 13 |
- * |
|
| 14 |
- * **백엔드가 모듈 목록 조회 API를 만들어 주기로 했다(2026-08-19 합의).** 그 API가 나오면 이 파일의 |
|
| 15 |
- * 상수를 지우고 응답으로 대체한다 — 업로드 호출부는 모두 여기만 참조하므로 바꿀 곳은 여기 하나다. |
|
| 16 |
- * 그때 화면이 모듈을 고르게 할 필요는 없다. 각 화면이 쓰는 모듈은 고정이고, 조회 API는 "그 이름이 |
|
| 17 |
- * 실재하는지"를 확인하는 용도다. |
|
| 18 |
- */ |
|
| 1 |
+// TODO: 백엔드가 모듈 목록 조회 API를 만들어 주기로 함(2026-08-19) — 나오면 이 상수를 대체한다. |
|
| 2 |
+// 이슈: 틀린 값이어도 업로드가 실패하지 않는다. 백엔드가 저장 설정을 못 찾으면 기본 저장소로 |
|
| 3 |
+// 폴백해 조용히 다른 곳에 저장한다. |
|
| 19 | 4 |
export const FILE_MODULE_ID = {
|
| 20 |
- /** 꾸미기 아이템 썸네일. */ |
|
| 21 | 5 |
decorationItem: 'MODULE_ITEM', |
| 22 |
- /** 게시판 첨부파일. */ |
|
| 23 | 6 |
board: 'MODULE_BBS', |
| 24 | 7 |
} as const; |
| 25 | 8 |
|
--- next.config.ts
+++ next.config.ts
... | ... | @@ -22,12 +22,6 @@ |
| 22 | 22 |
const nextConfig: NextConfig = {
|
| 23 | 23 |
experimental: {
|
| 24 | 24 |
serverActions: {
|
| 25 |
- // 이미지·첨부파일은 Server Action **본문에 실려** 온다(브라우저가 백엔드를 직접 부르지 |
|
| 26 |
- // 않으므로 — 토큰이 httpOnly 세션 안에만 있다). 기본값 1MB로는 웬만한 이미지가 요청 |
|
| 27 |
- // 단계에서 413으로 잘리는데, 그러면 **액션 본문이 실행조차 되지 않아** `useActionState`가 |
|
| 28 |
- // idle 그대로다 — 화면에는 오류도 토스트도 없이 폼만 비워져 "아무 반응 없음"으로 보인다. |
|
| 29 |
- // 백엔드는 `CommonsMultipartResolver`가 100MB까지 받지만, 이 본문은 Next 서버 메모리에 |
|
| 30 |
- // 통째로 올라가므로 관리 화면에 필요한 만큼만 연다. |
|
| 31 | 25 |
bodySizeLimit: '10mb', |
| 32 | 26 |
}, |
| 33 | 27 |
}, |
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?