`formatGradeClassNumber`가 학년·반·번호 셋 중 하나라도 없으면 통째로 `-`를 돌려주고 있었다.
백엔드 `TB_COM_USER`에는 GRADE·CLS_NO만 있고 학생 번호 컬럼이 아예 없어(코드베이스 전체 VO가
그 둘만 쓴다) 번호가 영영 오지 않으므로, 실제로 오는 학년·반까지 함께 묻혔다.
있는 값만 이어 붙이도록 바꿨다. 하나도 없을 때만 `-`다.
같은 규칙이 두 곳에 따로 있던 것도 정리했다 — 목록은 자체 `formatClass`로 부분 표기를 이미
하고 있어서 팝업과 결과가 달랐다. 목록이 도메인 함수를 쓰도록 바꿔 한 곳으로 모았다.
팝업은 `-`가 오면 "학교 / -"가 아니라 학교만 보이도록 그대로 걸러 낸다.
검증 — 2·5·7 → "2학년 5반 7번", 2·5·null → "2학년 5반", 2·null·null → "2학년",
null·5·null → "5반", 셋 다 null → "-". 화면에서도 "폭스고등학교 / 2학년 5반"으로 나온다.
Co-Authored-By: Claude Opus 5
백엔드는 `USER_TELNO`를 하이픈 없이 숫자열로 준다(`01012345678`). 시안은 `010-1234-5678`로
끊어 보여주므로 화면에 나가기 직전에 끊는다 — 저장된 값 자체는 건드리지 않는다.
lib/domain/phone-number.ts를 새로 뒀다. 학생·관리자·보호자가 같은 규칙을 써야 해서 도메인마다
두지 않고 한 곳에 모았다(지금은 학생 화면만 쓴다).
규칙 — 휴대전화 11자리는 3-4-4, 10자리는 3-3-4, 서울(02)은 9자리 2-3-4·10자리 2-4-4,
지역번호 없는 8자리는 4-4다. **판단이 서지 않으면 손대지 않는다**: 이미 하이픈이 있거나
자릿수가 규칙에 맞지 않으면 원본을 그대로 돌려준다 — 임의로 끊으면 잘못된 번호를 그럴듯하게
보여주게 된다.
적용 — 목록의 휴대전화번호·보호자연락처, 조회 팝업의 휴대전화 번호·보호자 연락처.
검증 — 함수를 직접 돌려 확인했다. 01011233400→010-1123-3400, 0111234567→011-123-4567,
0212345678→02-1234-5678, 021234567→02-123-4567, 12345678→1234-5678, 이미 하이픈 있는 값과
123·14자리는 원본 유지, 빈 문자열·공백은 null. 화면에서도 01012345678이 010-1234-5678로,
보호자가 010-2222-1234로 나온다.
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가 없어 해당 컨트롤은
비활성으로 둔다.