임동욱 임동욱 09-03
Merge branch 'claude/admin-member-list-design-b1fec7' into hub
@10038945700786774104ba01193e8a66ec1d8b81
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
@@ -96,10 +96,14 @@
     {
       key: 'guardian',
       term: '보호자 이름 / 연락처',
-      description: joinPair(
-        member.guardianName,
-        formatPhoneNumber(member.guardianPhoneNumber)
-      ),
+      description:
+        member.guardians.length === 0
+          ? EMPTY_FIELD_PLACEHOLDER
+          : member.guardians.map((guardian, index) => (
+              <div key={index}>
+                {joinPair(guardian.name, formatPhoneNumber(guardian.phoneNumber))}
+              </div>
+            )),
     },
     {
       key: 'school',
app/(protected)/(basic)/students/_components/student-list.tsx
--- app/(protected)/(basic)/students/_components/student-list.tsx
+++ app/(protected)/(basic)/students/_components/student-list.tsx
@@ -41,6 +41,14 @@
   return value === null || value === undefined || value === '' ? '-' : String(value);
 }
 
+/** 보호자가 여럿이면 한 칸 안에서 줄을 나눈다. 없으면 `-` 한 줄이다. */
+function lines(values: (string | null)[]) {
+  if (values.length === 0) {
+    return text(null);
+  }
+  return values.map((value, index) => <div key={index}>{text(value)}</div>);
+}
+
 /**
  * 학생 회원 목록 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) container 5199:9014
  *
@@ -108,13 +116,16 @@
       key: 'guardianName',
       header: '보호자이름',
       width: 120,
-      render: (row) => text(row.guardianName),
+      render: (row) => lines(row.guardians.map((guardian) => guardian.name)),
     },
     {
       key: 'guardianPhoneNumber',
       header: '보호자연락처',
       width: 160,
-      render: (row) => text(formatPhoneNumber(row.guardianPhoneNumber)),
+      render: (row) =>
+        lines(
+          row.guardians.map((guardian) => formatPhoneNumber(guardian.phoneNumber))
+        ),
     },
     {
       key: 'joinedAt',
lib/data/repositories/student-member-repository.ts
--- lib/data/repositories/student-member-repository.ts
+++ lib/data/repositories/student-member-repository.ts
@@ -5,7 +5,10 @@
   backendFetch,
   type BackendResult,
 } from '@/lib/http/backend-fetch';
-import type { StudentMember } from '@/lib/domain/student-member';
+import type {
+  StudentGuardian,
+  StudentMember,
+} from '@/lib/domain/student-member';
 import type {
   StudentMemberQuery,
   StudentMemberSearchField,
@@ -20,13 +23,12 @@
  *   → data: { list: [{ rnum, userId, userNm, userTelno, userEmlAddr, userType, loginId, schNm,
  *              grade, clsNo, birth, useYn, … }], page, size, totalCount, totalPages }
  *
- * 아래 내용은 백엔드 저장소(edupay-backend, develop b742bb4)의 실제 구현을 읽고 확인한 것이다
+ * 아래 내용은 백엔드 저장소(edupay-backend, develop 5fa9d34)의 실제 구현을 읽고 확인한 것이다
  * — MngrUserApiController / MngrUserServiceImpl / PaginationUtil / MngrUserMapper.xml.
  *
- * - **응답이 담는 값은 식별자·이름 계열 넷뿐이다.** 조회 SQL 자체는 USER_TELNO·USER_EML_ADDR·
- *   SCH_NM·GRADE·CLS_NO·BIRTH까지 이미 select하지만 응답 VO(MngrUserVo)가 4개 필드만 노출해
- *   나머지가 버려진다. 그래서 화면의 나머지 항목은 `null` → `-`다. **백엔드가 VO에 필드를
- *   추가하면** `toStudentMember`의 매핑만 늘리면 되고 화면은 손대지 않는다.
+ * - 보호자는 `TB_COM_ADBK`를 LEFT JOIN해 `adbkList`(`parentNm`·`parentTelNo`)로 온다.
+ *   한 학생에 여러 명이 붙을 수 있어 화면도 목록·팝업 모두 줄을 나눠 보여준다.
+ * - **학생 번호·가입일은 여전히 응답에 없다** — 테이블에 컬럼 자체가 없다. 화면은 `-`다.
  * - **정렬 파라미터가 없다** — 목록 SQL의 `ORDER BY rnum DESC`가 하드코딩돼 있어 순서를 지정할 수
  *   없다. 그 고정 순서가 곧 가입일 최신순이라(rnum은 최초등록일시 기준 행번호) 가입일순은 그대로
  *   쓰고, 이름순만 전체를 받아 서버에서 정렬한다(`fetchByName`).
@@ -159,6 +161,28 @@
   return value.toUpperCase() === 'Y';
 }
 
+/**
+ * `adbkList` → 보호자 목록. 백엔드가 `notNullColumn="parent_nm"`으로 걸러 주지만, 보호자가
+ * 없는 학생에게 빈 항목이 오는 경우도 있어 이름·연락처가 모두 없는 행은 버린다.
+ */
+function toGuardians(raw: unknown): StudentGuardian[] {
+  if (!Array.isArray(raw)) {
+    return [];
+  }
+
+  return raw.flatMap((entry) => {
+    if (!isRecord(entry)) {
+      return [];
+    }
+    const name = readOptionalString(entry, 'parentNm');
+    const phoneNumber = readOptionalString(entry, 'parentTelNo');
+    if (name === null && phoneNumber === null) {
+      return [];
+    }
+    return [{ name, phoneNumber }];
+  });
+}
+
 function toStudentMember(raw: unknown): StudentMember {
   if (!isRecord(raw)) {
     throw new Error('학생 회원 응답 항목의 형식이 올바르지 않습니다.');
@@ -172,7 +196,7 @@
     memberCode: userId,
     name: readRequiredString(raw, 'userNm'),
     loginId: readRequiredString(raw, 'loginId'),
-    phoneNumber: readOptionalString(raw, 'userTelno'),
+    phoneNumber: readOptionalString(raw, 'userTelNo'),
     email: readOptionalString(raw, 'userEmlAddr'),
     role: STUDENT_ROLE_LABEL,
     schoolName: readOptionalString(raw, 'schNm'),
@@ -180,9 +204,8 @@
     classNumber: readOptionalNumber(raw, 'clsNo'),
     // 학생 번호는 응답에 없다 — 목록 SQL에도 VO에도 해당 컬럼이 없다.
     studentNumber: null,
-    // 보호자 정보와 가입일도 아직 응답에 없다.
-    guardianName: null,
-    guardianPhoneNumber: null,
+    guardians: toGuardians(raw.adbkList),
+    // 가입일은 아직 응답에 없다.
     joinedAt: null,
     birthDate: readOptionalString(raw, 'birth'),
     isActive: readUseYn(raw),
lib/domain/student-member.ts
--- lib/domain/student-member.ts
+++ lib/domain/student-member.ts
@@ -13,6 +13,12 @@
  * 조회 전용 화면(등록/수정/삭제 없음)의 데이터라 모든 필드를 읽기 전용으로 취급한다
  * (수정 가능 여부는 UI 계층의 책임이지 타입 자체의 제약은 아니다).
  */
+/** 보호자 한 명. 백엔드 `adbkList`의 한 행(`parentNm`·`parentTelNo`)이다. */
+export type StudentGuardian = {
+  name: string | null;
+  phoneNumber: string | null;
+};
+
 export type StudentMember = {
   /** 내부 식별자 — 백엔드 `userId`. 목록 행의 key이자 향후 단건 조회의 입력값이다. */
   id: string;
@@ -30,8 +36,8 @@
   grade: number | null;
   classNumber: number | null;
   studentNumber: number | null;
-  guardianName: string | null;
-  guardianPhoneNumber: string | null;
+  /** 보호자 목록. 한 학생에 여러 명이 등록될 수 있어 배열이다. 없으면 빈 배열. */
+  guardians: StudentGuardian[];
   /** ISO 형식(YYYY-MM-DD) 문자열. */
   joinedAt: string | null;
   /** ISO 형식(YYYY-MM-DD) 문자열. */
Add a comment
List