feat: 학생 회원 목록을 백엔드 API로 전환
mock 학생 회원 생성기를 제거하고 GET /api/v1/mngr/user/pagination에 연결한다. 백엔드 저장소(develop b742bb4)의 실제 구현을 읽고 계약을 확정했다. - searchCondition은 "1"(이름)·"2"(아이디)·"3"(휴대전화)만 유효하다. 목록에 없는 값을 보내면 조건 없이 전체가 반환되므로 지원되지 않는 학교명·회원코드 검색은 제거했다. - 유효한 페이징 파라미터는 pageIndex·recordCountPerPage 둘뿐이다(서버가 offset을 직접 계산해 나머지를 덮어쓴다). - 응답의 totalCount는 전체 건수가 아니라 현재 페이지 행 수다(백엔드에 count 쿼리 없음). 그대로 믿으면 가득 찬 페이지 뒤 데이터에 접근할 수 없어 하한값으로 보정하고, 확정되지 않은 건수는 화면에 "N명 이상"으로 표기한다. - 응답이 주지 않는 항목(휴대전화·이메일·학교·학년/반·보호자·가입일·사용여부)은 열을 유지한 채 '-'로 표시한다. 정렬 파라미터와 사용여부 변경 API가 없어 해당 컨트롤은 비활성으로 둔다.
@3f071916829427346ff9720ab4031c0d12c5d7bb
--- app/(protected)/(basic)/students/_actions.ts
+++ app/(protected)/(basic)/students/_actions.ts
... | ... | @@ -1,49 +1,34 @@ |
| 1 | 1 |
'use server'; |
| 2 | 2 |
|
| 3 |
-import { revalidatePath } from 'next/cache';
|
|
| 4 | 3 |
import { verifySession } from '@/lib/auth/dal';
|
| 5 |
-import {
|
|
| 6 |
- fetchStudentMemberById, |
|
| 7 |
- updateStudentMemberActiveStatus, |
|
| 8 |
-} from '@/lib/data/repositories/student-member-repository'; |
|
| 9 |
-import { STUDENT_MEMBERS_PATH } from '@/lib/domain/student-member-query';
|
|
| 10 | 4 |
|
| 11 | 5 |
export type UpdateStudentActiveStatusState = |
| 12 | 6 |
| { status: 'idle' }
|
| 13 | 7 |
| { status: 'error'; error: string }
|
| 14 | 8 |
| { status: 'success' };
|
| 15 | 9 |
|
| 16 |
-const GENERIC_ERROR = '요청을 처리할 수 없습니다. 잠시 후 다시 시도해 주세요.'; |
|
| 10 |
+const UNSUPPORTED_ERROR = |
|
| 11 |
+ '사용여부 변경은 아직 제공되지 않습니다. (백엔드 API 준비 중)'; |
|
| 17 | 12 |
|
| 18 | 13 |
/** |
| 19 |
- * 학생 회원 사용여부 변경 Server Action — 조회 팝업에서 유일하게 수정 가능한 필드다. |
|
| 20 |
- * Server Action은 UI를 거치지 않고 직접 POST될 수 있으므로 인증 확인과 입력 검증을 이 |
|
| 21 |
- * 함수 내부에서 직접 수행한다(§3 SRP 체크). 성공 시 목록 화면을 재검증해 변경된 사용여부가 |
|
| 22 |
- * 즉시 반영되게 한다. |
|
| 14 |
+ * 학생 회원 사용여부 변경 Server Action — **현재는 미지원 상태의 이식 지점**이다. |
|
| 15 |
+ * |
|
| 16 |
+ * 목록이 백엔드 API(`GET /api/v1/mngr/user/pagination`)로 전환되면서 이 화면의 데이터 원천은 |
|
| 17 |
+ * 백엔드가 됐지만, 사용여부는 **조회 응답에 값도 없고 변경 API도 없다.** 이전의 mock 쓰기를 |
|
| 18 |
+ * 그대로 두면 mock 배열에만 존재하는 id를 찾다가 "존재하지 않는 학생 회원" 오류가 나므로 |
|
| 19 |
+ * (목록의 id는 이제 백엔드 `userId`다) 쓰기 경로를 명시적으로 막았다. 팝업의 사용여부 라디오와 |
|
| 20 |
+ * 저장 버튼도 같은 이유로 비활성이다. |
|
| 21 |
+ * |
|
| 22 |
+ * 변경 API가 생기면 `formData: FormData` 인자를 되살리고 본문을 "입력 검증 → Repository 호출 |
|
| 23 |
+ * → `revalidatePath`"로 되돌리면 된다(지금은 읽을 입력이 없어 인자를 받지 않는다 — 인자를 |
|
| 24 |
+ * 줄여도 `useActionState`의 호출 규약에는 어긋나지 않는다). 인증 확인을 본문 맨 앞에 남겨 둔 |
|
| 25 |
+ * 것도 그 형태를 유지하기 위함이다 — Server Action은 UI를 거치지 않고 직접 POST될 수 있어 |
|
| 26 |
+ * 이 확인이 유일한 최종 방어선이다. |
|
| 23 | 27 |
*/ |
| 24 | 28 |
export async function updateStudentActiveStatus( |
| 25 |
- _prevState: UpdateStudentActiveStatusState, |
|
| 26 |
- formData: FormData |
|
| 29 |
+ _prevState: UpdateStudentActiveStatusState |
|
| 27 | 30 |
): Promise<UpdateStudentActiveStatusState> {
|
| 28 | 31 |
await verifySession(); |
| 29 | 32 |
|
| 30 |
- const id = formData.get('id');
|
|
| 31 |
- const isActiveRaw = formData.get('isActive');
|
|
| 32 |
- |
|
| 33 |
- if (typeof id !== 'string' || id.trim().length === 0) {
|
|
| 34 |
- return { status: 'error', error: GENERIC_ERROR };
|
|
| 35 |
- } |
|
| 36 |
- if (isActiveRaw !== 'true' && isActiveRaw !== 'false') {
|
|
| 37 |
- return { status: 'error', error: GENERIC_ERROR };
|
|
| 38 |
- } |
|
| 39 |
- |
|
| 40 |
- const member = await fetchStudentMemberById(id); |
|
| 41 |
- if (!member) {
|
|
| 42 |
- return { status: 'error', error: '존재하지 않는 학생 회원입니다.' };
|
|
| 43 |
- } |
|
| 44 |
- |
|
| 45 |
- await updateStudentMemberActiveStatus(id, isActiveRaw === 'true'); |
|
| 46 |
- revalidatePath(STUDENT_MEMBERS_PATH); |
|
| 47 |
- |
|
| 48 |
- return { status: 'success' };
|
|
| 33 |
+ return { status: 'error', error: UNSUPPORTED_ERROR };
|
|
| 49 | 34 |
} |
--- app/(protected)/(basic)/students/_components/student-detail-modal.tsx
+++ app/(protected)/(basic)/students/_components/student-detail-modal.tsx
... | ... | @@ -6,7 +6,11 @@ |
| 6 | 6 |
import { Input } from '@/components/ui/input';
|
| 7 | 7 |
import { Modal } from '@/components/ui/modal';
|
| 8 | 8 |
import { RadioGroup } from '@/components/ui/radio-group';
|
| 9 |
-import type { StudentMember } from '@/lib/domain/student-member';
|
|
| 9 |
+import {
|
|
| 10 |
+ formatGradeClassNumber, |
|
| 11 |
+ formatOptionalValue, |
|
| 12 |
+ type StudentMember, |
|
| 13 |
+} from '@/lib/domain/student-member'; |
|
| 10 | 14 |
import {
|
| 11 | 15 |
updateStudentActiveStatus, |
| 12 | 16 |
type UpdateStudentActiveStatusState, |
... | ... | @@ -26,9 +30,23 @@ |
| 26 | 30 |
const DETAIL_FORM_ID = 'student-active-status-form'; |
| 27 | 31 |
|
| 28 | 32 |
/** |
| 29 |
- * 학생 회원 조회 팝업 — 사용여부를 제외한 모든 항목은 readOnly다. 데이터는 목록 행에서 이미 |
|
| 30 |
- * 갖고 있는 member를 props로 그대로 받으므로 재조회하지 않는다. 저장(Server Action) 성공 시 |
|
| 31 |
- * 자동으로 닫힌다. |
|
| 33 |
+ * 사용여부 수정 가능 여부의 단일 토글. 백엔드에 사용여부 값도 변경 API도 없어 현재는 false다 — |
|
| 34 |
+ * 변경 API가 생기면 이 상수를 true로 되돌리고 `_actions.ts`의 미지원 처리를 실제 구현으로 |
|
| 35 |
+ * 바꾸면 된다(라디오·저장 버튼의 비활성이 함께 풀린다). |
|
| 36 |
+ */ |
|
| 37 |
+const IS_ACTIVE_STATUS_EDITABLE = false; |
|
| 38 |
+const ACTIVE_STATUS_UNSUPPORTED_NOTE = |
|
| 39 |
+ '사용여부 변경은 백엔드 API 준비 후 제공됩니다.'; |
|
| 40 |
+ |
|
| 41 |
+/** |
|
| 42 |
+ * 학생 회원 조회 팝업 — 모든 항목이 readOnly다. 데이터는 목록 행에서 이미 갖고 있는 member를 |
|
| 43 |
+ * props로 그대로 받으므로 재조회하지 않는다(백엔드에 단건 조회 API도 아직 없다). |
|
| 44 |
+ * 백엔드가 주지 않는 항목은 `-`로 표시한다. |
|
| 45 |
+ * |
|
| 46 |
+ * 사용여부는 원래 이 팝업에서 유일하게 수정 가능한 항목이었지만, 백엔드 목록 응답에 값이 없고 |
|
| 47 |
+ * 변경 API도 없어 지금은 라디오·저장 버튼을 비활성으로 둔다(`_actions.ts`의 미지원 처리와 짝). |
|
| 48 |
+ * 폼·Server Action 배선은 그대로 남겨 두었으므로 변경 API가 생기면 `disabled`만 걷어내면 된다. |
|
| 49 |
+ * 저장(Server Action) 성공 시 자동으로 닫히는 동작도 그대로다. |
|
| 32 | 50 |
* |
| 33 | 51 |
* Modal이 내부적으로 portal을 쓸 수도 있어(구현 미확정 — components/ui/modal.tsx는 design |
| 34 | 52 |
* 레인 소관) 저장 버튼은 footer 슬롯에서 `form={DETAIL_FORM_ID}` 속성으로 폼과 연결한다.
|
... | ... | @@ -47,7 +65,7 @@ |
| 47 | 65 |
} |
| 48 | 66 |
}, [state, onClose]); |
| 49 | 67 |
|
| 50 |
- const gradeClassNumber = `${member.grade}학년 ${member.classNumber}반 ${member.studentNumber}번`;
|
|
| 68 |
+ const gradeClassNumber = formatGradeClassNumber(member); |
|
| 51 | 69 |
|
| 52 | 70 |
return ( |
| 53 | 71 |
<Modal |
... | ... | @@ -62,7 +80,12 @@ |
| 62 | 80 |
type="submit" |
| 63 | 81 |
form={DETAIL_FORM_ID}
|
| 64 | 82 |
variant="primary" |
| 65 |
- disabled={isPending}
|
|
| 83 |
+ disabled={isPending || !IS_ACTIVE_STATUS_EDITABLE}
|
|
| 84 |
+ title={
|
|
| 85 |
+ IS_ACTIVE_STATUS_EDITABLE |
|
| 86 |
+ ? undefined |
|
| 87 |
+ : ACTIVE_STATUS_UNSUPPORTED_NOTE |
|
| 88 |
+ } |
|
| 66 | 89 |
> |
| 67 | 90 |
{isPending ? '저장 중...' : '저장'}
|
| 68 | 91 |
</Button> |
... | ... | @@ -73,7 +96,7 @@ |
| 73 | 96 |
<input type="hidden" name="id" value={member.id} />
|
| 74 | 97 |
|
| 75 | 98 |
<p className="text-body-sm text-foreground-muted"> |
| 76 |
- 본 화면의 모든 항목은 조회 전용입니다. (단, 사용여부는 수정 가능) |
|
| 99 |
+ 본 화면의 모든 항목은 조회 전용입니다. |
|
| 77 | 100 |
</p> |
| 78 | 101 |
|
| 79 | 102 |
<Field label="이름"> |
... | ... | @@ -83,22 +106,25 @@ |
| 83 | 106 |
<Input value={member.loginId} readOnly />
|
| 84 | 107 |
</Field> |
| 85 | 108 |
<Field label="휴대전화 번호"> |
| 86 |
- <Input value={member.phoneNumber} readOnly />
|
|
| 109 |
+ <Input value={formatOptionalValue(member.phoneNumber)} readOnly />
|
|
| 87 | 110 |
</Field> |
| 88 | 111 |
<Field label="이메일"> |
| 89 |
- <Input value={member.email} readOnly />
|
|
| 112 |
+ <Input value={formatOptionalValue(member.email)} readOnly />
|
|
| 90 | 113 |
</Field> |
| 91 | 114 |
<Field label="생년월일"> |
| 92 |
- <Input value={member.birthDate} readOnly />
|
|
| 115 |
+ <Input value={formatOptionalValue(member.birthDate)} readOnly />
|
|
| 93 | 116 |
</Field> |
| 94 | 117 |
<Field label="보호자 이름"> |
| 95 |
- <Input value={member.guardianName} readOnly />
|
|
| 118 |
+ <Input value={formatOptionalValue(member.guardianName)} readOnly />
|
|
| 96 | 119 |
</Field> |
| 97 | 120 |
<Field label="보호자 연락처"> |
| 98 |
- <Input value={member.guardianPhoneNumber} readOnly />
|
|
| 121 |
+ <Input |
|
| 122 |
+ value={formatOptionalValue(member.guardianPhoneNumber)}
|
|
| 123 |
+ readOnly |
|
| 124 |
+ /> |
|
| 99 | 125 |
</Field> |
| 100 | 126 |
<Field label="학교"> |
| 101 |
- <Input value={member.schoolName} readOnly />
|
|
| 127 |
+ <Input value={formatOptionalValue(member.schoolName)} readOnly />
|
|
| 102 | 128 |
</Field> |
| 103 | 129 |
<Field label="학년/반/번호"> |
| 104 | 130 |
<Input value={gradeClassNumber} readOnly />
|
... | ... | @@ -108,16 +134,27 @@ |
| 108 | 134 |
<RadioGroup |
| 109 | 135 |
name="isActive" |
| 110 | 136 |
options={ACTIVE_STATUS_OPTIONS}
|
| 111 |
- defaultValue={member.isActive ? 'true' : 'false'}
|
|
| 137 |
+ defaultValue={
|
|
| 138 |
+ member.isActive === null |
|
| 139 |
+ ? undefined |
|
| 140 |
+ : String(member.isActive) |
|
| 141 |
+ } |
|
| 142 |
+ disabled={!IS_ACTIVE_STATUS_EDITABLE}
|
|
| 112 | 143 |
/> |
| 113 | 144 |
</Field> |
| 145 |
+ |
|
| 146 |
+ {!IS_ACTIVE_STATUS_EDITABLE && (
|
|
| 147 |
+ <p className="text-body-sm text-foreground-muted"> |
|
| 148 |
+ {ACTIVE_STATUS_UNSUPPORTED_NOTE}
|
|
| 149 |
+ </p> |
|
| 150 |
+ )} |
|
| 114 | 151 |
|
| 115 | 152 |
{state.status === 'error' && (
|
| 116 | 153 |
<p className="text-body-sm text-danger">{state.error}</p>
|
| 117 | 154 |
)} |
| 118 | 155 |
|
| 119 | 156 |
<p className="text-body-sm text-foreground-muted"> |
| 120 |
- 회원 가입일 : {member.joinedAt}
|
|
| 157 |
+ 회원 가입일 : {formatOptionalValue(member.joinedAt)}
|
|
| 121 | 158 |
</p> |
| 122 | 159 |
</form> |
| 123 | 160 |
</Modal> |
--- app/(protected)/(basic)/students/_components/student-list-toolbar.tsx
+++ app/(protected)/(basic)/students/_components/student-list-toolbar.tsx
... | ... | @@ -23,6 +23,11 @@ |
| 23 | 23 |
* 바뀌면 1페이지로 되돌린다(기존 페이지 번호가 새 정렬·크기 기준으로는 의미가 달라지므로). |
| 24 | 24 |
* 엑셀다운로드는 이번 범위에서 버튼 배치만 하고 동작은 구현하지 않는다(사용자 지시) — |
| 25 | 25 |
* disabled로 두어 클릭해도 아무 일도 일어나지 않게 한다. |
| 26 |
+ * |
|
| 27 |
+ * 정렬 select도 같은 이유로 비활성이다 — 백엔드 목록 API(`/api/v1/mngr/user/pagination`)에 |
|
| 28 |
+ * 정렬 파라미터가 없어 값을 바꿔도 결과가 달라지지 않는다. 현재 페이지 안에서만 다시 정렬하는 |
|
| 29 |
+ * 것은 "전체 기준 정렬"처럼 보이는 잘못된 결과라 하지 않는다. 정렬 파라미터가 생기면 이 |
|
| 30 |
+ * disabled만 걷어내고 Repository에 매핑을 추가하면 된다. |
|
| 26 | 31 |
*/ |
| 27 | 32 |
export function StudentListToolbar({ query }: StudentListToolbarProps) {
|
| 28 | 33 |
const router = useRouter(); |
... | ... | @@ -49,6 +54,8 @@ |
| 49 | 54 |
aria-label="정렬" |
| 50 | 55 |
defaultValue={query.sort}
|
| 51 | 56 |
onChange={handleSortChange}
|
| 57 |
+ disabled |
|
| 58 |
+ title="정렬은 백엔드 API가 지원하지 않습니다." |
|
| 52 | 59 |
> |
| 53 | 60 |
{STUDENT_MEMBER_SORT_OPTIONS.map((option) => (
|
| 54 | 61 |
<option key={option.value} value={option.value}>
|
--- app/(protected)/(basic)/students/_components/student-table.tsx
+++ app/(protected)/(basic)/students/_components/student-table.tsx
... | ... | @@ -6,7 +6,11 @@ |
| 6 | 6 |
TableHeaderCell, |
| 7 | 7 |
TableRow, |
| 8 | 8 |
} from '@/components/ui/table'; |
| 9 |
-import type { StudentMember } from '@/lib/domain/student-member';
|
|
| 9 |
+import {
|
|
| 10 |
+ formatGradeClassNumber, |
|
| 11 |
+ formatOptionalValue, |
|
| 12 |
+ type StudentMember, |
|
| 13 |
+} from '@/lib/domain/student-member'; |
|
| 10 | 14 |
import { StudentRowActions } from './student-row-actions';
|
| 11 | 15 |
|
| 12 | 16 |
interface StudentTableProps {
|
... | ... | @@ -36,6 +40,10 @@ |
| 36 | 40 |
* 현재 페이지 기준 표시 순번(= (page-1)*pageSize + 행 인덱스 + 1)이다. 개인정보 마스킹은 |
| 37 | 41 |
* 기획 시안에서 명시적으로 해제 지시가 있어 원본 값을 그대로 노출한다. |
| 38 | 42 |
* |
| 43 |
+ * 백엔드가 아직 주지 않는 항목(휴대전화·이메일·학교·학년/반/번호·보호자·가입일)은 열을 그대로 |
|
| 44 |
+ * 유지한 채 `-`로 표시한다 — 백엔드가 필드를 추가하면 Repository 매핑만 늘리면 이 파일은 |
|
| 45 |
+ * 그대로 값이 채워진다. |
|
| 46 |
+ * |
|
| 39 | 47 |
* "관리" 열은 행별 조회 팝업 트리거(StudentRowActions)에 위임한다 — 상호작용이 필요한 것은 |
| 40 | 48 |
* 그 셀뿐이라 이 테이블 자체는 Server Component로 유지하고 최말단만 클라이언트 경계로 뗀다 |
| 41 | 49 |
* (§2.4 "'use client'는 최말단에만"). |
... | ... | @@ -57,14 +65,16 @@ |
| 57 | 65 |
<TableCell>{member.memberCode}</TableCell>
|
| 58 | 66 |
<TableCell>{member.name}</TableCell>
|
| 59 | 67 |
<TableCell>{member.loginId}</TableCell>
|
| 60 |
- <TableCell>{member.phoneNumber}</TableCell>
|
|
| 61 |
- <TableCell>{member.email}</TableCell>
|
|
| 68 |
+ <TableCell>{formatOptionalValue(member.phoneNumber)}</TableCell>
|
|
| 69 |
+ <TableCell>{formatOptionalValue(member.email)}</TableCell>
|
|
| 62 | 70 |
<TableCell>{member.role}</TableCell>
|
| 63 |
- <TableCell>{member.schoolName}</TableCell>
|
|
| 64 |
- <TableCell>{`${member.grade}학년 ${member.classNumber}반 ${member.studentNumber}번`}</TableCell>
|
|
| 65 |
- <TableCell>{member.guardianName}</TableCell>
|
|
| 66 |
- <TableCell>{member.guardianPhoneNumber}</TableCell>
|
|
| 67 |
- <TableCell>{member.joinedAt}</TableCell>
|
|
| 71 |
+ <TableCell>{formatOptionalValue(member.schoolName)}</TableCell>
|
|
| 72 |
+ <TableCell>{formatGradeClassNumber(member)}</TableCell>
|
|
| 73 |
+ <TableCell>{formatOptionalValue(member.guardianName)}</TableCell>
|
|
| 74 |
+ <TableCell> |
|
| 75 |
+ {formatOptionalValue(member.guardianPhoneNumber)}
|
|
| 76 |
+ </TableCell> |
|
| 77 |
+ <TableCell>{formatOptionalValue(member.joinedAt)}</TableCell>
|
|
| 68 | 78 |
<TableCell> |
| 69 | 79 |
<StudentRowActions member={member} />
|
| 70 | 80 |
</TableCell> |
--- app/(protected)/(basic)/students/page.tsx
+++ app/(protected)/(basic)/students/page.tsx
... | ... | @@ -21,8 +21,14 @@ |
| 21 | 21 |
await verifySession(); |
| 22 | 22 |
|
| 23 | 23 |
const query = parseStudentMemberQuery(await searchParams); |
| 24 |
- const { items, totalCount } = await fetchStudentMembers(query);
|
|
| 25 |
- const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize)); |
|
| 24 |
+ const { items, totalCount, isTotalCountExact } =
|
|
| 25 |
+ await fetchStudentMembers(query); |
|
| 26 |
+ |
|
| 27 |
+ // 전체 건수가 확정되지 않았다면(백엔드가 count를 주지 않아 하한값만 아는 상태) 다음 페이지를 |
|
| 28 |
+ // 한 칸 열어 둔다 — 열어 두지 않으면 가득 찬 페이지 뒤의 데이터에 접근할 방법이 없어진다. |
|
| 29 |
+ const totalPages = isTotalCountExact |
|
| 30 |
+ ? Math.max(1, Math.ceil(totalCount / query.pageSize)) |
|
| 31 |
+ : query.page + 1; |
|
| 26 | 32 |
const currentPage = Math.min(query.page, totalPages); |
| 27 | 33 |
|
| 28 | 34 |
return ( |
... | ... | @@ -34,7 +40,9 @@ |
| 34 | 40 |
<StudentListToolbar query={query} />
|
| 35 | 41 |
|
| 36 | 42 |
<p className="text-body-md text-foreground-muted"> |
| 37 |
- 총 {totalCount}명 | 현재페이지 {currentPage}/{totalPages}
|
|
| 43 |
+ 총 {totalCount}명{isTotalCountExact ? '' : ' 이상'} | 현재페이지{' '}
|
|
| 44 |
+ {currentPage}
|
|
| 45 |
+ {isTotalCountExact ? `/${totalPages}` : ''}
|
|
| 38 | 46 |
</p> |
| 39 | 47 |
|
| 40 | 48 |
{items.length === 0 ? (
|
... | ... | @@ -53,7 +61,14 @@ |
| 53 | 61 |
</Alert> |
| 54 | 62 |
) : ( |
| 55 | 63 |
<> |
| 56 |
- <StudentTable items={items} page={query.page} pageSize={query.pageSize} />
|
|
| 64 |
+ {/* 표의 순번 기준은 요청 페이지(query.page)가 아니라 화면이 실제로 보여주는
|
|
| 65 |
+ 페이지(currentPage)다 — 백엔드가 범위를 벗어난 페이지 요청을 clamp해서 응답하면 |
|
| 66 |
+ 둘이 어긋나 "현재페이지 1/1"인데 순번은 31부터 시작하는 상태가 된다. */} |
|
| 67 |
+ <StudentTable |
|
| 68 |
+ items={items}
|
|
| 69 |
+ page={currentPage}
|
|
| 70 |
+ pageSize={query.pageSize}
|
|
| 71 |
+ /> |
|
| 57 | 72 |
<Pagination |
| 58 | 73 |
currentPage={currentPage}
|
| 59 | 74 |
totalPages={totalPages}
|
+++ lib/data/api-client.ts
... | ... | @@ -0,0 +1,175 @@ |
| 1 | +import 'server-only'; | |
| 2 | +import { getApiBaseUrl, getDevApiAccessToken } from '@/lib/env'; | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * 백엔드(edupay-backend) 통신 규약의 단일 지점 (설계서 §4 `server/api-client`). | |
| 6 | + * | |
| 7 | + * 이 파일이 아는 것은 "백엔드와 말하는 법"뿐이다 — URL 조립, 인증 헤더 부착, 공통 응답 봉투 | |
| 8 | + * 해석, 실패 정규화. 어떤 화면이 무엇을 조회하는지(엔드포인트·파라미터·필드 매핑)는 각 | |
| 9 | + * Repository의 책임이라 여기로 들어오지 않는다. 반환 타입이 `unknown`인 것도 그 때문이다 — | |
| 10 | + * 응답 shape 검증은 그 shape을 아는 Repository가 한다. | |
| 11 | + * | |
| 12 | + * 브라우저는 백엔드를 직접 호출하지 않는다(설계서 §7 BFF 전제). `server-only`로 클라이언트 | |
| 13 | + * 번들 유입 시 빌드가 실패하게 막아 이 전제를 도구로 강제한다. | |
| 14 | + */ | |
| 15 | + | |
| 16 | +/** 백엔드 공통 응답 봉투. 성공·실패 모두 이 형태로 온다. */ | |
| 17 | +type ApiEnvelope = { | |
| 18 | + success?: unknown; | |
| 19 | + code?: unknown; | |
| 20 | + message?: unknown; | |
| 21 | + data?: unknown; | |
| 22 | +}; | |
| 23 | + | |
| 24 | +export type ApiErrorKind = | |
| 25 | + /** 401 — 토큰이 없거나 만료·무효다. */ | |
| 26 | + | 'unauthorized' | |
| 27 | + /** 백엔드가 입력값을 거절했다(예: code 900 입력값 무결성 오류). */ | |
| 28 | + | 'validation' | |
| 29 | + /** 백엔드가 그 외 실패를 반환했다. */ | |
| 30 | + | 'server' | |
| 31 | + /** 백엔드에 닿지 못했다(DNS·타임아웃·TLS 등). */ | |
| 32 | + | 'network' | |
| 33 | + /** 닿았지만 약속된 형태가 아니다(JSON 파싱 실패, 필드 누락 등). */ | |
| 34 | + | 'contract'; | |
| 35 | + | |
| 36 | +/** | |
| 37 | + * 백엔드 호출 실패의 정규화된 표현 (설계서 §8.1 6층 "실패 안전"). | |
| 38 | + * 화면에는 이 메시지를 그대로 내보내지 않는다 — 호출부가 일반화된 문구로 바꿔 보여준다. | |
| 39 | + */ | |
| 40 | +export class ApiError extends Error { | |
| 41 | + readonly kind: ApiErrorKind; | |
| 42 | + /** 백엔드가 준 code. HTTP 레벨에서 끊긴 경우엔 HTTP status, 그마저 없으면 null. */ | |
| 43 | + readonly code: number | null; | |
| 44 | + | |
| 45 | + constructor( | |
| 46 | + kind: ApiErrorKind, | |
| 47 | + message: string, | |
| 48 | + code: number | null = null, | |
| 49 | + options?: { cause?: unknown } | |
| 50 | + ) { | |
| 51 | + super(message, options); | |
| 52 | + this.name = 'ApiError'; | |
| 53 | + this.kind = kind; | |
| 54 | + this.code = code; | |
| 55 | + } | |
| 56 | +} | |
| 57 | + | |
| 58 | +/** | |
| 59 | + * 요청에 붙일 백엔드 accessToken을 얻는다 — **인증 연동의 유일한 이식 지점**이다. | |
| 60 | + * | |
| 61 | + * 현재 세션 토큰(`lib/auth/session-token.ts`)은 mock 관리자의 `adminId`만 담고 있어 부착할 | |
| 62 | + * 백엔드 토큰이 없다. 관리자 로그인 연동(별도 작업)이 들어오면 이 함수 하나만 | |
| 63 | + * "세션에서 accessToken을 읽어 반환"하도록 바꾸면 되고, 개별 Repository는 손대지 않는다. | |
| 64 | + * 그때 개발용 env 폴백(`getDevApiAccessToken`)은 함께 제거한다. | |
| 65 | + */ | |
| 66 | +async function resolveAccessToken(): Promise<string | null> { | |
| 67 | + return getDevApiAccessToken(); | |
| 68 | +} | |
| 69 | + | |
| 70 | +/** base URL과 경로를 합친다. base 끝의 `/`·경로 앞의 `/` 유무와 무관하게 같은 URL을 만든다. */ | |
| 71 | +function buildRequestUrl( | |
| 72 | + path: string, | |
| 73 | + params: Record<string, string | number> | |
| 74 | +): string { | |
| 75 | + const base = getApiBaseUrl().replace(/\/+$/, ''); | |
| 76 | + const normalizedPath = path.startsWith('/') ? path : `/${path}`; | |
| 77 | + const url = new URL(`${base}${normalizedPath}`); | |
| 78 | + | |
| 79 | + for (const [key, value] of Object.entries(params)) { | |
| 80 | + url.searchParams.set(key, String(value)); | |
| 81 | + } | |
| 82 | + | |
| 83 | + return url.toString(); | |
| 84 | +} | |
| 85 | + | |
| 86 | +/** 실패 응답에서 code/message를 최대한 건져낸다 — 형태가 깨져 있어도 throw하지 않는다. */ | |
| 87 | +function readEnvelopeCode(envelope: ApiEnvelope): number | null { | |
| 88 | + return typeof envelope.code === 'number' ? envelope.code : null; | |
| 89 | +} | |
| 90 | + | |
| 91 | +function readEnvelopeMessage(envelope: ApiEnvelope, fallback: string): string { | |
| 92 | + return typeof envelope.message === 'string' && envelope.message | |
| 93 | + ? envelope.message | |
| 94 | + : fallback; | |
| 95 | +} | |
| 96 | + | |
| 97 | +function classifyFailure(code: number | null): ApiErrorKind { | |
| 98 | + if (code === 401 || code === 403) { | |
| 99 | + return 'unauthorized'; | |
| 100 | + } | |
| 101 | + // 900 = 입력값 무결성 오류(백엔드 통지 계약). | |
| 102 | + if (code === 900) { | |
| 103 | + return 'validation'; | |
| 104 | + } | |
| 105 | + return 'server'; | |
| 106 | +} | |
| 107 | + | |
| 108 | +async function readEnvelope(response: Response): Promise<ApiEnvelope> { | |
| 109 | + try { | |
| 110 | + const parsed: unknown = await response.json(); | |
| 111 | + return parsed !== null && typeof parsed === 'object' | |
| 112 | + ? (parsed as ApiEnvelope) | |
| 113 | + : {}; | |
| 114 | + } catch { | |
| 115 | + return {}; | |
| 116 | + } | |
| 117 | +} | |
| 118 | + | |
| 119 | +/** | |
| 120 | + * 백엔드 GET 조회. 성공 시 봉투의 `data`만 돌려주고, 실패는 전부 `ApiError`로 정규화한다. | |
| 121 | + * | |
| 122 | + * 캐시: `no-store` 고정. 어드민은 데이터 신선도가 우선이고(설계서 §7.1), 조회 대상이 | |
| 123 | + * 개인정보이며 검색 조건이 매 요청 달라져 재사용 캐시를 둘 이유가 없다. | |
| 124 | + * | |
| 125 | + * 성공 판정에 `success` 플래그를 단독으로 믿지 않는다 — 실측 결과 401 응답이 | |
| 126 | + * `{"success":true,"auth":false,"code":401,...}`로 와서 `success`만 보면 실패를 성공으로 | |
| 127 | + * 읽는다. HTTP 상태와 `code`를 함께 확인한다. | |
| 128 | + */ | |
| 129 | +export async function apiGet( | |
| 130 | + path: string, | |
| 131 | + params: Record<string, string | number> = {} | |
| 132 | +): Promise<unknown> { | |
| 133 | + const url = buildRequestUrl(path, params); | |
| 134 | + const accessToken = await resolveAccessToken(); | |
| 135 | + | |
| 136 | + let response: Response; | |
| 137 | + try { | |
| 138 | + response = await fetch(url, { | |
| 139 | + method: 'GET', | |
| 140 | + headers: { | |
| 141 | + Accept: 'application/json', | |
| 142 | + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), | |
| 143 | + }, | |
| 144 | + cache: 'no-store', | |
| 145 | + }); | |
| 146 | + } catch (cause) { | |
| 147 | + throw new ApiError( | |
| 148 | + 'network', | |
| 149 | + `백엔드에 연결하지 못했습니다: ${path}`, | |
| 150 | + null, | |
| 151 | + { cause } | |
| 152 | + ); | |
| 153 | + } | |
| 154 | + | |
| 155 | + const envelope = await readEnvelope(response); | |
| 156 | + const code = readEnvelopeCode(envelope) ?? response.status; | |
| 157 | + | |
| 158 | + if (!response.ok) { | |
| 159 | + throw new ApiError( | |
| 160 | + classifyFailure(code), | |
| 161 | + readEnvelopeMessage(envelope, `백엔드 응답 실패(HTTP ${response.status})`), | |
| 162 | + code | |
| 163 | + ); | |
| 164 | + } | |
| 165 | + | |
| 166 | + if (envelope.success !== true || code !== 200) { | |
| 167 | + throw new ApiError( | |
| 168 | + classifyFailure(code), | |
| 169 | + readEnvelopeMessage(envelope, '백엔드 응답 실패'), | |
| 170 | + code | |
| 171 | + ); | |
| 172 | + } | |
| 173 | + | |
| 174 | + return envelope.data; | |
| 175 | +} |
--- lib/data/repositories/student-member-repository.ts
+++ lib/data/repositories/student-member-repository.ts
... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 |
import 'server-only'; |
| 2 |
+import { ApiError, apiGet } from '@/lib/data/api-client';
|
|
| 2 | 3 |
import type { StudentMember } from '@/lib/domain/student-member';
|
| 3 | 4 |
import type {
|
| 4 | 5 |
StudentMemberQuery, |
... | ... | @@ -6,208 +7,193 @@ |
| 6 | 7 |
} from '@/lib/domain/student-member-query'; |
| 7 | 8 |
|
| 8 | 9 |
/** |
| 9 |
- * mock 학생 회원 Repository. |
|
| 10 |
+ * 학생 회원 Repository — 이 도메인을 백엔드에서 "어떻게 조회하는지"만 안다(엔드포인트·파라미터· |
|
| 11 |
+ * 응답 매핑). 백엔드와 말하는 공통 규약(URL·인증 헤더·응답 봉투·에러 정규화)은 |
|
| 12 |
+ * `lib/data/api-client.ts`가 소유하므로 여기에 들어오지 않는다. |
|
| 10 | 13 |
* |
| 11 |
- * 데이터 출처는 외부 시스템 「알콩」이며, 백엔드(edupay-backend)에 학생 회원 조회 API가 아직 |
|
| 12 |
- * 없어 mock으로 구현한다. 공개 시그니처(도메인 타입만 주고받음)는 백엔드 연동 후에도 유지한다 — |
|
| 13 |
- * 연동 시점에는 이 파일의 내부 구현만 실제 API 호출로 교체하고, 각 함수에 캐시 전략 |
|
| 14 |
- * (`fetchStudentMembers`/`fetchStudentMemberById`는 `cache: 'no-store'` — 개인정보를 담은 |
|
| 15 |
- * 목록이고 검색 조건이 매 요청 달라지며 사용여부가 자주 바뀌므로 재사용 캐시를 두지 않는다)을 |
|
| 16 |
- * 명시적으로 추가해야 한다. 지금은 실제 `fetch()` 호출이 없어(in-memory mock) 옵션을 걸 대상이 |
|
| 17 |
- * 없다는 점에 유의 — 위 주석은 연동 시점에 적용할 의도를 남겨두는 것이다. |
|
| 14 |
+ * GET /api/v1/mngr/user/pagination (ROLE_ADMIN 전용) |
|
| 15 |
+ * → data: { list: [{ rnum, userId, loginId, userNm }], page, size, totalCount, totalPages }
|
|
| 18 | 16 |
* |
| 19 |
- * mock 데이터는 결정적(deterministic)으로 생성한다 — `Math.random()`을 쓰지 않고 인덱스 기반 |
|
| 20 |
- * 순환으로 이름·학교·학년 등을 만들어 매 요청 동일한 128건을 반환한다. 모듈이 처음 로드될 때 |
|
| 21 |
- * 한 번만 생성해(module-level 배열) 이후 요청 간 같은 데이터를 유지하고, |
|
| 22 |
- * `updateStudentMemberActiveStatus`가 반영한 변경도 프로세스 생존 동안 유지된다(재시작 시 |
|
| 23 |
- * 초기화 — 실제 영속 저장소가 아님에 유의). |
|
| 17 |
+ * 아래 내용은 백엔드 저장소(edupay-backend, develop b742bb4)의 실제 구현을 읽고 확인한 것이다 |
|
| 18 |
+ * — MngrUserApiController / MngrUserServiceImpl / PaginationUtil / MngrUserMapper.xml. |
|
| 19 |
+ * |
|
| 20 |
+ * - **응답이 담는 값은 식별자·이름 계열 넷뿐이다.** 조회 SQL 자체는 USER_TELNO·USER_EML_ADDR· |
|
| 21 |
+ * SCH_NM·GRADE·CLS_NO·BIRTH까지 이미 select하지만 응답 VO(MngrUserVo)가 4개 필드만 노출해 |
|
| 22 |
+ * 나머지가 버려진다. 그래서 화면의 나머지 항목은 `null` → `-`다. **백엔드가 VO에 필드를 |
|
| 23 |
+ * 추가하면** `toStudentMember`의 매핑만 늘리면 되고 화면은 손대지 않는다. |
|
| 24 |
+ * - **정렬은 고정이다** — `ORDER BY rnum DESC`(rnum은 최초등록일시 기준 행번호)라 사실상 |
|
| 25 |
+ * 가입일 최신순이고, 정렬 파라미터는 없다. |
|
| 26 |
+ * - **사용자 유형을 걸러 주지 않는다** — 조회 대상이 TB_COM_USER 전체라 student 외 |
|
| 27 |
+ * teacher·manager도 섞일 수 있고, 응답에 USER_TYPE도 없어 구분할 방법이 없다. |
|
| 28 |
+ * 화면이 역할을 '학생'으로 표기하는 것은 이 화면의 전제일 뿐 백엔드가 보장하는 값이 아니다. |
|
| 29 |
+ * |
|
| 30 |
+ * 캐시: `apiGet`이 `no-store`를 고정한다 — 개인정보 목록이고 검색 조건이 매 요청 다르다. |
|
| 24 | 31 |
*/ |
| 25 | 32 |
|
| 26 |
-const TOTAL_MOCK_COUNT = 128; |
|
| 27 |
-const MEMBER_CODE_PREFIX = 'ST'; |
|
| 28 |
- |
|
| 29 |
-const SURNAMES = ['김', '이', '박', '최', '정', '강', '조', '윤', '장', '임']; |
|
| 30 |
-const GIVEN_NAMES = [ |
|
| 31 |
- '민준', |
|
| 32 |
- '서연', |
|
| 33 |
- '도윤', |
|
| 34 |
- '지우', |
|
| 35 |
- '하은', |
|
| 36 |
- '주원', |
|
| 37 |
- '수아', |
|
| 38 |
- '지호', |
|
| 39 |
- '예은', |
|
| 40 |
- '건우', |
|
| 41 |
- '서윤', |
|
| 42 |
- '연우', |
|
| 43 |
- '다은', |
|
| 44 |
-]; |
|
| 45 |
-const GUARDIAN_GIVEN_NAMES = ['영수', '순자', '동현', '미경', '재훈', '은영']; |
|
| 46 |
- |
|
| 47 |
-const SCHOOL_NAMES = [ |
|
| 48 |
- '한빛초등학교', |
|
| 49 |
- '늘푸른초등학교', |
|
| 50 |
- '서울중학교', |
|
| 51 |
- '대한중학교', |
|
| 52 |
- '한강고등학교', |
|
| 53 |
- '동산고등학교', |
|
| 54 |
- '중앙초등학교', |
|
| 55 |
- '푸른중학교', |
|
| 56 |
-]; |
|
| 57 |
- |
|
| 58 |
-/** 모듈 로드 시점에 1회만 고정한다 — 이후 모든 날짜 계산이 이 값 기준 오프셋이라 같은 |
|
| 59 |
- * 프로세스에서는 매 요청 동일한 결과를 반환한다(Math.random 없이 결정적). */ |
|
| 60 |
-const GENERATED_AT = new Date(); |
|
| 61 |
- |
|
| 62 |
-function formatDate(date: Date): string {
|
|
| 63 |
- const year = date.getFullYear(); |
|
| 64 |
- const month = String(date.getMonth() + 1).padStart(2, '0'); |
|
| 65 |
- const day = String(date.getDate()).padStart(2, '0'); |
|
| 66 |
- return `${year}-${month}-${day}`;
|
|
| 67 |
-} |
|
| 68 |
- |
|
| 69 |
-function subtractDays(base: Date, days: number): Date {
|
|
| 70 |
- const result = new Date(base); |
|
| 71 |
- result.setDate(result.getDate() - days); |
|
| 72 |
- return result; |
|
| 73 |
-} |
|
| 74 |
- |
|
| 75 |
-function padDigits(value: number, length: number): string {
|
|
| 76 |
- const max = 10 ** length; |
|
| 77 |
- const normalized = ((value % max) + max) % max; |
|
| 78 |
- return String(normalized).padStart(length, '0'); |
|
| 79 |
-} |
|
| 80 |
- |
|
| 81 |
-function buildPhoneNumber(seed: number): string {
|
|
| 82 |
- const middle = padDigits(1000 + seed * 137, 4); |
|
| 83 |
- const last = padDigits(2000 + seed * 271, 4); |
|
| 84 |
- return `010-${middle}-${last}`;
|
|
| 85 |
-} |
|
| 33 |
+const STUDENT_MEMBER_PAGINATION_PATH = '/api/v1/mngr/user/pagination'; |
|
| 86 | 34 |
|
| 87 | 35 |
/** |
| 88 |
- * 인덱스 하나로부터 학생 회원 1건을 결정적으로 만든다. 회원코드는 ST00001~ST00128을 |
|
| 89 |
- * 생성 순서(index) 그대로 부여하고, 가입일은 "최근 날짜부터 역순"(index가 커질수록 과거)으로 |
|
| 90 |
- * 채운다 — 즉 index 0(ST00001)이 가장 최근 가입자, index 127(ST00128)이 가장 오래된 |
|
| 91 |
- * 가입자다. |
|
| 36 |
+ * 화면의 "검색 대상" → 백엔드 `searchCondition` 값 매핑. 값은 MngrUserMapper.xml의 |
|
| 37 |
+ * `<choose>` 분기에서 그대로 가져온 것이다(1=USER_NM, 2=LOGIN_ID, 3=USER_TELNO). |
|
| 38 |
+ * |
|
| 39 |
+ * 목록에 없는 값을 보내면 백엔드가 **검색 조건 없이 전체를 반환**하므로(분기 미매칭 시 |
|
| 40 |
+ * WHERE가 비는 구조) 화면의 검색 대상 선택지도 이 세 가지로 맞춰 두었다. |
|
| 92 | 41 |
*/ |
| 93 |
-function buildStudentMember(index: number): StudentMember {
|
|
| 94 |
- const sequenceNumber = index + 1; |
|
| 95 |
- const memberCode = `${MEMBER_CODE_PREFIX}${padDigits(sequenceNumber, 5)}`;
|
|
| 42 |
+const SEARCH_CONDITION_BY_FIELD: Record<StudentMemberSearchField, string> = {
|
|
| 43 |
+ name: '1', |
|
| 44 |
+ loginId: '2', |
|
| 45 |
+ phoneNumber: '3', |
|
| 46 |
+}; |
|
| 96 | 47 |
|
| 97 |
- // SURNAMES.length(10)와 GIVEN_NAMES.length(13)는 서로소라 둘 다 index를 그대로 |
|
| 98 |
- // 모듈러로 써도 (성, 이름) 쌍이 lcm(10,13)=130번째(=128건 범위 밖)에야 반복된다 — |
|
| 99 |
- // floor(index / 10)처럼 나눗셈으로 이름 축을 늦게 회전시키면 성이 한 바퀴(10명) 도는 |
|
| 100 |
- // 동안 이름이 고정돼 "김민준·이민준·박민준..." 식으로 화면 첫 페이지가 온통 같은 |
|
| 101 |
- // given name으로 보이는 부자연스러운 반복이 생긴다(실제로 확인됨) — 그래서 두 축 |
|
| 102 |
- // 모두 index를 직접 쓴다. |
|
| 103 |
- const surname = SURNAMES[index % SURNAMES.length]; |
|
| 104 |
- const givenName = GIVEN_NAMES[index % GIVEN_NAMES.length]; |
|
| 105 |
- const name = `${surname}${givenName}`;
|
|
| 48 |
+/** |
|
| 49 |
+ * 역할 표기. 백엔드 응답에 USER_TYPE이 없어 이 화면(학생 회원 목록)의 전제를 그대로 적는 상수다 |
|
| 50 |
+ * — 백엔드가 사용자 유형으로 필터링하지도, 유형을 내려주지도 않으므로 **보장된 값이 아니다.** |
|
| 51 |
+ * 백엔드가 userType을 노출하면 그 값을 매핑해 이 상수를 없애야 한다. |
|
| 52 |
+ */ |
|
| 53 |
+const STUDENT_ROLE_LABEL = '학생'; |
|
| 106 | 54 |
|
| 107 |
- const loginId = `stu${padDigits(sequenceNumber, 4)}`;
|
|
| 108 |
- const grade = (index % 6) + 1; |
|
| 109 |
- const classNumber = (index % 10) + 1; |
|
| 110 |
- const studentNumber = (index % 30) + 1; |
|
| 111 |
- |
|
| 112 |
- const guardianGivenName = |
|
| 113 |
- GUARDIAN_GIVEN_NAMES[index % GUARDIAN_GIVEN_NAMES.length]; |
|
| 114 |
- |
|
| 115 |
- const joinedAt = formatDate(subtractDays(GENERATED_AT, index)); |
|
| 116 |
- const birthYear = GENERATED_AT.getFullYear() - (grade + 6); |
|
| 117 |
- const birthDate = `${birthYear}-${padDigits((index % 12) + 1, 2)}-${padDigits(
|
|
| 118 |
- (index % 28) + 1, |
|
| 119 |
- 2 |
|
| 120 |
- )}`; |
|
| 121 |
- |
|
| 55 |
+/** |
|
| 56 |
+ * 페이징 파라미터. 계약 예시에는 7종이 나열돼 있지만 **실제로 쓰이는 것은 두 개뿐**이다. |
|
| 57 |
+ * |
|
| 58 |
+ * `MngrUserServiceImpl`이 `PaginationUtil.execute(pageIndex, recordCountPerPage, ...)`로 |
|
| 59 |
+ * offset을 직접 계산해 `firstIndex`·`recordCountPerPage`를 덮어쓰고, SQL은 그 두 값으로만 |
|
| 60 |
+ * `LIMIT/OFFSET`을 건다. 클라이언트가 보낸 `firstIndex`·`lastIndex`·`pageUnit`·`pageSize`는 |
|
| 61 |
+ * 읽히지 않으므로 보내지 않는다 — 보내면 "이 값이 결과에 영향을 준다"는 오해만 남는다. |
|
| 62 |
+ */ |
|
| 63 |
+function buildPaginationParams( |
|
| 64 |
+ query: StudentMemberQuery |
|
| 65 |
+): Record<string, string | number> {
|
|
| 122 | 66 |
return {
|
| 123 |
- id: memberCode, |
|
| 124 |
- memberCode, |
|
| 125 |
- name, |
|
| 126 |
- loginId, |
|
| 127 |
- phoneNumber: buildPhoneNumber(index), |
|
| 128 |
- email: `${loginId}@example.com`,
|
|
| 129 |
- role: '학생', |
|
| 130 |
- schoolName: SCHOOL_NAMES[index % SCHOOL_NAMES.length], |
|
| 131 |
- grade, |
|
| 132 |
- classNumber, |
|
| 133 |
- studentNumber, |
|
| 134 |
- guardianName: `${surname}${guardianGivenName}`,
|
|
| 135 |
- guardianPhoneNumber: buildPhoneNumber(index + 500), |
|
| 136 |
- joinedAt, |
|
| 137 |
- birthDate, |
|
| 138 |
- // 11명 중 1명 꼴로 비활성 — 조회 팝업의 사용여부 라디오가 실제로 두 상태 모두를 |
|
| 139 |
- // 반영하는지 검증할 수 있도록 결정적으로 소수를 비활성화한다. |
|
| 140 |
- isActive: index % 11 !== 0, |
|
| 67 |
+ pageIndex: query.page, |
|
| 68 |
+ recordCountPerPage: query.pageSize, |
|
| 141 | 69 |
}; |
| 142 | 70 |
} |
| 143 | 71 |
|
| 144 |
-let mockStudentMembers: StudentMember[] = Array.from( |
|
| 145 |
- { length: TOTAL_MOCK_COUNT },
|
|
| 146 |
- (_, index) => buildStudentMember(index) |
|
| 147 |
-); |
|
| 72 |
+/** |
|
| 73 |
+ * 검색 파라미터. 검색어가 비어 있으면 조건도 함께 빈 값으로 보낸다 — 백엔드도 `searchKeyword`가 |
|
| 74 |
+ * 비면 조건 분기 자체를 타지 않으므로(전체 조회) 결과는 같고, 의미 없는 조건을 실어 보내지 않는다. |
|
| 75 |
+ */ |
|
| 76 |
+function buildSearchParams( |
|
| 77 |
+ query: StudentMemberQuery |
|
| 78 |
+): Record<string, string> {
|
|
| 79 |
+ const keyword = query.keyword.trim(); |
|
| 148 | 80 |
|
| 149 |
-function matchesKeyword( |
|
| 150 |
- member: StudentMember, |
|
| 151 |
- field: StudentMemberSearchField, |
|
| 152 |
- keyword: string |
|
| 153 |
-): boolean {
|
|
| 154 |
- return member[field].toLowerCase().includes(keyword); |
|
| 81 |
+ if (!keyword) {
|
|
| 82 |
+ return { searchCondition: '', searchKeyword: '' };
|
|
| 83 |
+ } |
|
| 84 |
+ |
|
| 85 |
+ return {
|
|
| 86 |
+ searchCondition: SEARCH_CONDITION_BY_FIELD[query.searchField], |
|
| 87 |
+ searchKeyword: keyword, |
|
| 88 |
+ }; |
|
| 89 |
+} |
|
| 90 |
+ |
|
| 91 |
+function isRecord(value: unknown): value is Record<string, unknown> {
|
|
| 92 |
+ return value !== null && typeof value === 'object'; |
|
| 93 |
+} |
|
| 94 |
+ |
|
| 95 |
+function readRequiredString( |
|
| 96 |
+ source: Record<string, unknown>, |
|
| 97 |
+ key: string |
|
| 98 |
+): string {
|
|
| 99 |
+ const value = source[key]; |
|
| 100 |
+ if (typeof value !== 'string' || value.length === 0) {
|
|
| 101 |
+ throw new ApiError( |
|
| 102 |
+ 'contract', |
|
| 103 |
+ `학생 회원 응답에 ${key}가 없습니다.`,
|
|
| 104 |
+ null |
|
| 105 |
+ ); |
|
| 106 |
+ } |
|
| 107 |
+ return value; |
|
| 155 | 108 |
} |
| 156 | 109 |
|
| 157 | 110 |
/** |
| 158 |
- * 검색·정렬·페이징이 적용된 학생 회원 목록을 조회한다. |
|
| 159 |
- * 캐시 전략: `no-store` 상당 — searchParams 기반이라 조건이 매 요청 달라지고 개인정보를 |
|
| 160 |
- * 포함하므로 재사용 캐시를 두지 않는다. |
|
| 111 |
+ * 백엔드 응답 1건 → 도메인 타입. 백엔드가 주지 않는 항목은 `null`로 둔다(설계서 §8.1 5층 — |
|
| 112 |
+ * 원본 응답을 그대로 흘리지 않고 화면에 필요한 필드만 골라 담는다). |
|
| 113 |
+ * |
|
| 114 |
+ * `rnum`은 담지 않는다 — 표의 "번호"는 백엔드의 행 번호가 아니라 현재 페이지 기준 표시 순번 |
|
| 115 |
+ * (`(page-1)*pageSize + 행 인덱스 + 1`)이고, 그 계산은 이미 표 컴포넌트가 한다. |
|
| 116 |
+ * |
|
| 117 |
+ * 식별자·이름 세 필드는 없으면 예외로 끊는다(fail-fast) — 목록의 존재 이유인 값이라 |
|
| 118 |
+ * 빈 화면을 조용히 보여주는 것보다 계약 위반을 즉시 드러내는 편이 낫다. |
|
| 119 |
+ */ |
|
| 120 |
+function toStudentMember(raw: unknown): StudentMember {
|
|
| 121 |
+ if (!isRecord(raw)) {
|
|
| 122 |
+ throw new ApiError('contract', '학생 회원 응답 항목의 형식이 올바르지 않습니다.');
|
|
| 123 |
+ } |
|
| 124 |
+ |
|
| 125 |
+ const userId = readRequiredString(raw, 'userId'); |
|
| 126 |
+ |
|
| 127 |
+ return {
|
|
| 128 |
+ id: userId, |
|
| 129 |
+ // 백엔드에 회원코드 전용 필드가 없어 식별자를 그대로 노출한다 — 별도 코드가 생기면 교체한다. |
|
| 130 |
+ memberCode: userId, |
|
| 131 |
+ name: readRequiredString(raw, 'userNm'), |
|
| 132 |
+ loginId: readRequiredString(raw, 'loginId'), |
|
| 133 |
+ phoneNumber: null, |
|
| 134 |
+ email: null, |
|
| 135 |
+ role: STUDENT_ROLE_LABEL, |
|
| 136 |
+ schoolName: null, |
|
| 137 |
+ grade: null, |
|
| 138 |
+ classNumber: null, |
|
| 139 |
+ studentNumber: null, |
|
| 140 |
+ guardianName: null, |
|
| 141 |
+ guardianPhoneNumber: null, |
|
| 142 |
+ joinedAt: null, |
|
| 143 |
+ birthDate: null, |
|
| 144 |
+ isActive: null, |
|
| 145 |
+ }; |
|
| 146 |
+} |
|
| 147 |
+ |
|
| 148 |
+export type StudentMemberPage = {
|
|
| 149 |
+ items: StudentMember[]; |
|
| 150 |
+ /** 전체 건수. `isTotalCountExact`가 false면 "적어도 이만큼"이라는 하한값이다. */ |
|
| 151 |
+ totalCount: number; |
|
| 152 |
+ /** 위 값이 확정된 전체 건수인지. false면 화면이 "N명 이상"으로 표기하고 다음 페이지를 열어 둔다. */ |
|
| 153 |
+ isTotalCountExact: boolean; |
|
| 154 |
+}; |
|
| 155 |
+ |
|
| 156 |
+/** |
|
| 157 |
+ * 검색·페이징이 적용된 학생 회원 목록을 조회한다. |
|
| 158 |
+ * |
|
| 159 |
+ * 정렬(`query.sort`)은 백엔드에 정렬 파라미터가 없어 전달하지 않는다 — 순서는 백엔드가 |
|
| 160 |
+ * `ORDER BY rnum DESC`로 고정(사실상 가입일 최신순)하며, 화면의 정렬 select도 그래서 비활성이다. |
|
| 161 |
+ * |
|
| 162 |
+ * **응답의 `totalCount`는 전체 건수가 아니라 "그 페이지에 담긴 행 수"다.** 백엔드에 count 쿼리가 |
|
| 163 |
+ * 없어 `PaginationUtil`이 `list.size()`를 그대로 총건수로 쓰기 때문이다(edupay-backend |
|
| 164 |
+ * `PaginationUtil.execute`). 그 값을 그대로 믿으면 한 페이지가 가득 찰 때마다 `totalPages`가 1로 |
|
| 165 |
+ * 계산돼 **2페이지 이후 데이터에 영원히 접근할 수 없다.** 그래서 여기서 하한값으로 보정한다: |
|
| 166 |
+ * |
|
| 167 |
+ * - 페이지가 가득 차지 않았다 → 마지막 페이지다 → 전체 건수 = offset + 받은 행 수 (확정). |
|
| 168 |
+ * - 가득 찼다 → 더 있을 수 있다 → 하한값만 알리고(`isTotalCountExact: false`) 화면이 다음 |
|
| 169 |
+ * 페이지를 열어 두게 한다. |
|
| 170 |
+ * |
|
| 171 |
+ * 백엔드가 진짜 count 쿼리를 넣으면 `totalCount`가 offset+행 수보다 커지므로 `Math.max`가 |
|
| 172 |
+ * 자동으로 그 값을 채택하고 확정으로 표기된다 — 이 함수를 다시 고칠 필요가 없다. |
|
| 161 | 173 |
*/ |
| 162 | 174 |
export async function fetchStudentMembers( |
| 163 | 175 |
query: StudentMemberQuery |
| 164 |
-): Promise<{ items: StudentMember[]; totalCount: number }> {
|
|
| 165 |
- const keyword = query.keyword.trim().toLowerCase(); |
|
| 176 |
+): Promise<StudentMemberPage> {
|
|
| 177 |
+ const data = await apiGet(STUDENT_MEMBER_PAGINATION_PATH, {
|
|
| 178 |
+ ...buildSearchParams(query), |
|
| 179 |
+ ...buildPaginationParams(query), |
|
| 180 |
+ }); |
|
| 166 | 181 |
|
| 167 |
- const filtered = keyword |
|
| 168 |
- ? mockStudentMembers.filter((member) => |
|
| 169 |
- matchesKeyword(member, query.searchField, keyword) |
|
| 170 |
- ) |
|
| 171 |
- : mockStudentMembers; |
|
| 172 |
- |
|
| 173 |
- const sorted = [...filtered].sort((a, b) => |
|
| 174 |
- query.sort === 'name' |
|
| 175 |
- ? a.name.localeCompare(b.name, 'ko') |
|
| 176 |
- : b.joinedAt.localeCompare(a.joinedAt) |
|
| 177 |
- ); |
|
| 178 |
- |
|
| 179 |
- const totalCount = sorted.length; |
|
| 180 |
- const start = (query.page - 1) * query.pageSize; |
|
| 181 |
- const items = sorted.slice(start, start + query.pageSize); |
|
| 182 |
- |
|
| 183 |
- return { items, totalCount };
|
|
| 184 |
-} |
|
| 185 |
- |
|
| 186 |
-/** |
|
| 187 |
- * 단건 조회 — Server Action의 입력 검증(존재 여부 확인)에 사용한다. |
|
| 188 |
- * 캐시 전략: `no-store` 상당(위와 동일한 이유). |
|
| 189 |
- */ |
|
| 190 |
-export async function fetchStudentMemberById( |
|
| 191 |
- id: string |
|
| 192 |
-): Promise<StudentMember | null> {
|
|
| 193 |
- return mockStudentMembers.find((member) => member.id === id) ?? null; |
|
| 194 |
-} |
|
| 195 |
- |
|
| 196 |
-/** |
|
| 197 |
- * 사용여부만 갱신한다(조회 팝업에서 유일하게 수정 가능한 필드). mock 한정 — 모듈 레벨 배열을 |
|
| 198 |
- * 불변 갱신(map으로 새 배열 생성)하고 프로세스 생존 동안 유지한다. 쓰기 함수라 캐시 대상이 |
|
| 199 |
- * 아니다 — 호출부(Server Action)가 `revalidatePath('/students')`로 목록 화면을 재검증한다.
|
|
| 200 |
- */ |
|
| 201 |
-export async function updateStudentMemberActiveStatus( |
|
| 202 |
- id: string, |
|
| 203 |
- isActive: boolean |
|
| 204 |
-): Promise<void> {
|
|
| 205 |
- const exists = mockStudentMembers.some((member) => member.id === id); |
|
| 206 |
- if (!exists) {
|
|
| 207 |
- throw new Error(`존재하지 않는 학생 회원입니다: ${id}`);
|
|
| 182 |
+ if (!isRecord(data) || !Array.isArray(data.list)) {
|
|
| 183 |
+ throw new ApiError('contract', '학생 회원 목록 응답의 형식이 올바르지 않습니다.');
|
|
| 208 | 184 |
} |
| 209 | 185 |
|
| 210 |
- mockStudentMembers = mockStudentMembers.map((member) => |
|
| 211 |
- member.id === id ? { ...member, isActive } : member
|
|
| 212 |
- ); |
|
| 186 |
+ const items = data.list.map(toStudentMember); |
|
| 187 |
+ const reportedTotalCount = |
|
| 188 |
+ typeof data.totalCount === 'number' ? data.totalCount : items.length; |
|
| 189 |
+ |
|
| 190 |
+ const offset = (query.page - 1) * query.pageSize; |
|
| 191 |
+ const confirmedCount = offset + items.length; |
|
| 192 |
+ const reachedLastPage = items.length < query.pageSize; |
|
| 193 |
+ |
|
| 194 |
+ return {
|
|
| 195 |
+ items, |
|
| 196 |
+ totalCount: Math.max(reportedTotalCount, confirmedCount), |
|
| 197 |
+ isTotalCountExact: reachedLastPage || reportedTotalCount > confirmedCount, |
|
| 198 |
+ }; |
|
| 213 | 199 |
} |
--- lib/domain/student-member-query.ts
+++ lib/domain/student-member-query.ts
... | ... | @@ -10,12 +10,15 @@ |
| 10 | 10 |
/** 라우트 경로 — 이 파일 안에서만 하드코딩하고 나머지는 이 상수를 참조한다. */ |
| 11 | 11 |
export const STUDENT_MEMBERS_PATH = '/students'; |
| 12 | 12 |
|
| 13 |
-export type StudentMemberSearchField = |
|
| 14 |
- | 'name' |
|
| 15 |
- | 'loginId' |
|
| 16 |
- | 'phoneNumber' |
|
| 17 |
- | 'schoolName' |
|
| 18 |
- | 'memberCode'; |
|
| 13 |
+/** |
|
| 14 |
+ * 검색 대상 — 백엔드가 실제로 필터링해 주는 3종만 둔다. |
|
| 15 |
+ * |
|
| 16 |
+ * 백엔드 목록 쿼리(MngrUserMapper.xml)는 `searchCondition`이 "1"|"2"|"3"일 때만 조건을 붙이고, |
|
| 17 |
+ * 그 외 값이면 **조건 없이 전체를 반환한다**(검색어를 무시한 결과가 검색 결과인 척 나온다). |
|
| 18 |
+ * 그래서 지원되지 않는 학교명·회원코드 검색은 화면에서 아예 제거했다 — 백엔드가 조건을 |
|
| 19 |
+ * 추가하면 여기와 Repository의 매핑 표에 같이 넣으면 된다. |
|
| 20 |
+ */ |
|
| 21 |
+export type StudentMemberSearchField = 'name' | 'loginId' | 'phoneNumber'; |
|
| 19 | 22 |
|
| 20 | 23 |
export const STUDENT_MEMBER_SEARCH_FIELD_OPTIONS: ReadonlyArray<{
|
| 21 | 24 |
value: StudentMemberSearchField; |
... | ... | @@ -24,8 +27,6 @@ |
| 24 | 27 |
{ value: 'name', label: '회원명' },
|
| 25 | 28 |
{ value: 'loginId', label: 'ID' },
|
| 26 | 29 |
{ value: 'phoneNumber', label: '휴대전화번호' },
|
| 27 |
- { value: 'schoolName', label: '학교명' },
|
|
| 28 |
- { value: 'memberCode', label: '회원코드' },
|
|
| 29 | 30 |
]; |
| 30 | 31 |
|
| 31 | 32 |
export type StudentMemberSortOption = 'joinedAt' | 'name'; |
--- lib/domain/student-member.ts
+++ lib/domain/student-member.ts
... | ... | @@ -1,35 +1,66 @@ |
| 1 | 1 |
/** |
| 2 | 2 |
* 학생 회원 도메인 타입 — 순수 데이터 표현, 외부 의존 없음. |
| 3 | 3 |
* |
| 4 |
- * 데이터 출처는 외부 시스템 「알콩」이다. 백엔드(edupay-backend)에 학생 회원 조회 API가 아직 |
|
| 5 |
- * 없는 구간이라 `student-member-repository.ts`가 결정적 mock 데이터로 이 타입을 채워 제공한다 |
|
| 6 |
- * — 백엔드 연동 후에도 이 타입 자체는 그대로 유지되고 Repository 내부 구현만 교체된다. |
|
| 4 |
+ * 데이터 출처는 백엔드(edupay-backend)의 `GET /api/v1/mngr/user/pagination`이며, |
|
| 5 |
+ * `lib/data/repositories/student-member-repository.ts`가 응답을 이 타입으로 매핑한다. |
|
| 7 | 6 |
* |
| 8 |
- * 조회 전용 화면(등록/수정/삭제 없음)의 데이터라 `isActive`를 제외한 모든 필드는 읽기 전용으로 |
|
| 9 |
- * 취급한다(수정 가능 여부는 UI 계층의 책임이지 타입 자체의 제약은 아니다). |
|
| 7 |
+ * **`null`의 의미는 "백엔드가 아직 주지 않는 항목"이다.** 현재 응답이 담고 있는 값은 |
|
| 8 |
+ * 식별자·이름 계열(`userId`/`loginId`/`userNm`) 넷뿐이라 나머지 항목은 전부 `null`로 채워지고 |
|
| 9 |
+ * 화면에서 `-`로 표시된다(화면 컬럼은 유지 — 백엔드가 필드를 추가하면 Repository의 매핑만 |
|
| 10 |
+ * 늘리면 그대로 채워진다). 값이 "비어 있다"와 "제공되지 않는다"를 굳이 구분하지 않는 이유는, |
|
| 11 |
+ * 조회 전용 화면에서 둘 다 사용자에게는 `-`로 같은 의미이기 때문이다. |
|
| 12 |
+ * |
|
| 13 |
+ * 조회 전용 화면(등록/수정/삭제 없음)의 데이터라 모든 필드를 읽기 전용으로 취급한다 |
|
| 14 |
+ * (수정 가능 여부는 UI 계층의 책임이지 타입 자체의 제약은 아니다). |
|
| 10 | 15 |
*/ |
| 11 | 16 |
export type StudentMember = {
|
| 12 |
- /** 내부 식별자. mock 단계에서는 memberCode와 동일한 값을 쓰지만, 실제 백엔드 연동 시에는 |
|
| 13 |
- * 별도의 PK일 수 있어 memberCode와 분리된 필드로 둔다. */ |
|
| 17 |
+ /** 내부 식별자 — 백엔드 `userId`. 목록 행의 key이자 향후 단건 조회의 입력값이다. */ |
|
| 14 | 18 |
id: string; |
| 15 |
- /** 화면에 노출되는 회원코드 (예: ST00001). */ |
|
| 19 |
+ /** 화면에 노출되는 회원코드. 백엔드에 전용 필드가 없어 현재는 `userId`를 그대로 쓴다. */ |
|
| 16 | 20 |
memberCode: string; |
| 21 |
+ /** 백엔드 `userNm`. */ |
|
| 17 | 22 |
name: string; |
| 23 |
+ /** 백엔드 `loginId`. */ |
|
| 18 | 24 |
loginId: string; |
| 19 |
- phoneNumber: string; |
|
| 20 |
- email: string; |
|
| 25 |
+ phoneNumber: string | null; |
|
| 26 |
+ email: string | null; |
|
| 21 | 27 |
/** 회원 역할 — 본 화면(학생 회원 목록)에서는 항상 '학생'이다. */ |
| 22 | 28 |
role: string; |
| 23 |
- schoolName: string; |
|
| 24 |
- grade: number; |
|
| 25 |
- classNumber: number; |
|
| 26 |
- studentNumber: number; |
|
| 27 |
- guardianName: string; |
|
| 28 |
- guardianPhoneNumber: string; |
|
| 29 |
+ schoolName: string | null; |
|
| 30 |
+ grade: number | null; |
|
| 31 |
+ classNumber: number | null; |
|
| 32 |
+ studentNumber: number | null; |
|
| 33 |
+ guardianName: string | null; |
|
| 34 |
+ guardianPhoneNumber: string | null; |
|
| 29 | 35 |
/** ISO 형식(YYYY-MM-DD) 문자열. */ |
| 30 |
- joinedAt: string; |
|
| 36 |
+ joinedAt: string | null; |
|
| 31 | 37 |
/** ISO 형식(YYYY-MM-DD) 문자열. */ |
| 32 |
- birthDate: string; |
|
| 33 |
- /** 조회 팝업에서 유일하게 수정 가능한 필드(사용여부). */ |
|
| 34 |
- isActive: boolean; |
|
| 38 |
+ birthDate: string | null; |
|
| 39 |
+ /** 사용여부. 백엔드가 값도 변경 API도 제공하지 않아 현재는 항상 null이다. */ |
|
| 40 |
+ isActive: boolean | null; |
|
| 35 | 41 |
}; |
| 42 |
+ |
|
| 43 |
+/** 값이 없는 항목의 화면 표기. 표·조회 팝업이 같은 문자를 쓰도록 여기 한 곳에 둔다. */ |
|
| 44 |
+export const EMPTY_FIELD_PLACEHOLDER = '-'; |
|
| 45 |
+ |
|
| 46 |
+/** 값이 없으면 `-`, 있으면 문자열로 표기한다. */ |
|
| 47 |
+export function formatOptionalValue( |
|
| 48 |
+ value: string | number | null |
|
| 49 |
+): string {
|
|
| 50 |
+ return value === null ? EMPTY_FIELD_PLACEHOLDER : String(value); |
|
| 51 |
+} |
|
| 52 |
+ |
|
| 53 |
+/** |
|
| 54 |
+ * "학년/반/번호" 합성 표기. 표와 조회 팝업이 같은 규칙을 쓰도록 한 곳에 둔다. |
|
| 55 |
+ * 세 값 중 하나라도 없으면 부분 문장("1학년 -반 -번")을 만들지 않고 통째로 `-`로 표기한다 —
|
|
| 56 |
+ * 셋이 함께여야 의미가 성립하는 한 덩어리이기 때문이다. |
|
| 57 |
+ */ |
|
| 58 |
+export function formatGradeClassNumber(member: StudentMember): string {
|
|
| 59 |
+ const { grade, classNumber, studentNumber } = member;
|
|
| 60 |
+ |
|
| 61 |
+ if (grade === null || classNumber === null || studentNumber === null) {
|
|
| 62 |
+ return EMPTY_FIELD_PLACEHOLDER; |
|
| 63 |
+ } |
|
| 64 |
+ |
|
| 65 |
+ return `${grade}학년 ${classNumber}반 ${studentNumber}번`;
|
|
| 66 |
+} |
--- lib/env.ts
+++ lib/env.ts
... | ... | @@ -24,6 +24,27 @@ |
| 24 | 24 |
return readRequiredEnv('SESSION_SECRET');
|
| 25 | 25 |
} |
| 26 | 26 |
|
| 27 |
+/** 백엔드(edupay-backend) API 오리진. 누락 시 즉시 throw(fail-fast, 기본값 폴백 없음). */ |
|
| 28 |
+export function getApiBaseUrl(): string {
|
|
| 29 |
+ return readRequiredEnv('EDUPAY_API_BASE_URL');
|
|
| 30 |
+} |
|
| 31 |
+ |
|
| 32 |
+/** |
|
| 33 |
+ * 개발용 백엔드 accessToken. |
|
| 34 |
+ * |
|
| 35 |
+ * 관리자 로그인 연동(별도 작업)이 끝나면 토큰은 세션에서 나오고 이 함수는 삭제 대상이다. |
|
| 36 |
+ * 그 전까지 보호된 백엔드 API를 실제 데이터로 확인할 수 있게 하는 임시 우회로이며, |
|
| 37 |
+ * 운영에서는 키가 있더라도 읽지 않는다(fail-closed) — 개발용 토큰이 배포에 섞여 들어가도 |
|
| 38 |
+ * 운영 트래픽이 그 토큰으로 백엔드를 호출하는 일이 없어야 하기 때문이다. |
|
| 39 |
+ */ |
|
| 40 |
+export function getDevApiAccessToken(): string | null {
|
|
| 41 |
+ if (process.env.NODE_ENV === 'production') {
|
|
| 42 |
+ return null; |
|
| 43 |
+ } |
|
| 44 |
+ |
|
| 45 |
+ return process.env.EDUPAY_API_ACCESS_TOKEN || null; |
|
| 46 |
+} |
|
| 47 |
+ |
|
| 27 | 48 |
export type MockAdminCredentials = {
|
| 28 | 49 |
loginId: string; |
| 29 | 50 |
password: string; |
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?