기획에서 「메뉴 선택」이 빠졌다(사용자 확정). 화면만 감추지 않고 네 층에서 모두 걷어낸다 —
남겨 두면 저장되지도 조회되지도 않는 값이 계속 도메인에 떠다닌다.
화면 등록·수정 팝업의 메뉴 칩 묶음과 그 상태·prop
검증 `AdminMemberEditableValues.menuCodes`와 허용 코드 필터
액션 `readMenuCodes` (hidden 필드 수집)
도메인 `AdminMember.menuCodes` · `ADMIN_MENU_OPTIONS` mock 카탈로그 ·
`formatAdminMenuLabels`
응답 Repository가 채우던 `menuCodes: []`
애초에 백엔드에 관리자별 메뉴 권한이 없어 저장도 조회도 되지 않던 항목이라, 지우면서 없어지는
기능은 없다.
`/admins/menus`(메뉴관리 화면)는 그대로 둔다 — 관리자마다의 접근 권한이 아니라 메뉴 자체를
관리하는 별개 화면이다.
확인: 등록 팝업에 남은 항목이 이름·ID·비밀번호·휴대전화 번호·이메일·역할 선택뿐이고,
「메뉴 선택」 문구와 칩이 하나도 없으며 hidden 필드도 phoneNumber·email·roleCode만 남는다.
Co-Authored-By: Claude Opus 5
Fox*Icon 컴포넌트 141곳을 fox-ico 클래스로 바꾸고 옛 SVG 모듈을 지웠다.
아이콘 상자 규칙에는 상자 크기를 그대로 둔 채 font-size만 더했다 — 손으로 그린
인라인 svg가 든 자리도 같이 쓰이기 때문이다.
사이드바 메뉴 아이콘의 화이트리스트가 사라졌다. 폰트는 통째로 하나라 이름이 늘어도
번들이 커지지 않아, 백엔드가 주는 kebab 이름을 PascalCase로 바꿔 붙이기만 하면 된다.
@fox 13.3MB → 6.4MB.
Co-Authored-By: Claude Opus 5
사용자 지시 — "주석이 너무 많다, 주석은 TODO나 이슈사항만 적어줘".
이번 작업에서 내가 쓴 설명 주석을 지웠다(23개 파일, 633줄 삭제 / 14줄 추가). 파일·함수 설명,
동작 요약, 결정 근거, 시안 번호, 검증 방법은 전부 뺐다 — 코드·커밋 메시지·README가 할 일이다.
남긴 것은 없으면 다음 사람이 사고를 낼 자리 다섯 줄뿐이다.
- 등록은 form(@ParameterObject), 수정은 JSON(@RequestBody)로 갈려 있음
- 백엔드 totalCount가 전체가 아니라 현재 페이지 행 수
- 관리자별 메뉴 권한이 백엔드에 없어 저장되지 않음
- 파일 업로드 moduleId는 틀려도 실패하지 않고 기본 저장소로 폴백 (+ 모듈 조회 API TODO)
- `
앞자리 셀렉트를 비워 두면 placeholder "010"이 이미 고른 것처럼 보이는데 값은 빈 문자열이라,
사용자가 앞자리를 건드리지 않고 뒷자리만 채우면 8자리가 제출돼 "휴대전화 번호를 정확히 입력해
주세요."로 막혔다. 목록을 넘겨 고를 수 있게 한 것(a0316e6)만으로는 여전히 한 번 골라야 했다.
값을 화면이 소유하므로 초기값을 DEFAULT_MOBILE_PHONE_PREFIX('010')로 둔다 — 이제 placeholder가
아니라 실제 값이라 그대로 제출된다. 수정 팝업도 저장된 번호가 없으면 같은 기본값으로 시작한다.
검증 — 화면 실측: 팝업을 열자마자 제출값이 "010"(placeholder 아님)이고, 앞자리 셀렉트를 한 번도
건드리지 않고 1234·5678만 입력했을 때 제출값 "01012345678" → 서버 포맷 "010-1234-5678" 통과.
Co-Authored-By: Claude Opus 5
아이템 등록·수정 팝업은 hub가 @fox로 재작성해 둔 쪽을 취하고, 거기에 폼 제출 방식만 다시
얹었다(브랜치 쪽 파일은 재작성 전 버전이라 그대로 쓰면 되돌리는 셈이 된다).
hub에만 있던 코드관리 팝업 둘(코드그룹·코드상세)도 같은 결함이라 함께 바꿨다 — 이 병합으로
`action={formAction}`을 쓰는 폼은 학생 조회 하나만 남고, 그건 hidden id뿐이라 잃을 값이 없다.
Co-Authored-By: Claude Opus 5
**React는 폼 액션이 끝나면 성공·실패를 가리지 않고 폼을 리셋한다.** 그래서 검증 오류가 하나만
나도 사용자가 채운 값이 전부 사라졌다. 특히 `type="file"`은 프로그램으로 되채울 수 없어, 한 번
실패하면 그 뒤로는 **"썸네일 이미지를 등록해 주세요."만 반복되는 상태에 갇힌다** — 꾸미기 아이템
등록에서 실제로 그랬다(다른 항목이 틀려 한 번 실패 → 파일 선택까지 지워짐 → 이후 계속 이미지
오류). 즉 사용자에게는 "고칠수록 더 안 되는" 화면이었다.
확인: 같은 Server Action을 두 방식으로 제출해 비교했다. `action={formAction}`은 입력값이 빈
문자열이 됐고, `onSubmit` + `startTransition(() => formAction(fd))`은 값이 그대로 남았다.
실제 로그인 폼으로도 검증 — 백엔드가 "아이디 또는 비밀번호가 올바르지 않습니다."를 돌려준
뒤에도 입력값이 유지된다(종전에는 다시 타이핑해야 했다).
같은 결함이 있던 폼 7개를 한 번에 바꿨다(관리자 등록·수정, 게시글, 문의 답변, 아이템 등록·수정,
로그인). 학생 조회 팝업은 hidden id 하나뿐이라 잃을 값이 없어 그대로 뒀다.
이유를 한곳에 적어 두려고 `submitFormAction`으로 뽑았다 — 세 줄짜리라 각자 쓰면 왜 `action`을
안 쓰는지가 사라진다.
Co-Authored-By: Claude Opus 5
증상: 다 입력하고 [저장]을 눌러도 이름·비밀번호만 비워지고 아무 메시지 없이 생성되지 않음.
원인 두 가지가 겹쳐 있었다.
1. **비밀번호 조합 판정이 화면과 서버에서 달랐다.** 화면이 쓰는 @fox의 characterKinds는
`/[a-zA-Z]/`로 대문자를 영문 한 종류로 세는데(그 파일 주석에 "시안이 그렇게 묻는다"고
적혀 있다), 서버의 countCharacterKinds만 `/[a-z]/`로 남아 대문자를 어느 종류에도 세지
않았다. 그래서 영문자가 전부 대문자인 값(PASSWORD123·EDUPAY2026 등)이 화면은 통과하고
저장만 거부됐다. 서버를 `/[a-zA-Z]/`로 맞춰 규칙을 한 벌로 되돌렸다.
2. **그 거부가 화면에 드러나지 않았다.** 오류 문구가 평소 안내 문구(ADMIN_PASSWORD_HELP_TEXT)와
같은 문자열이라, 실패해도 글자가 하나도 바뀌지 않고 테두리 색만 달라졌다. 여기에 React가
폼 액션 뒤 비제어 입력(이름·비밀번호)을 비우는 동작이 겹쳐 "값만 리셋되고 아무 일도 없음"으로
보였다. ADMIN_PASSWORD_ERROR_TEXT를 따로 두고 서버 검증과 화면 minlength 문구가 그걸 쓰게 했다.
3. 같은 사각지대가 ID 칸에도 있었다 — 중복확인을 통과하면 '사용할 수 있는 ID입니다.'가
errors.loginId보다 우선해, 저장이 ID 때문에 거부돼도(확인과 저장 사이의 선점 등) 화면에서
사라졌다. 서버 오류가 성공 문구를 이기도록 순서를 바꿨다.
검증 — 규칙 대조: 수정 전 PASSWORD123/EDUPAY2026/ABCDEFGH12가 클라 통과·서버 실패였고,
수정 후 여덟 가지 표본에서 클라·서버 판정이 전부 일치한다.
화면 실측(실제 키 입력): ABCDEFGHIJ → "영문, 숫자, 특수문자 중 2종류 이상을 섞어 주세요." +
aria-invalid=true, PASSWORD123 → 통과(오류 없음).
Co-Authored-By: Claude Opus 5
비밀번호에 이어 ID·이메일도 `@fox/core/validation` 위에 올렸다. 핵심은 검증기를 화면이 아니라
**도메인이 소유**한다는 점이다 — `ADMIN_LOGIN_ID_VALIDATORS`·`adminEmailValidators()`를
`admin-member-form.ts`가 내보내고, 팝업의 입력 칸과 Server Action의 검증이 **같은 배열**을
돌린다. 규칙이 한 군데뿐이라 화면은 통과시키는데 저장은 거부하는 상태가 생기지 않고, 서버가
돌려주는 문구와 입력 중에 보이는 문구가 같다.
ID의 "영어 소문자와 숫자를 조합"은 두 조각으로 나뉜다 — 허용 문자는 `pattern`이 소문자·숫자로
묶고, 그 안에서 둘 다 있어야 한다는 것은 `characterKinds(2)`가 본다. 종전의 손으로 쓴 세 갈래
분기(`validateAdminLoginId`)와 `isValidEmail`은 지웠다.
이메일은 필수 여부만 두 시안이 달라(등록에 `*`, 수정에 없음) 그것만 인자로 받는다.
등록 팝업의 이메일은 칸이 둘로 나뉘어 있어 검증 대상이 합친 값이다. FoxInput 한 칸이 스스로
판정할 수 없으므로 팝업이 돌리고 `touched`도 직접 든다(FoxInput의 blur 규칙과 같은 시점).
아울러 **로컬부가 비면 빈 이메일로 본다** — 도메인만 고른 `@naver.com`을 값으로 보내면 "형식이
올바르지 않습니다"가 뜨지만, 사용자가 한 일은 아무것도 입력하지 않은 것이다.
브라우저 확인: ID는 대문자(`Admin`)·22자에서 각각 다른 사유가 뜨고 `admin01`에서 사라진다.
이메일은 빈 로컬부에서 "이메일을 입력해 주세요.", 입력하면 사라지며 hidden 값이 채워진다.
Co-Authored-By: Claude Opus 5
두 변경이 등록 팝업의 같은 파일에 겹쳐 한 커밋으로 둔다.
## 중복 확인 실패 사유
확인 호출이 백엔드에서 깨지면 `isAdminLoginIdTaken`이 던진 예외가 클라이언트 transition의
처리되지 않은 rejection으로 사라졌다 — 버튼만 원복되고 아무 말도 남지 않아 "눌러도 아무 일이
없는" 상태로 보였다. 확인 결과에 `failed`를 더해 **값의 판정(unavailable)과 확인 실패(failed)를
구분**하고, 백엔드 메시지를 ID 칸 아래 같은 자리에 그대로 띄운다.
## @fox/core/validation
Angular `Validators`의 계약을 옮겼다 — 검증기는 값을 받아 통과면 `null`, 아니면 **오류 객체**를
돌려주는 순수 함수이고, 키가 오류 이름, 값이 문구를 만들 맥락이다
(`{ minlength: { requiredLength, actualLength } }`). 인자가 `AbstractControl`이 아니라 문자열인
것만 다르다 — @fox에는 폼 모델이 없고 검증 대상이 칸의 값 하나뿐이다.
`required`를 뺀 검증기는 빈 값을 통과시키고(Angular와 같다), `compose`는 오류를 **병합**해
"10자 이상"과 "2종류 이상"을 함께 판정할 수 있게 한다.
**문구는 검증기가 갖지 않는다**(Angular와 같은 이유 — 같은 규칙도 자리마다 다른 말이 필요하다).
`foxValidationMessage(errors, overrides)`가 옮기고, 한 번에 한 문구만 낸다.
비밀번호처럼 규칙이 여러 개인 칸을 위해 `foxPasswordValidator(policy)`를 둔다 — 길이·조합에
더해 같은 문자 연속·잇따르는 문자·특정 문자열 포함 금지를 옵션으로 받는다.
## FoxInput 연결
`validators` · `validationMessages` · `validateOn` · `onValidationChange`.
- `message`는 평소 헬퍼이고 검증이 걸리면 그 자리에 사유가 들어간다. 호출부가 `invalid`를 직접
켜면 `message`가 이긴다 — 서버가 준 사유(중복·권한)는 화면 규칙이 알 수 없어 덮이면 안 된다.
- 오류는 **한 번 포커스를 벗어난 뒤부터** 보이고 그 뒤로는 입력할 때마다 갱신된다
(Angular의 `touched`. `updateOn` 기본값만 다르다 — 한 글자에 "10자 이상"이 뜨면 방해가 된다).
## 사용
비밀번호 규칙을 `ADMIN_PASSWORD_POLICY` 한 곳으로 모아 **화면과 Server Action이 같은 값을 본다**
— 한쪽만 고치면 화면은 통과시키고 저장은 거부하는 상태가 된다. 등록은 `required: true`,
수정은 비우면 "바꾸지 않음"이라 걸지 않는다.
Co-Authored-By: Claude Opus 5
## 백엔드 최신화 반영 (edupay-backend develop 4d98756)
- **수정이 동작하지 않던 것을 고쳤다.** `PUT /mngr/admin`에 form으로 보내고 있었으나 백엔드는
`PUT /mngr/admin/{admUserId}` + `@RequestBody`(JSON)다. 등록만 `@ParameterObject`(form)라
같은 도메인인데 본문 형식이 갈린다 — 두 함수 주석에 그 이유를 적어 두었다.
- 삭제를 mock 오버레이에서 실 API(`DELETE /{admUserId}`, soft delete)로 옮기고
`lib/data/mock/admin-member-store.ts`를 지웠다.
- 엑셀 다운로드 API가 생겨 `/admins/excel` 라우트 핸들러로 중계하고 버튼을 열었다.
- 목록 응답에 `admTelNo`·`admEmlAddr`·`frstRegDtStr`이 추가돼 생성일 매핑을 넣었다 —
휴대전화번호·이메일·생성일 세 열의 `-`가 실값으로 바뀐다.
- 검색 분기가 `USER_TELNO`→`ADM_TEL_NO`로 고쳐져 휴대전화번호 검색을 되살렸다(시안 ①의 셋).
- 수정 시 비밀번호 필수 제약을 없앴다 — UPDATE의 `LOGIN_PW`가 빈 값이면 SET 절에서 빠지도록
바뀌어 "바꾸지 않음"을 표현할 수 있게 됐다.
- `totalCount`는 여전히 `list.size()`라 전체를 받아 서버에서 세는 방식은 유지한다.
## 등록·수정 팝업 (시안 5227:2035 / 5227:3351)
@fox로 다시 그렸다 — modal·input·btn·form-label·form-phonenumber·select·chip-area·toggle이
Figma 인스턴스 이름과 그대로 대응한다. 확인한 값: 모달 400, 입력 40, 칩 28, 푸터 버튼 80×40,
필드 간격 32.
저장 버튼은 모달 actions 슬롯이라 폼 밖에 그려진다. FoxButton에 네이티브 `form` 속성이 없어
폼을 ref로 잡아 `requestSubmit()`을 부른다. @fox 제어 위젯(전화번호·역할·메뉴)은 값이
FormData에 실리지 않아 hidden input으로 함께 낸다.
이메일은 두 시안이 다르다 — 등록은 `[아이디] @ [도메인]`, 수정은 단일 입력이라 그대로 따랐다.
등록 쪽은 `FoxEmail`을 쓰지 않는다: 잠긴 '직접입력' 상자를 하나 더 그리고 칸마다 240px 최소폭이
박혀 320px 안에서 세 줄로 쌓인다(사용자 확정).
메뉴 선택은 시안대로 그리되 **필수 검증을 걸지 않는다**(사용자 확정) — 백엔드에 관리자별 메뉴
권한이 테이블·VO·SQL 어디에도 없어 고른 값이 저장되지 않는다.
삭제 확인 얼럿의 버튼도 무스타일 `components/ui/button`에서 FoxButton으로 옮겼다.
Co-Authored-By: Claude Opus 5
백엔드(develop 924db37)에 관리자 쓰기 API가 추가되어 mock을 걷어낸다.
등록 POST /mngr/admin, 수정 PUT /mngr/admin, 중복확인과 단건 조회는 전용 GET을
쓴다. mock 저장소에는 삭제만 남는다 — 백엔드에 삭제 엔드포인트가 없고, 수정
API로도 USE_YN을 끌 수 없어 비활성화로 대신할 수 없다.
수정 시 비밀번호를 필수로 막는다. 백엔드 UPDATE가 LOGIN_PW를 조건 없이
덮어써서 빈 값을 보내면 그 계정이 로그인 불가가 되기 때문이다.
메뉴 선택은 시안대로 유지하되 전송하지 않는다 — 관리자별 메뉴 권한 개념이
백엔드에 없다.
Co-Authored-By: Claude Opus 5
admins/_actions.ts('use server')가 AdminMemberFormState 타입의 초깃값
INITIAL_ADMIN_MEMBER_FORM_STATE를 값으로 export하고 있어, 관리자 등록/수정/
삭제/ID 중복확인 Server Action을 호출할 때마다 "A 'use server' file can
only export async functions, found object" 런타임 에러로 500이 발생했다.
빌드·타입체크는 통과하고 실제로 폼을 제출해야만 드러나는 문제였다.
decoration-items가 이미 적용해 둔 동일 패턴을 따라 AdminMemberFormState
타입과 INITIAL_ADMIN_MEMBER_FORM_STATE 상수를 'use server'가 없는
lib/domain/admin-member-form.ts로 옮기고, _actions.ts는 타입만 다시
import하도록 정리했다. 두 소비처(admin-member-create-modal.tsx,
admin-member-edit-modal.tsx)의 import 경로도 함께 갱신했다.
Co-Authored-By: Claude Opus 5
사이드바 「관리자정보관리 > 관리자 회원」(/admins) 신설.
목록은 백엔드 GET /api/v1/mngr/admin/pagination으로 조회하고,
등록·수정·삭제는 백엔드 API가 없어 메모리 mock 오버레이로 처리한다
(lib/data/mock/admin-member-store.ts — API 추가 시 Repository 세 함수만 교체).
백엔드 계약 확인에 따른 결정:
- 응답에 휴대전화번호·이메일·생성일이 없어 해당 열은 `-`로 표기(열은 유지)
- totalCount가 현재 페이지 행 수라 신뢰할 수 없어, 전체를 받아 서버에서
검색·정렬·페이징한다(총건수 확정 → 번호 역순·현재페이지 표기 성립)
- searchCondition=3(휴대전화번호)이 TB_ADM_USER에 없는 USER_TELNO를
참조해 검색 대상에서 제외
- admRoleCd 코드 정의가 백엔드에 없어 잠정 매핑 + 미지의 코드는 원문 노출
엑셀다운로드는 버튼만 배치(추후 진행), 아이콘 부재로 관리 열은 텍스트 버튼.
Co-Authored-By: Claude Opus 5