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
... | ... | @@ -96,10 +96,14 @@ |
| 96 | 96 |
{
|
| 97 | 97 |
key: 'guardian', |
| 98 | 98 |
term: '보호자 이름 / 연락처', |
| 99 |
- description: joinPair( |
|
| 100 |
- member.guardianName, |
|
| 101 |
- formatPhoneNumber(member.guardianPhoneNumber) |
|
| 102 |
- ), |
|
| 99 |
+ description: |
|
| 100 |
+ member.guardians.length === 0 |
|
| 101 |
+ ? EMPTY_FIELD_PLACEHOLDER |
|
| 102 |
+ : member.guardians.map((guardian, index) => ( |
|
| 103 |
+ <div key={index}>
|
|
| 104 |
+ {joinPair(guardian.name, formatPhoneNumber(guardian.phoneNumber))}
|
|
| 105 |
+ </div> |
|
| 106 |
+ )), |
|
| 103 | 107 |
}, |
| 104 | 108 |
{
|
| 105 | 109 |
key: 'school', |
--- app/(protected)/(basic)/students/_components/student-list.tsx
+++ app/(protected)/(basic)/students/_components/student-list.tsx
... | ... | @@ -41,6 +41,14 @@ |
| 41 | 41 |
return value === null || value === undefined || value === '' ? '-' : String(value); |
| 42 | 42 |
} |
| 43 | 43 |
|
| 44 |
+/** 보호자가 여럿이면 한 칸 안에서 줄을 나눈다. 없으면 `-` 한 줄이다. */ |
|
| 45 |
+function lines(values: (string | null)[]) {
|
|
| 46 |
+ if (values.length === 0) {
|
|
| 47 |
+ return text(null); |
|
| 48 |
+ } |
|
| 49 |
+ return values.map((value, index) => <div key={index}>{text(value)}</div>);
|
|
| 50 |
+} |
|
| 51 |
+ |
|
| 44 | 52 |
/** |
| 45 | 53 |
* 학생 회원 목록 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) container 5199:9014 |
| 46 | 54 |
* |
... | ... | @@ -108,13 +116,16 @@ |
| 108 | 116 |
key: 'guardianName', |
| 109 | 117 |
header: '보호자이름', |
| 110 | 118 |
width: 120, |
| 111 |
- render: (row) => text(row.guardianName), |
|
| 119 |
+ render: (row) => lines(row.guardians.map((guardian) => guardian.name)), |
|
| 112 | 120 |
}, |
| 113 | 121 |
{
|
| 114 | 122 |
key: 'guardianPhoneNumber', |
| 115 | 123 |
header: '보호자연락처', |
| 116 | 124 |
width: 160, |
| 117 |
- render: (row) => text(formatPhoneNumber(row.guardianPhoneNumber)), |
|
| 125 |
+ render: (row) => |
|
| 126 |
+ lines( |
|
| 127 |
+ row.guardians.map((guardian) => formatPhoneNumber(guardian.phoneNumber)) |
|
| 128 |
+ ), |
|
| 118 | 129 |
}, |
| 119 | 130 |
{
|
| 120 | 131 |
key: 'joinedAt', |
--- lib/data/repositories/student-member-repository.ts
+++ lib/data/repositories/student-member-repository.ts
... | ... | @@ -5,7 +5,10 @@ |
| 5 | 5 |
backendFetch, |
| 6 | 6 |
type BackendResult, |
| 7 | 7 |
} from '@/lib/http/backend-fetch'; |
| 8 |
-import type { StudentMember } from '@/lib/domain/student-member';
|
|
| 8 |
+import type {
|
|
| 9 |
+ StudentGuardian, |
|
| 10 |
+ StudentMember, |
|
| 11 |
+} from '@/lib/domain/student-member'; |
|
| 9 | 12 |
import type {
|
| 10 | 13 |
StudentMemberQuery, |
| 11 | 14 |
StudentMemberSearchField, |
... | ... | @@ -20,13 +23,12 @@ |
| 20 | 23 |
* → data: { list: [{ rnum, userId, userNm, userTelno, userEmlAddr, userType, loginId, schNm,
|
| 21 | 24 |
* grade, clsNo, birth, useYn, … }], page, size, totalCount, totalPages } |
| 22 | 25 |
* |
| 23 |
- * 아래 내용은 백엔드 저장소(edupay-backend, develop b742bb4)의 실제 구현을 읽고 확인한 것이다 |
|
| 26 |
+ * 아래 내용은 백엔드 저장소(edupay-backend, develop 5fa9d34)의 실제 구현을 읽고 확인한 것이다 |
|
| 24 | 27 |
* — MngrUserApiController / MngrUserServiceImpl / PaginationUtil / MngrUserMapper.xml. |
| 25 | 28 |
* |
| 26 |
- * - **응답이 담는 값은 식별자·이름 계열 넷뿐이다.** 조회 SQL 자체는 USER_TELNO·USER_EML_ADDR· |
|
| 27 |
- * SCH_NM·GRADE·CLS_NO·BIRTH까지 이미 select하지만 응답 VO(MngrUserVo)가 4개 필드만 노출해 |
|
| 28 |
- * 나머지가 버려진다. 그래서 화면의 나머지 항목은 `null` → `-`다. **백엔드가 VO에 필드를 |
|
| 29 |
- * 추가하면** `toStudentMember`의 매핑만 늘리면 되고 화면은 손대지 않는다. |
|
| 29 |
+ * - 보호자는 `TB_COM_ADBK`를 LEFT JOIN해 `adbkList`(`parentNm`·`parentTelNo`)로 온다. |
|
| 30 |
+ * 한 학생에 여러 명이 붙을 수 있어 화면도 목록·팝업 모두 줄을 나눠 보여준다. |
|
| 31 |
+ * - **학생 번호·가입일은 여전히 응답에 없다** — 테이블에 컬럼 자체가 없다. 화면은 `-`다. |
|
| 30 | 32 |
* - **정렬 파라미터가 없다** — 목록 SQL의 `ORDER BY rnum DESC`가 하드코딩돼 있어 순서를 지정할 수 |
| 31 | 33 |
* 없다. 그 고정 순서가 곧 가입일 최신순이라(rnum은 최초등록일시 기준 행번호) 가입일순은 그대로 |
| 32 | 34 |
* 쓰고, 이름순만 전체를 받아 서버에서 정렬한다(`fetchByName`). |
... | ... | @@ -159,6 +161,28 @@ |
| 159 | 161 |
return value.toUpperCase() === 'Y'; |
| 160 | 162 |
} |
| 161 | 163 |
|
| 164 |
+/** |
|
| 165 |
+ * `adbkList` → 보호자 목록. 백엔드가 `notNullColumn="parent_nm"`으로 걸러 주지만, 보호자가 |
|
| 166 |
+ * 없는 학생에게 빈 항목이 오는 경우도 있어 이름·연락처가 모두 없는 행은 버린다. |
|
| 167 |
+ */ |
|
| 168 |
+function toGuardians(raw: unknown): StudentGuardian[] {
|
|
| 169 |
+ if (!Array.isArray(raw)) {
|
|
| 170 |
+ return []; |
|
| 171 |
+ } |
|
| 172 |
+ |
|
| 173 |
+ return raw.flatMap((entry) => {
|
|
| 174 |
+ if (!isRecord(entry)) {
|
|
| 175 |
+ return []; |
|
| 176 |
+ } |
|
| 177 |
+ const name = readOptionalString(entry, 'parentNm'); |
|
| 178 |
+ const phoneNumber = readOptionalString(entry, 'parentTelNo'); |
|
| 179 |
+ if (name === null && phoneNumber === null) {
|
|
| 180 |
+ return []; |
|
| 181 |
+ } |
|
| 182 |
+ return [{ name, phoneNumber }];
|
|
| 183 |
+ }); |
|
| 184 |
+} |
|
| 185 |
+ |
|
| 162 | 186 |
function toStudentMember(raw: unknown): StudentMember {
|
| 163 | 187 |
if (!isRecord(raw)) {
|
| 164 | 188 |
throw new Error('학생 회원 응답 항목의 형식이 올바르지 않습니다.');
|
... | ... | @@ -172,7 +196,7 @@ |
| 172 | 196 |
memberCode: userId, |
| 173 | 197 |
name: readRequiredString(raw, 'userNm'), |
| 174 | 198 |
loginId: readRequiredString(raw, 'loginId'), |
| 175 |
- phoneNumber: readOptionalString(raw, 'userTelno'), |
|
| 199 |
+ phoneNumber: readOptionalString(raw, 'userTelNo'), |
|
| 176 | 200 |
email: readOptionalString(raw, 'userEmlAddr'), |
| 177 | 201 |
role: STUDENT_ROLE_LABEL, |
| 178 | 202 |
schoolName: readOptionalString(raw, 'schNm'), |
... | ... | @@ -180,9 +204,8 @@ |
| 180 | 204 |
classNumber: readOptionalNumber(raw, 'clsNo'), |
| 181 | 205 |
// 학생 번호는 응답에 없다 — 목록 SQL에도 VO에도 해당 컬럼이 없다. |
| 182 | 206 |
studentNumber: null, |
| 183 |
- // 보호자 정보와 가입일도 아직 응답에 없다. |
|
| 184 |
- guardianName: null, |
|
| 185 |
- guardianPhoneNumber: null, |
|
| 207 |
+ guardians: toGuardians(raw.adbkList), |
|
| 208 |
+ // 가입일은 아직 응답에 없다. |
|
| 186 | 209 |
joinedAt: null, |
| 187 | 210 |
birthDate: readOptionalString(raw, 'birth'), |
| 188 | 211 |
isActive: readUseYn(raw), |
--- lib/domain/student-member.ts
+++ lib/domain/student-member.ts
... | ... | @@ -13,6 +13,12 @@ |
| 13 | 13 |
* 조회 전용 화면(등록/수정/삭제 없음)의 데이터라 모든 필드를 읽기 전용으로 취급한다 |
| 14 | 14 |
* (수정 가능 여부는 UI 계층의 책임이지 타입 자체의 제약은 아니다). |
| 15 | 15 |
*/ |
| 16 |
+/** 보호자 한 명. 백엔드 `adbkList`의 한 행(`parentNm`·`parentTelNo`)이다. */ |
|
| 17 |
+export type StudentGuardian = {
|
|
| 18 |
+ name: string | null; |
|
| 19 |
+ phoneNumber: string | null; |
|
| 20 |
+}; |
|
| 21 |
+ |
|
| 16 | 22 |
export type StudentMember = {
|
| 17 | 23 |
/** 내부 식별자 — 백엔드 `userId`. 목록 행의 key이자 향후 단건 조회의 입력값이다. */ |
| 18 | 24 |
id: string; |
... | ... | @@ -30,8 +36,8 @@ |
| 30 | 36 |
grade: number | null; |
| 31 | 37 |
classNumber: number | null; |
| 32 | 38 |
studentNumber: number | null; |
| 33 |
- guardianName: string | null; |
|
| 34 |
- guardianPhoneNumber: string | null; |
|
| 39 |
+ /** 보호자 목록. 한 학생에 여러 명이 등록될 수 있어 배열이다. 없으면 빈 배열. */ |
|
| 40 |
+ guardians: StudentGuardian[]; |
|
| 35 | 41 |
/** ISO 형식(YYYY-MM-DD) 문자열. */ |
| 36 | 42 |
joinedAt: string | null; |
| 37 | 43 |
/** ISO 형식(YYYY-MM-DD) 문자열. */ |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?