admin-sidebar(시안 snb-area 3041:11860)의 규칙을 그대로 옮겼다. 240px 폭,
회색→옅은 파랑 그라데이션, 1depth 알약(multiply)·2depth 흰 카드·3depth 들여쓰기.
@fox 폴더만 복사해도 하니스가 따라가야 해서 app SCSS를 참조하지 않고 옮겨 적었다.
깊이를 맞추려고 트리도 본 프로젝트와 같은 3depth 접힘으로 바꿨다. 상위 분류가 없는
토큰 섹션(Size·Theme·Primitive)은 자식 없는 1depth라 바로 선택된다. 펼침은
useSidebarNav와 같이 "선택 항목에서 파생된 기본값 + 사용자가 누른 값" 2층이다.
시안에 없어 남긴 것: 선택 가능한 행의 개수 배지(캐럿 자리)와 focus-visible 윤곽선.
Co-Authored-By: Claude Opus 5
본문이 길어지면 문서 전체가 스크롤되어 사이드바까지 함께 밀려 올라갔다. shell을 뷰포트 높이로
못박고 overflow를 끊은 뒤, 두 칸이 각자 overflow-y를 갖게 했다(AdminShell과 같은 방식).
좁은 화면에서는 종전처럼 위아래로 쌓이고 문서가 스크롤된다.
사이드바를 디자인 시스템으로 옮겼다 — 손으로 그린 테마 전환 버튼 묶음을 FoxSegmentedControl로,
항목 옆 개수를 FoxBadge로 바꿨다. 대체된 .themeSwitch·.themeButton·.navCount 스타일은 지웠다.
검증 — 1600×900 실측: shell 900·overflow hidden, 문서 전체 스크롤 없음, 사이드바(3237)·
본문(9432) 각각 자체 스크롤.
Co-Authored-By: Claude Opus 5
두 가지가 겹쳐 세 토글이 모두 먹히지 않았다.
1. 폼과 Server Action의 값 규약이 어긋났다. 팝업을 @fox로 옮기며 토글을 value="true"로 두었는데
게시판 액션의 readBoolean은 'Y'만 참으로 본다 — 켜도 항상 false가 되어 'N'으로 저장됐다.
꾸미기 아이템 쪽 규약을 그대로 가져온 것이 원인이라 게시판 규약('Y')에 맞췄다.
2. 수정 PUT을 쿼리스트링으로 보내고 있었다. 백엔드 update는 @RequestBody(JSON)라 본문이 비어
바인딩이 되지 않고, 통과하더라도 전 필드가 null이라 UPDATE의 가드가
모두 건너뛴다 — 수정으로는 어떤 값도 바뀌지 않았다. 1:1문의 답변 저장도 같은 경로였다.
등록 POST는 컨트롤러가 form 바인딩이라 그대로 둔다.
검증 — 팝업 FormData 실측: 기본 상태 isVisible='Y'(켜짐)·isPinned/isPushEnabled 없음(꺼짐),
두 토글을 켜면 셋 다 'Y'로 실린다(액션의 'Y' 규약과 일치).
TODO(백엔드): 앱푸쉬는 이 수정으로도 저장되지 않는다 — PUSH_YN이 MngrBbsMapper.xml의
INSERT·UPDATE·SELECT 어디에도 없다.
Co-Authored-By: Claude Opus 5
`/dev-test/design`이 개발 빌드에서만 인증 면제였다(proxy의 DEV_ONLY_PUBLIC_PATHS가
NODE_ENV !== 'production'일 때만 평가됐다). 운영·테스트 환경에서는 세션이 없으면 /login으로
리다이렉트돼 볼 수 없었다.
PUBLIC_PATHS에 넣어 환경 구분을 없앴다. 페이지 쪽에는 이미 프로덕션 차단이 없어 주석만 정리했다.
검증 — 쿠키를 모두 지운 상태에서 /dev-test/design 요청이 200, 리다이렉트 없음. 프로덕션
빌드 산출물에도 라우트가 포함된다.
Co-Authored-By: Claude Opus 5
시안 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) A_BOA 목록 5227:5058 · 등록 팝업 5227:5848.
공지사항·1:1문의·FAQ 세 화면이 같은 뼈대를 쓴다.
stngId를 실제 값으로 바꿨다. 종전 TEMP_STNG_ID_* 임시값이라 목록이 늘 비어 있었다
(/api/v1/common/bbs/stng/list 응답, typeSe가 셋 다 'NOT'이라 구분에 쓸 수 없어 하드코딩).
목록 — FoxListContainer + 시안의 필터 줄(새로고침 아이콘 + 전체/노출/미노출 알약 탭).
구분은 FoxBadge(neutral), 사용여부는 FoxStatusIndicator, 첨부는 클립 아이콘, 관리는 테두리
아이콘 버튼. 세 화면이 열 정의만 다르고 나머지는 공유한다.
등록·수정 팝업 — FoxModal(md=560, 시안 폭)에 구분·제목·내용(FoxTextArea)·첨부파일
(FoxFileUpload default)·상단고정·게시기간·사용여부·앱푸쉬. 공지사항 전용 항목은 그 게시판에서만
그린다. 죽은 components/ui 대신 전부 @fox다.
옛 표·툴바·필터바 컴포넌트 5개는 지웠다(참조 0).
@fox: FoxTextArea에 requirement 추가 — 다른 폼 컴포넌트와 같은 규약.
TODO(백엔드): 구분(bbsCd)은 조회로만 오고 등록·수정 요청 VO에 필드가 없어 저장되지 않는다.
TODO: 게시기간이 네이티브 date 입력이다 — 시안은 YYYY.MM.DD 형식의 전용 필드이고 @fox에
날짜 선택 조각이 없다.
TODO: 목록 정렬이 백엔드 고정(등록일 오름차순)이라 "최근등록순" 셀렉트가 표시만 한다.
검증 — 시안 대조 실측: 모달 560px, 첨부 [파일선택] 버튼 100px 정상 노출, 목록 8열·필터 알약·
페이지네이션 정상.
Co-Authored-By: Claude Opus 5
관리자 회원·꾸미기 아이템(그리고 같은 얼럿을 쓰는 코드관리·게시판 2곳)의 삭제 확인이 화면에
제대로 뜨지 않았다. FeedbackHost가 `components/ui/alert`을 쓰는데 그 파일은 Tailwind 클래스로만
스타일링돼 있고(`fixed inset-0 bg-scrim` 등) Tailwind는 e3d3133에서 제거됐다 — position도 배경도
걸리지 않아 문서 흐름에 글자만 붙었다.
FeedbackHost의 얼럿을 `FoxModal`로 바꿨다. `showAlert` API는 그대로라 호출부 다섯 곳이 함께
살아난다. 기본 버튼도 FoxButton으로 바꿨고, 게시판 두 곳이 넘기던 `components/ui/button`(같은
이유로 무스타일)도 FoxButton으로 교체했다.
삭제 버튼은 `type="error"` → `type="primary"`. `.fox-button--error`가 빈 블록이고 Figma 토큰에도
`button-error-*` 그룹이 없어 브라우저 기본 버튼으로 그려지고 있었다(사용자 확정: @fox에 있는
계열만 쓴다. 파괴적이라는 경고는 모달 문구가 맡는다).
검증 — 실측: 모달 400px·흰 배경·radius 24, [취소] 80×40 투명+회색 글자, [삭제] 80×40
rgb(37,110,244) 흰 글자, 스크림·닫기(X) 정상.
TODO: 같은 파일의 토스트·스피너도 Tailwind 클래스만 남아 무스타일이다. @fox에 계열을 가진
스낵바가 없어(아이콘을 호출부가 정한다) 별도 논의가 필요하다.
Co-Authored-By: Claude Opus 5
``로 못박는다. @fox 토큰은 `color-scheme` + `light-dark()`로 갈리므로
(`@fox/styles/_root.scss`) 이 속성 하나가 OS 다크 설정까지 덮는다 — 앱 CSS에는
`prefers-color-scheme` 분기가 없어 다른 경로가 없다.
첫 페인트 전 localStorage를 읽어 data-theme을 부여하던 부트스트랩 스크립트를 지웠다. 남겨 두면
예전에 다크를 골라 둔 기기에서 그 값이 고정값을 덮어쓴다.
`_hooks/use-theme-preference`와 `_components/theme-toggle-button`은 지우지 않고 둔다 — 이미
어디서도 렌더되지 않아(헤더가 붙이지 않는다) 번들에 들어가지 않고, 전환이 기획되면 그대로 다시
붙이면 된다. 되돌리는 방법은 layout 주석에 적어 두었다.
검증 — OS를 dark로 두고 확인: html data-theme="light", color-scheme "light", 화면도 라이트.
localStorage에 "dark"가 남아 있는 상태로 새로고침해도 라이트를 유지한다(종전에는 다크로 갔다).
Co-Authored-By: Claude Opus 5
**FoxPhoneNumber** — `unit` 모드에서 `prefixOptions`가 비면 앞자리를 셀렉트가 아니라 입력 칸으로
그린다. 항목 없는 셀렉트는 placeholder "010"이 이미 고른 값처럼 보이는데 실제 값은 비어 있어,
뒷자리만 채운 번호가 조용히 만들어졌다(관리자 등록이 막힌 원인). 세 칸을 한 map으로 돌게
정리하면서 combine 분기와 모양을 맞췄다.
**FoxEmail** — 시안(form-email 5543:9940)은 줄이 나뉜다: [아이디] @ [도메인] 한 줄, 그 아래
도메인 드롭다운, 그 아래 확인 버튼. 종전에는 넷을 한 줄에 두고 넘치면 접히게 했는데, 좁은 곳에서
드롭다운이 도메인 칸보다 먼저 접혀 순서가 어긋났다(320px 팝업에서 세 줄로 흩어졌다).
`__body`(4px 간격) 안에 form-group·select·btn을 쌓고, 칸의 `min-inline-size: 24rem`을 0으로 바꿔
좁은 폭에서도 두 칸이 나눠 갖게 했다. 라벨의 필수 표시(`requirement`)도 다른 폼 컴포넌트와 같은
규약으로 추가했다.
**관리자 등록 팝업** — 손으로 짜 두었던 이메일 칸(아이디·도메인·직접입력 상태 세 개)을 `FoxEmail`
하나로 바꿨다. 시안 5227:2036 그대로이고, 아이디 placeholder는 "ID"(종전 "1234-5678"은 전화번호
칸에서 복사된 값이었다), 도메인은 첫 항목이 골라진 채로 시작한다.
검증 — 시안 대조 실측: 아이디 149×40 · 도메인 149×40(readOnly, "naver.com") · 셀렉트 320×40 ·
그룹↔셀렉트 간격 4 · 라벨 "이메일*"(시안 149/149/320/4와 일치).
동작: 직접입력 고르면 도메인 칸의 readOnly가 풀린다. prefixOptions 없이 쓴 FoxPhoneNumber는
앞자리가 입력 칸이 되고 "02"를 적으면 값에 실린다(종전에는 아무리 눌러도 빈 값이었다).
Co-Authored-By: Claude Opus 5
앞자리 셀렉트를 비워 두면 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
증상: 번호를 다 입력해도 "휴대전화 번호를 정확히 입력해 주세요."로 막힘.
`FoxPhoneNumber`의 `prefixOptions` 기본값이 빈 배열인데 호출부가 넘기지 않았다. 그래서 앞자리
셀렉트에 고를 항목이 하나도 없고, 그 자리에는 placeholder "010"이 떠서 이미 고른 것처럼 보인다.
실제 값은 빈 문자열이라 가운데·끝자리를 다 채워도 8자리만 제출되고, `formatPhoneNumber`의
`/^(\d{3})(\d{3,4})(\d{4})$/`에 걸리지 않아 null → 빈 값 → 위 오류로 떨어졌다.
앞자리 목록을 `lib/domain/phone-number.ts`의 MOBILE_PHONE_PREFIXES로 두고 호출부가 넘긴다 —
등록·수정이 같은 `AdminMemberFormFields`를 쓰므로 두 팝업이 함께 고쳐진다.
검증 — 화면 실측(실제 클릭·키 입력): 앞자리 셀렉트에 010/011/016/017/018/019가 뜨고, 010 선택
후 1234·5678 입력 시 제출값 "01012345678"(11자리) → 서버 포맷 "010-1234-5678" 통과.
수정 전에는 같은 조작에서 "12345678"(8자리) → null이었다.
@fox 관찰(보고만): `unit` 모드에서 `prefixOptions`를 주지 않으면 항목이 없는 셀렉트가 그려지고,
placeholder 때문에 고른 것처럼 보인 채 잘못된 값이 조용히 만들어진다. 기본값을 두거나 빈 목록일
때 앞자리를 입력 칸으로 바꾸는 편이 안전해 보인다.
Co-Authored-By: Claude Opus 5
아이템 수정 API 형식(PUT=JSON)과 수정일 매핑(`lastMdfcnDtStr`)은 hub에 이미 같은 수정이
들어와 있어 hub 쪽 구현을 그대로 두었다 — 형식 분기를 호출부가 `{form}`/`{body}`로 넘기는
쪽이 `sendWrite` 안에서 method로 갈라내는 것보다 읽기 쉽다.
이 병합이 실제로 더하는 것은 `next.config.ts`의 `serverActions.bodySizeLimit`이다.
Server Action 본문 기본 상한 1MB에 걸려 이미지가 413으로 잘리고 있었고, 그때 액션 본문이
실행되지 않아 화면에는 오류 없이 폼만 비워졌다("아무 반응 없음"). 게시판 첨부도 같은 구조라
함께 낫는다.
Co-Authored-By: Claude Opus 5
## 등록이 "아무 반응 없이" 실패하던 원인
썸네일 이미지는 Server Action **본문에 실려** 간다(브라우저가 백엔드를 직접 부르지 않으므로).
그런데 `serverActions.bodySizeLimit`이 설정돼 있지 않아 Next 기본값 1MB가 걸려 있었고, 요즘
이미지는 그걸 쉽게 넘는다. 초과하면 요청이 413으로 끊겨 **액션 본문이 실행조차 되지 않으므로**
`useActionState`는 idle 그대로다 — 오류도 토스트도 없고, React가 액션 종료 후 폼을 리셋하니
화면에는 "입력값만 지워지고 아무 일도 안 일어남"으로 보인다.
재현·확인: 임시 Server Action에 2MB를 보내면 1mb 설정에서 `Body exceeded 1mb limit.` + 500이
뜨고, 10mb로 올리면 2MB·8MB 모두 통과한다. 백엔드는 `CommonsMultipartResolver`가 100MB까지
받으므로 막고 있던 것은 Next 쪽뿐이었다. 본문이 Next 서버 메모리에 통째로 올라가는 값이라
관리 화면에 필요한 만큼(10MB)만 열었다.
같은 구조인 **게시판 첨부파일도 함께 낫는다** — 그쪽도 File을 Server Action 본문으로 보낸다.
## 아이템 수정 API (백엔드 4d98756, 2026-08-18)
- `PUT /mngr/item/{itemSn}`가 `@ParameterObject` → `@RequestBody`로 바뀌었는데 프론트는 POST·PUT
둘 다 form으로 보내고 있었다(415). 등록은 여전히 form이라 **형식이 갈린다** — 관리자 API와
똑같은 모양이다.
- 목록 SQL 별칭이 `LAST_MDFCN_DT` → `last_mdfcn_dt_str`로 개명돼 수정일이 늘 비어 있었다.
매핑을 `lastMdfcnDtStr`로 맞췄다(다만 컨트롤러가 `lastMdfcnDt`를 세팅하지 않아 백엔드가
그것을 채우기 전까지는 계속 빈 값이다 — 주석에 남겼다).
"수정하면 아이템이 목록에서 사라지던" 종전 결함은 그 커밋에서 해소돼 주석을 갱신했다. 대신
UPDATE가 ``로 감싸지면서 **설명을 비워도 지워지지 않는** 제약이 새로 생겼다.
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