## 백엔드 최신화 반영 (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
시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) snb-area 3041:11860.
메뉴는 화면이 소유하지 않는다. 계정 권한마다 항목이 달라 매 요청 백엔드가 구성해 주는 값이라,
라우트 목록을 컴포넌트 상수로 두던 구조를 걷어내고 서버에서 조회해 내려주는 흐름으로 바꿨다.
그래서 새 화면이 생겨도 사이드바 컴포넌트는 손대지 않는다.
- lib/domain/sidebar-menu.ts: section > 1depth > 2depth > 3depth + 즐겨찾기 타입. 아이콘은
컴포넌트가 아니라 이름 문자열로 받는다 — 백엔드가 넘길 수 있는 형태가 그것뿐이다.
- lib/data/repositories/sidebar-menu-repository.ts: 조회 지점. 아직 API가 없어 mock을 돌려주되
권한(roleCode)을 인자로 받아 `cache()`가 권한별로 따로 기억하게 했다 — API가 생기면 이 함수
본문만 교체하면 호출부는 그대로다.
- layout.tsx가 세션 권한으로 메뉴를 조회해 AdminShell을 거쳐 사이드바에 넘긴다. 클라이언트에서
불러오면 첫 페인트에 메뉴가 비었다가 채워지고 조회용 토큰도 브라우저로 내려가야 한다.
- sidebar-menu-icon.tsx: 이름 → @fox 아이콘 화이트리스트. 동적 import로 하면 번들러가 1,512종을
전부 싣게 되므로 적힌 것만 번들에 넣는다. 모르는 이름이 와도 기본 아이콘으로 그려진다.
- use-sidebar-nav.ts: 3단 트리용으로 다시 썼다. 펼침은 "현재 경로에서 파생된 기본값 + 사용자가
누른 값" 2층이라, 이동하면 그 가지가 저절로 열리고 직접 접으면 그 선택이 유지된다.
@fox 사용 범위(사용자 확정) — 1·2·3depth 항목은 @fox에 없어 사이드바 전용으로 짜되 토큰·아이콘은
전부 @fox를 쓰고, 있는 것은 그대로 재사용했다.
- 토글 버튼: FoxIconButton xsm/ghost가 24×24·radius 4·아이콘 20으로 시안과 정확히 일치한다.
- 검색창: FoxInput md가 크기·radius·여백까지 시안과 같고 테두리 색만 달라(input-border는
neutral-60, 시안은 border-neutral-subtler) 그 한 줄만 덮었다.
mock 메뉴는 실제로 존재하는 화면만 담았다 — 시안의 통합플랫폼 메뉴(기관 관리·역할 관리 등)를
그대로 넣으면 전부 갈 곳 없는 죽은 링크가 된다.
검증 — 브라우저 실측이 시안과 일치한다. 셸 240px·패딩 12·우측 1px, 배경 그라데이션
#f4f5f6 70% → #ecf2fe, 검색창 40/radius 8/테두리 #e6e8ea, 토글 24×24/radius 4/아이콘 20,
1depth 40/패딩 10·8/radius 8/14px ExtraBold/mix-blend multiply/아이콘 20, 활성 배경 #063a74 +
흰 글자, 2depth 판 흰 배경+1px+radius 8+패딩 8, 2depth 행 28, 별·캐럿 12(자리는 유지하고
opacity 0으로 감춤 — 시안과 같다).
AdminShell이 더 이상 useSidebar를 쓰지 않는다. 헤더 구현 때 시안에 없어 걷어낸 토글의 후속이다.
Co-Authored-By: Claude Opus 5
공지사항 등록 시 "A 'use server' file can only export async functions,
found object"로 500이 발생했다. boards/_actions.ts가 INITIAL_* 상태 상수를
함께 export한 것이 원인이다.
관리자 회원에서 같은 원인을 고친 방식(abb6259)에 맞춰 *FormState 타입과
INITIAL_* 상수를 lib/domain/board-post-form.ts로 옮겼다. 빌드·타입체크는
통과하고 폼을 실제로 제출해야만 드러나는 런타임 전용 제약이라, 근거를
domain 파일 주석에 남겼다.
Co-Authored-By: Claude Opus 5
충돌 2건 해결:
- lib/http/backend-fetch.ts: hub 버전 채택. hub가 이미 PUT/DELETE·form·
multipart를 갖췄고 data:null 성공을 canHaveNullData로 다루므로, 같은 목적으로
추가했던 backendCommand를 걷어내고 게시판 Repository를 그 규약에 맞췄다.
파일 업로드용 timeoutMs만 BackendRequestInit에 추가로 남겼다.
- admin-sidebar.tsx: 꾸미기아이템관리(hub)와 게시판관리(고객센터)를 함께 둔다.
Co-Authored-By: Claude Opus 5
사이드바 「게시판관리(고객센터)」 그룹과 3개 화면 신설
(/boards/notices, /boards/inquiries, /boards/faqs).
백엔드 최신화(develop 924db37)로 새로 생긴 관리자 게시판 API에 연동한다.
세 화면이 TB_COM_BBS 한 테이블을 stngId로만 구분하므로 도메인·Repository·
Server Action·공용 컴포넌트를 한 벌로 두고 BoardType으로 분기한다.
- 공지사항: 목록·등록·수정·삭제 (구분/사용여부 필터, 상단고정·노출기간·앱푸쉬)
- 1:1문의: 목록·상세답변·삭제 (등록 없음, 전화번호 마스킹, 진행상태)
- FAQ: 목록·등록·수정·삭제 (구분/유형 필터)
- 첨부파일 업로드(Server Action 경유)와 다운로드 중계 라우트 추가
백엔드 계약 확인에 따른 결정(사용자 확인 완료):
- totalCount·검색조건 한계로 전체를 받아 서버에서 검색·필터·페이징
- stngId·moduleId·구분/유형/진행상태 코드는 백엔드 미확정이라 상수 한 곳에
임시값을 두고 추후 교체(BOARD_SETTING_IDS 등)
- 유형은 저장 필드가 없어 임시 파라미터명으로 전송(백엔드 추가 예정)
- 노출기간·앱푸쉬는 조회 SELECT에 없어 전송만 하고 조회는 빈 값
- 본문은 리치 에디터 대신 글자수 카운트 textarea
backend-fetch: PUT/DELETE·폼 인코딩·멀티파트 지원 추가. 쓰기 API가
success(null)을 반환해 기존 backendFetch로는 성공이 실패로 읽히므로
데이터 없는 쓰기 전용 backendCommand를 분리했다.
Co-Authored-By: Claude Opus 5
두 레인이 같은 시기에 backendFetch에 form-urlencoded 전송을 추가해 lib/http/backend-fetch.ts가
충돌했다. 원인이 같다 — 백엔드의 쓰기 API가 @RequestBody가 아니라 @ParameterObject로 받아
JSON을 보내면 전 필드가 null로 저장된다(관리자 등록·수정, 아이템 등록·수정 모두).
아이템 쪽 구현이 관리자 쪽을 포함하므로 그것을 채택했다:
- method에 DELETE가 더 있다(아이템 삭제).
- form 값이 string 외에 number·undefined를 받는다(undefined는 전송에서 제외).
- multipart 본문을 지원한다(아이템 이미지 업로드).
- 본문·Content-Type 결정을 buildRequestBody 한 곳으로 모아 세 형식을 한눈에 볼 수 있다.
관리자 레인의 호출부는 Record을 넘기므로 넓어진 타입에 그대로 들어맞는다.
주석에는 두 도메인의 예시를 함께 남겼다.
사이드바 「관리자정보관리 > 관리자 회원」(/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
비활성 상태였던 엑셀다운로드 버튼을 GET /api/v1/mngr/user/list/excel에 연결한다.
브라우저는 백엔드를 직접 부르지 않으므로(토큰이 httpOnly 세션 안에만 있다) 라우트 핸들러
/students/excel이 인증을 확인하고 백엔드 xlsx를 스트림으로 중계한다. 파일명·MIME은 백엔드가
RFC 5987 형식으로 이미 올바르게 내려주므로 그대로 전달한다.
백엔드 엑셀 API는 목록 API와 같은 selectPagination을 그대로 타서 페이징이 걸린다 —
파라미터를 생략하면 기본값 recordCountPerPage=10이 적용돼 10건짜리 파일이 나온다. "항상 전체"
사양을 만족시키려고 recordCountPerPage에 충분히 큰 상한(100,000)을 명시해 1페이지로 전부
받아온다. 검색 조건은 의도적으로 싣지 않는다(화면에서 무엇을 검색 중이든 파일은 전체).
- lib/http/backend-fetch.ts: JSON 봉투가 아닌 응답을 위한 backendFetchStream 추가. 성공 판정만
HTTP status 기준이고(성공 응답에 봉투가 없다) 실패 봉투의 code/message 보존은 동일하다.
파일 생성 시간을 감안해 호출부가 타임아웃을 늘려 잡을 수 있게 했다.
- 버튼은 네이티브 GET 폼 제출로 둔다 — 첨부파일 응답이라 화면을 유지한 채 파일만 받는다.
학생 회원 목록 조회를 로그인 구현이 세운 인증·통신 기반 위로 옮긴다.
- 백엔드 HTTP 클라이언트를 lib/http/backend-fetch.ts 하나로 통일한다. 목록 연동에서 임시로
두었던 lib/data/api-client.ts는 삭제하고, 거기에만 있던 세 가지를 backendFetch로 옮겼다:
쿼리스트링 조립, Authorization 헤더(accessToken), 그리고 비2xx 응답의 봉투 code 보존.
마지막 항목이 없으면 백엔드가 HTTP 401로 주는 인증 실패가 통신 오류(code -1)로 뭉개져
"세션이 끊겼다"와 "서버가 죽었다"를 호출부가 구분할 수 없다.
- 세션에 보관된 accessToken을 꺼내는 getSessionAccessToken()을 DAL에 추가한다. 화면 DTO
(AdminUser)는 토큰을 담지 않으므로 서버 전용 경로를 따로 둔다.
- 개발용 토큰 우회로(getDevApiAccessToken / EDUPAY_API_ACCESS_TOKEN)를 제거한다. 실제
로그인이 토큰을 공급하므로 존재 이유가 사라졌다.
- lib/env.ts 충돌은 hub 쪽 getApiBaseUrl()을 채택해 해소했다(같은 환경변수 키, 같은 동작).
mock 학생 회원 생성기를 제거하고 GET /api/v1/mngr/user/pagination에 연결한다.
백엔드 저장소(develop b742bb4)의 실제 구현을 읽고 계약을 확정했다.
- searchCondition은 "1"(이름)·"2"(아이디)·"3"(휴대전화)만 유효하다. 목록에 없는 값을
보내면 조건 없이 전체가 반환되므로 지원되지 않는 학교명·회원코드 검색은 제거했다.
- 유효한 페이징 파라미터는 pageIndex·recordCountPerPage 둘뿐이다(서버가 offset을 직접
계산해 나머지를 덮어쓴다).
- 응답의 totalCount는 전체 건수가 아니라 현재 페이지 행 수다(백엔드에 count 쿼리 없음).
그대로 믿으면 가득 찬 페이지 뒤 데이터에 접근할 수 없어 하한값으로 보정하고, 확정되지
않은 건수는 화면에 "N명 이상"으로 표기한다.
- 응답이 주지 않는 항목(휴대전화·이메일·학교·학년/반·보호자·가입일·사용여부)은 열을
유지한 채 '-'로 표시한다. 정렬 파라미터와 사용여부 변경 API가 없어 해당 컨트롤은
비활성으로 둔다.