두 가지가 겹쳐 세 토글이 모두 먹히지 않았다.
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
증상: 다 입력하고 [저장]을 눌러도 이름·비밀번호만 비워지고 아무 메시지 없이 생성되지 않음.
원인 두 가지가 겹쳐 있었다.
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
기획 SYS_COD_001(1842:17507)과 팝업 4종(1842:16056·16122·16179·16262),
시안 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) 5402:13399.
한 화면에 목록이 둘이다. 좌측 공통코드를 행 전체로 눌러 고르면 우측 상세코드가 그 그룹으로
갱신된다(기획 ④). 선택·검색·두 목록의 페이지가 모두 URL에 실려, 갱신은 서버가 다시 그린다 —
공통코드를 바꾸면 상세 페이지가 1로 돌아가는 규칙은 buildCommonCodeHref가 강제한다(호출부가
잊어도 어긋나지 않게).
**검색·페이징을 서버(RSC)에서 한다.** 백엔드가 두 목록 모두 전체를 반환하고 그룹 검색도
LIKE가 아닌 완전일치라, 부분일치 필터와 페이지 자르기를 page.tsx가 맡는다(사용자 확정 사항).
전체를 받으므로 총건수는 정확해 다른 목록의 하한값 보정이 여기엔 필요 없다.
URL의 comCd가 목록에 없으면 첫 행으로 대체한다 — 검색으로 걸러졌거나 삭제된 코드가 주소에
남아 있으면 우측이 영영 비어 보인다.
분류코드(시안의 CMS 셀렉트)는 저장되지 않는다. TB_SYS_COM_CD에 해당 컬럼이 없어(관리자용·
공용 두 매퍼와 후보 컬럼명까지 확인), 코드ID 접두사를 채워 주는 입력 보조로만 쓴다 — 시안
목록이 전부 CMS+3자리라 그 작명 규칙을 화면이 거드는 것이다(사용자 확정 사항).
설명 열은 한 줄 말줄임이다. 시안 셀에는 말줄임 스타일이 없지만(전문 + word-break) 프레임이
44로 고정이라 Figma가 넘침을 잘라 보여준다 — CSS는 감싸므로 그대로 두면 자유 입력인 설명
때문에 행 높이가 제각각이 된다. 잘린 글자는 title로 남긴다.
@fox: FoxHeadingGroup 신설(시안 3002:7177). 화면에 여럿 설 수 있는 구역 제목이라
FoxPageHeader(h1·breadcrumb)와 역할이 다르고, 제목 태그를 고를 수 있다. 아이템 목록 시안에도
숨김으로 들어 있어 재사용된다.
백엔드 요청 2건(시안대로 두고 보고, 사용자 확정 사항)
- 상세 목록 조회 SQL이 DTL_CD_EXPLN을 select하지 않아 상세코드설명이 늘 비어 온다.
- 상세 등록 INSERT에도 없어 등록 시 입력한 설명이 저장되지 않는다(수정은 정상).
검증 — 시안 대조 실측: 패널 2단 gap 40, 열 폭 공통 80/120/160/156/120/120 · 상세
80/120/120/196/120/120, 행 높이 44(+테두리 1), 검색 320, 패널 제목 26px #1e2124 h2.
동작: 분류코드 SYS 선택 시 코드ID 004→SYS004, 다시 CMS 선택 시 CMS004(접두사 교체),
상세코드 등록 팝업의 코드ID는 읽기 전용·name 없음(제출 제외)에 hidden comCd로 전달,
정렬번호 기본값 11(기존 10개 다음).
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
목록 셀(80×80)과 등록·수정 팝업의 업로드 미리보기(120×120) 모두. 아이템은 생김새 자체가
정보라 잘라내면 어떤 아이템인지 알아볼 수 없고, 미리보기는 고른 그림을 확인하는 자리라
일부만 보이면 확인이 되지 않는다.
@fox: `.fox-file-upload__thumb-image`의 object-fit. FoxFileUpload의 image 모드를 쓰는 화면이
이 팝업뿐이라 영향 범위가 이 화면 안이다.
Co-Authored-By: Claude Opus 5
`GET /api/v1/mngr/code/list/ITEM_CATE_CD`(사용자 안내)로 카테고리를 가져온다. 종전에는
코드테이블이 정비되기 전이라 프론트에 임시 코드(CATE01 계절 / CATE02 축하 / CATE03 시즌)를
두고 있었다 — 그 상수를 지웠다.
- lib/domain/common-code.ts — 코드 그룹 도메인. 코드 그룹 ID를 한곳에 모아 화면이 문자열을
직접 적지 않게 한다.
- lib/data/repositories/common-code-repository.ts — 조회. `cache()`로 감싸 한 요청 안에서
화면(선택지 그리기)과 Server Action(입력값 검증)이 같은 목록을 한 번만 받아 쓰게 한다.
그리는 데 쓴 목록과 검증에 쓴 목록이 반드시 같아진다는 점이 중요하다.
- 정렬은 백엔드 순서를 그대로 쓴다 — SQL이 아이템 목록과 같은 이중 역순 패턴이라 화면에서
다시 정렬하면 다른 화면과 어긋난다.
- 코드 한 줄이 깨져도(코드값 없음) 그 줄만 건너뛴다. 목록 조회의 fail-fast와 다른 판단인데,
선택지 하나 때문에 화면 전체를 못 쓰게 만들 이유가 없기 때문이다.
검증도 상수 대신 조회 결과를 쓴다. `validateDecorationItemCreate/Update`가 허용 코드 목록을
**인자로** 받고(domain 계층은 통신을 하지 않는다), Server Action이 저장 직전에 다시 조회해
넘긴다 — 화면이 보낸 코드를 그대로 믿으면 코드테이블에 없는 값이 저장된다.
카테고리 표기도 단순해졌다. 목록 SQL이 코드테이블을 조인해 `itemCateNm`을 내려 주므로
프론트 목록에서 이름을 찾던 두 번째 경로가 사라지고, 코드가 지워졌을 때만 코드값으로 떨어진다.
Co-Authored-By: Claude Opus 5
수정 팝업(ADM_ITM_103_p)은 시안이 없어 등록 팝업(5227:4629)의 구성을 그대로 쓰되 아이템ID만
읽기 전용이다. 붙이면서 백엔드(develop, "FIX API 수정")를 다시 읽어 세 가지가 달라진 것을
확인했다.
1. **수정은 이제 JSON이다.** 컨트롤러의 update가 `@ParameterObject` → `@RequestBody`로 바뀌었다.
등록은 그대로 `@ParameterObject`(form)라 **한 도메인 안에서 형식이 갈린다** — 종전처럼 둘 다
form으로 보내면 수정이 조용히 깨진다. 보내는 필드는 그대로 두고 전송 형식만 나눴다.
2. **수정일시 필드명이 다르다.** VO의 `lastMdfcnDt`는 `@JsonIgnore`라 응답에 없고, SQL이
`DATE_FORMAT(...) AS last_mdfcn_dt_str`로 따로 내려 준다 — `lastMdfcnDtStr`를 읽는다.
종전 코드는 응답에 없는 이름을 읽어 수정일시가 항상 `-`였다.
3. **종전에 보고한 "수정하면 유형이 날아간다"는 해소됐다.** 컨트롤러가 itemType을 빌더에 담고
UPDATE 문도 필드마다 ``로 감싸여 보내지 않은 값은 건드리지 않는다. 그래서 썸네일 파일
ID를 보내지 않아도 기존 값이 남는다.
썸네일을 지우면 유지 중이던 파일 ID도 함께 비운다 — 비우지 않으면 지운 것처럼 보이는데 저장은
예전 이미지를 그대로 남긴다. 이제 검증이 "썸네일 이미지를 등록해 주세요."로 잡는다.
남은 백엔드 제약: UPDATE의 `` 때문에 **설명을 빈 값으로 지울 수
없다**(조용히 무시된다).
검증 — 수정 팝업 프리뷰 실측: 탭이 item.itemType(셋트) 선택, 아이템ID 128 읽기전용·name 없음
(제출 제외), hidden itemSn/itemType/categoryCode/imageFileId 정상, 이름·포인트·설명·정렬순서·
사용여부 프리필, 기존 썸네일 표시, 썸네일 삭제 시 imageFileId가 ""로 비워짐.
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