임동욱 임동욱 08-18
feat: 사용여부를 백엔드 실제 API로 연결 + 목록 응답 신규 필드 매핑
백엔드를 최신화(develop 924db37 → 4d98756)하고 확인하니 막고 있던 두 가지가 모두 풀렸다.

- `GET /api/v1/mngr/user/pagination`이 4개 필드에서 18개로 늘었다 — userTelno·userEmlAddr·
  userType·schNm·grade·clsNo·birth·useYn이 실린다.
- `PUT /api/v1/mngr/user/{userId}/{useYn}` 사용여부 변경 API가 생겼다.
- (덤) `GET /api/v1/mngr/user/{userId}` 단건 조회도 생겼다. 지금은 쓰지 않는다 — 목록 행이
  이미 갖고 있는 값으로 팝업을 채우므로 재조회가 필요 없다.

이에 맞춰 이식했다.

- Repository: toStudentMember가 전화·이메일·학교·학년·반·생년월일·사용여부를 실제 값으로
  채운다. `useYn`은 'Y'/'N' 문자열이라 boolean으로 옮기고, 값이 없으면 판단하지 않고 null이다.
  보호자·학생번호·가입일은 응답에 여전히 없어 `-`로 남는다(SQL·VO 어디에도 컬럼이 없다).
- Repository: updateStudentMemberUseYn 추가. 값과 대상이 모두 경로에 실리고 본문은 쓰지 않는다.
- Server Action: 미지원 반환을 걷어내고 실제 호출로 바꿨다. 성공하면 목록을 revalidate한다.
- 팝업: 저장 성공 시 닫는 동작을 되살렸다.

backend-fetch에 `canHaveEmptyBody`를 더했다 — 이 PUT은 인터페이스에 `ApiResponseVO` 반환으로
문서화돼 있지만 구현이 `void`이고 `ApiResponseVO.success(null)`을 만들어 놓고 버려서, 본문 없는
200이 나간다. 봉투를 파싱하기 전에 빈 본문을 갈라 성공으로 받는다. 백엔드가 봉투를 돌려주도록
고쳐도 그대로 동작한다.

감사 컬럼(LAST_MDFR_*)은 보내지 않는다 — 매퍼가 쓰지만 백엔드 CrudLogInterceptor가 UPDATE마다
자동으로 채운다(확인함).

Co-Authored-By: Claude Opus 5 
@18e8e584173b4286d384e153d7916952bb7b4335
app/(protected)/(basic)/students/_actions.ts
--- app/(protected)/(basic)/students/_actions.ts
+++ app/(protected)/(basic)/students/_actions.ts
@@ -1,34 +1,49 @@
 'use server';
 
+import { revalidatePath } from 'next/cache';
 import { verifySession } from '@/lib/auth/dal';
+import { updateStudentMemberUseYn } from '@/lib/data/repositories/student-member-repository';
+import { STUDENT_MEMBERS_PATH } from '@/lib/domain/student-member-query';
 
 export type UpdateStudentActiveStatusState =
   | { status: 'idle' }
   | { status: 'error'; error: string }
   | { status: 'success' };
 
-const UNSUPPORTED_ERROR =
-  '사용여부 변경은 아직 제공되지 않습니다. (백엔드 API 준비 중)';
+const INVALID_INPUT_ERROR = '요청 값이 올바르지 않습니다.';
 
 /**
- * 학생 회원 사용여부 변경 Server Action — **현재는 미지원 상태의 이식 지점**이다.
+ * 학생 회원 사용여부 변경 Server Action.
  *
- * 목록이 백엔드 API(`GET /api/v1/mngr/user/pagination`)로 전환되면서 이 화면의 데이터 원천은
- * 백엔드가 됐지만, 사용여부는 **조회 응답에 값도 없고 변경 API도 없다.** 이전의 mock 쓰기를
- * 그대로 두면 mock 배열에만 존재하는 id를 찾다가 "존재하지 않는 학생 회원" 오류가 나므로
- * (목록의 id는 이제 백엔드 `userId`다) 쓰기 경로를 명시적으로 막았다. 팝업의 사용여부 라디오와
- * 저장 버튼도 같은 이유로 비활성이다.
+ *   PUT /api/v1/mngr/user/{userId}/{useYn}
  *
- * 변경 API가 생기면 `formData: FormData` 인자를 되살리고 본문을 "입력 검증 → Repository 호출
- * → `revalidatePath`"로 되돌리면 된다(지금은 읽을 입력이 없어 인자를 받지 않는다 — 인자를
- * 줄여도 `useActionState`의 호출 규약에는 어긋나지 않는다). 인증 확인을 본문 맨 앞에 남겨 둔
- * 것도 그 형태를 유지하기 위함이다 — Server Action은 UI를 거치지 않고 직접 POST될 수 있어
- * 이 확인이 유일한 최종 방어선이다.
+ * 백엔드에 값도 변경 API도 없어 한동안 막아 두었던 경로다. edupay-backend develop 4d98756에서
+ * 목록 응답에 `useYn`이 실리고 변경 API가 생기면서 되살렸다.
+ *
+ * 인증 확인을 본문 맨 앞에 둔다 — Server Action은 UI를 거치지 않고 직접 POST될 수 있어 이
+ * 확인이 유일한 최종 방어선이다. 실패 사유는 일반화된 문구만 화면으로 보낸다(백엔드 message
+ * 원문에는 내부 정보가 실릴 수 있다).
  */
 export async function updateStudentActiveStatus(
-  _prevState: UpdateStudentActiveStatusState
+  _prevState: UpdateStudentActiveStatusState,
+  formData: FormData
 ): Promise<UpdateStudentActiveStatusState> {
   await verifySession();
 
-  return { status: 'error', error: UNSUPPORTED_ERROR };
+  const id = formData.get('id');
+  if (typeof id !== 'string' || id.trim() === '') {
+    return { status: 'error', error: INVALID_INPUT_ERROR };
+  }
+
+  // 체크박스는 켜졌을 때만 값이 실린다 — 없으면 꺼진 것이다.
+  const isActive = formData.get('isActive') !== null;
+
+  const result = await updateStudentMemberUseYn(id, isActive);
+  if (!result.ok) {
+    return { status: 'error', error: result.message };
+  }
+
+  // 목록의 사용여부 열이 방금 바꾼 값을 반영해야 한다.
+  revalidatePath(STUDENT_MEMBERS_PATH);
+  return { status: 'success' };
 }
app/(protected)/(basic)/students/_components/student-detail-modal.tsx
--- app/(protected)/(basic)/students/_components/student-detail-modal.tsx
+++ app/(protected)/(basic)/students/_components/student-detail-modal.tsx
@@ -1,6 +1,6 @@
 'use client';
 
-import { useActionState, useRef, useState } from 'react';
+import { useActionState, useEffect, useRef, useState } from 'react';
 import { FoxButton } from '@fox/core/components/fox-button';
 import {
   FoxDescriptionList,
@@ -44,9 +44,8 @@
  * 시안은 보호자(이름/연락처)와 학교(학교/학년·반·번호)를 각각 한 줄로 묶는다 — 종전처럼
  * 항목을 따로 두지 않고 시안의 8줄 구성을 그대로 따랐다.
  *
- * 사용여부는 이 팝업에서 유일하게 수정 가능한 항목이지만, 백엔드 목록 응답에 값이 없고 변경
- * API도 없어 토글·저장 버튼을 비활성으로 둔다(`_actions.ts`의 미지원 처리와 짝). 폼·Server
- * Action 배선은 남겨 두었으므로 API가 생기면 `IS_ACTIVE_STATUS_EDITABLE`만 되돌리면 된다.
+ * 사용여부는 이 팝업에서 유일하게 수정 가능한 항목이다. 토글을 넘기면 화면이 바로 반응하고,
+ * 저장을 누르면 `PUT /api/v1/mngr/user/{userId}/{useYn}`로 나간다.
  *
  * 저장 버튼은 `form={DETAIL_FORM_ID}`로 폼과 이어 둔다 — FoxModal이 버튼을 `foot` 슬롯에
  * 그려 폼의 자손이 아니게 되는데, 네이티브 `form` 속성은 같은 문서 안에서 id만 맞으면 그
@@ -59,9 +58,16 @@
   );
   const formRef = useRef<HTMLFormElement>(null);
   // 토글은 화면에서 바로 반응해야 하므로 이 컴포넌트가 상태를 갖는다 — prop에서 계산하면
-  // 스위치를 넘겨도 라벨이 그대로다. 저장이 되지 않으므로 이 값은 화면에만 머문다.
+  // 스위치를 넘겨도 라벨이 그대로다. 저장하면 이 값이 폼에 실려 나간다.
   const [isActive, setIsActive] = useState(member.isActive ?? false);
 
+  // 저장에 성공하면 닫는다. 목록은 Server Action의 revalidatePath가 다시 그린다.
+  useEffect(() => {
+    if (state.status === 'success') {
+      onClose();
+    }
+  }, [state, onClose]);
+
   const items: FoxDescriptionItem[] = [
     { key: 'name', term: '이름', description: member.name },
     { key: 'loginId', term: 'ID', description: member.loginId },
lib/data/repositories/student-member-repository.ts
--- lib/data/repositories/student-member-repository.ts
+++ lib/data/repositories/student-member-repository.ts
@@ -1,6 +1,10 @@
 import 'server-only';
 import { getSessionAccessToken } from '@/lib/auth/dal';
-import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
+import {
+  BackendRequestError,
+  backendFetch,
+  type BackendResult,
+} from '@/lib/http/backend-fetch';
 import type { StudentMember } from '@/lib/domain/student-member';
 import type {
   StudentMemberQuery,
@@ -13,7 +17,8 @@
  * `lib/http/backend-fetch.ts`가, 토큰 보관·검증은 `lib/auth`가 소유하므로 여기에 들어오지 않는다.
  *
  *   GET /api/v1/mngr/user/pagination  (ROLE_ADMIN 전용)
- *   → data: { list: [{ rnum, userId, loginId, userNm }], page, size, totalCount, totalPages }
+ *   → data: { list: [{ rnum, userId, userNm, userTelno, userEmlAddr, userType, loginId, schNm,
+ *              grade, clsNo, birth, useYn, … }], page, size, totalCount, totalPages }
  *
  * 아래 내용은 백엔드 저장소(edupay-backend, develop b742bb4)의 실제 구현을 읽고 확인한 것이다
  * — MngrUserApiController / MngrUserServiceImpl / PaginationUtil / MngrUserMapper.xml.
@@ -37,6 +42,7 @@
  */
 
 const STUDENT_MEMBER_PAGINATION_PATH = '/api/v1/mngr/user/pagination';
+const STUDENT_MEMBER_BASE_PATH = '/api/v1/mngr/user';
 
 /**
  * 화면의 "검색 대상" → 백엔드 `searchCondition` 값 매핑. 값은 MngrUserMapper.xml의
@@ -115,6 +121,25 @@
   return value;
 }
 
+/** 있으면 문자열로, 없거나 비어 있으면 null. 백엔드가 빈 문자열로 "없음"을 표현하기도 한다. */
+function readOptionalString(raw: Record<string, unknown>, key: string): string | null {
+  const value = raw[key];
+  if (typeof value === 'string') {
+    return value.trim() === '' ? null : value;
+  }
+  return typeof value === 'number' ? String(value) : null;
+}
+
+/** 학년·반처럼 숫자로 쓰는 값. 숫자로 읽히지 않으면 null이다(백엔드가 문자열로 준다). */
+function readOptionalNumber(raw: Record<string, unknown>, key: string): number | null {
+  const text = readOptionalString(raw, key);
+  if (text === null) {
+    return null;
+  }
+  const parsed = Number(text);
+  return Number.isFinite(parsed) ? parsed : null;
+}
+
 /**
  * 백엔드 응답 1건 → 도메인 타입. 백엔드가 주지 않는 항목은 `null`로 둔다(설계서 §8.1 5층 —
  * 원본 응답을 그대로 흘리지 않고 화면에 필요한 필드만 골라 담는다).
@@ -125,6 +150,15 @@
  * 식별자·이름 세 필드는 없으면 예외로 끊는다(fail-fast) — 목록의 존재 이유인 값이라
  * 빈 화면을 조용히 보여주는 것보다 계약 위반을 즉시 드러내는 편이 낫다.
  */
+/** 백엔드의 `useYn`은 'Y'/'N' 문자열이다. 값이 없으면 판단하지 않고 null로 둔다. */
+function readUseYn(raw: Record<string, unknown>): boolean | null {
+  const value = readOptionalString(raw, 'useYn');
+  if (value === null) {
+    return null;
+  }
+  return value.toUpperCase() === 'Y';
+}
+
 function toStudentMember(raw: unknown): StudentMember {
   if (!isRecord(raw)) {
     throw new Error('학생 회원 응답 항목의 형식이 올바르지 않습니다.');
@@ -138,18 +172,20 @@
     memberCode: userId,
     name: readRequiredString(raw, 'userNm'),
     loginId: readRequiredString(raw, 'loginId'),
-    phoneNumber: null,
-    email: null,
+    phoneNumber: readOptionalString(raw, 'userTelno'),
+    email: readOptionalString(raw, 'userEmlAddr'),
     role: STUDENT_ROLE_LABEL,
-    schoolName: null,
-    grade: null,
-    classNumber: null,
+    schoolName: readOptionalString(raw, 'schNm'),
+    grade: readOptionalNumber(raw, 'grade'),
+    classNumber: readOptionalNumber(raw, 'clsNo'),
+    // 학생 번호는 응답에 없다 — 목록 SQL에도 VO에도 해당 컬럼이 없다.
     studentNumber: null,
+    // 보호자 정보와 가입일도 아직 응답에 없다.
     guardianName: null,
     guardianPhoneNumber: null,
     joinedAt: null,
-    birthDate: null,
-    isActive: null,
+    birthDate: readOptionalString(raw, 'birth'),
+    isActive: readUseYn(raw),
   };
 }
 
@@ -277,3 +313,38 @@
 ): Promise<StudentMemberPage> {
   return query.sort === 'name' ? fetchByName(query) : fetchByJoinedAt(query);
 }
+
+/**
+ * 사용여부 변경.
+ *
+ *   PUT /api/v1/mngr/user/{userId}/{useYn}   (ROLE_ADMIN 전용)
+ *
+ * 값과 대상이 모두 **경로에** 실린다 — 본문도 쿼리도 쓰지 않는다.
+ *
+ * ⚠️ 이 엔드포인트는 **성공 시 본문이 비어 온다.** 인터페이스에는 `ApiResponseVO` 반환으로
+ * 문서화돼 있지만 구현이 `void`이고 `ApiResponseVO.success(null)`을 만들어 놓고 버린다
+ * (edupay-backend develop 4d98756의 `MngrUserApiController.updateUserUseYn`). 그래서
+ * `canHaveEmptyBody`로 봉투 없는 2xx를 성공으로 받는다 — 백엔드가 봉투를 돌려주도록 고쳐도
+ * 그대로 동작한다.
+ *
+ * 최종수정자 정보는 보내지 않는다. 매퍼가 `LAST_MDFR_*`를 쓰지만 그 값은 백엔드의
+ * `CrudLogInterceptor`가 UPDATE마다 자동으로 채운다.
+ */
+export async function updateStudentMemberUseYn(
+  userId: string,
+  isActive: boolean
+): Promise<BackendResult<null>> {
+  const accessToken = await getSessionAccessToken();
+
+  return backendFetch<null>(
+    `${STUDENT_MEMBER_BASE_PATH}/${encodeURIComponent(userId)}/${
+      isActive ? 'Y' : 'N'
+    }`,
+    {
+      method: 'PUT',
+      accessToken: accessToken ?? undefined,
+      canHaveEmptyBody: true,
+      canHaveNullData: true,
+    }
+  );
+}
lib/http/backend-fetch.ts
--- lib/http/backend-fetch.ts
+++ lib/http/backend-fetch.ts
@@ -89,6 +89,20 @@
    * null이 직렬화됨).
    */
   canHaveNullData?: boolean;
+  /**
+   * 이 호출은 성공 시 **본문이 비어 올 수 있다.** 봉투를 파싱하지 않고 2xx를 그대로 성공으로
+   * 본다(`data`는 null).
+   *
+   * 백엔드가 봉투를 안 주는 엔드포인트가 실제로 있다 — 사용여부 변경
+   * (`PUT /api/v1/mngr/user/{userId}/{useYn}`)은 인터페이스에 `ApiResponseVO` 반환으로
+   * 문서화돼 있지만 구현이 `void`이고 `ApiResponseVO.success(null)`을 만들어 놓고 버려서,
+   * `@RestController` + `void` + `HttpServletResponse` 조합상 본문 없는 200이 나간다
+   * (edupay-backend develop 4d98756 확인).
+   *
+   * 백엔드가 봉투를 돌려주도록 고쳐도 이 플래그를 그대로 둘 수 있다 — 본문이 있으면 아래에서
+   * 정상적으로 파싱한다.
+   */
+  canHaveEmptyBody?: boolean;
   /** 기본 타임아웃보다 오래 걸리는 호출(파일 업로드 등)이 값을 올려 잡는다. */
   timeoutMs?: number;
 };
@@ -268,6 +282,30 @@
     return communicationError(`예상치 못한 HTTP 상태: ${response.status}`, undefined);
   }
 
+  // 본문 없는 2xx를 허용한 호출은 먼저 글자를 읽어 비었는지 본다 — 비어 있으면 `response.json()`이
+  // 파싱 오류를 던지므로 그 전에 갈라야 한다.
+  if (init.canHaveEmptyBody) {
+    let raw: string;
+    try {
+      raw = await response.text();
+    } catch (error) {
+      return communicationError('응답 읽기 실패', error);
+    }
+
+    if (raw.trim() === '') {
+      return { ok: true, data: null as T };
+    }
+
+    try {
+      const parsed = JSON.parse(raw) as BackendEnvelope<T>;
+      return parsed.success
+        ? { ok: true, data: parsed.data as T }
+        : { ok: false, code: parsed.code, message: parsed.message };
+    } catch (error) {
+      return communicationError('응답 파싱 실패', error);
+    }
+  }
+
   let envelope: BackendEnvelope<T>;
   try {
     envelope = (await response.json()) as BackendEnvelope<T>;
Add a comment
List