임동욱 임동욱 08-10
feat: 학생 회원 조회 팝업과 사용여부 변경 추가
@8a48276c024c56b97e1eaee5c3eca3940014f619
 
app/(protected)/(basic)/students/_actions.ts (added)
+++ app/(protected)/(basic)/students/_actions.ts
@@ -0,0 +1,49 @@
+'use server';
+
+import { revalidatePath } from 'next/cache';
+import { verifySession } from '@/lib/auth/dal';
+import {
+  fetchStudentMemberById,
+  updateStudentMemberActiveStatus,
+} 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 GENERIC_ERROR = '요청을 처리할 수 없습니다. 잠시 후 다시 시도해 주세요.';
+
+/**
+ * 학생 회원 사용여부 변경 Server Action — 조회 팝업에서 유일하게 수정 가능한 필드다.
+ * Server Action은 UI를 거치지 않고 직접 POST될 수 있으므로 인증 확인과 입력 검증을 이
+ * 함수 내부에서 직접 수행한다(§3 SRP 체크). 성공 시 목록 화면을 재검증해 변경된 사용여부가
+ * 즉시 반영되게 한다.
+ */
+export async function updateStudentActiveStatus(
+  _prevState: UpdateStudentActiveStatusState,
+  formData: FormData
+): Promise<UpdateStudentActiveStatusState> {
+  await verifySession();
+
+  const id = formData.get('id');
+  const isActiveRaw = formData.get('isActive');
+
+  if (typeof id !== 'string' || id.trim().length === 0) {
+    return { status: 'error', error: GENERIC_ERROR };
+  }
+  if (isActiveRaw !== 'true' && isActiveRaw !== 'false') {
+    return { status: 'error', error: GENERIC_ERROR };
+  }
+
+  const member = await fetchStudentMemberById(id);
+  if (!member) {
+    return { status: 'error', error: '존재하지 않는 학생 회원입니다.' };
+  }
+
+  await updateStudentMemberActiveStatus(id, isActiveRaw === 'true');
+  revalidatePath(STUDENT_MEMBERS_PATH);
+
+  return { status: 'success' };
+}
 
app/(protected)/(basic)/students/_components/student-detail-modal.tsx (added)
+++ app/(protected)/(basic)/students/_components/student-detail-modal.tsx
@@ -0,0 +1,125 @@
+'use client';
+
+import { useActionState, useEffect } from 'react';
+import { Button } from '@/components/ui/button';
+import { Field } from '@/components/ui/field';
+import { Input } from '@/components/ui/input';
+import { Modal } from '@/components/ui/modal';
+import { RadioGroup } from '@/components/ui/radio-group';
+import type { StudentMember } from '@/lib/domain/student-member';
+import {
+  updateStudentActiveStatus,
+  type UpdateStudentActiveStatusState,
+} from '../_actions';
+
+interface StudentDetailModalProps {
+  member: StudentMember;
+  onClose: () => void;
+}
+
+const ACTIVE_STATUS_OPTIONS = [
+  { value: 'true', label: '활성' },
+  { value: 'false', label: '비활성' },
+];
+
+const INITIAL_STATE: UpdateStudentActiveStatusState = { status: 'idle' };
+const DETAIL_FORM_ID = 'student-active-status-form';
+
+/**
+ * 학생 회원 조회 팝업 — 사용여부를 제외한 모든 항목은 readOnly다. 데이터는 목록 행에서 이미
+ * 갖고 있는 member를 props로 그대로 받으므로 재조회하지 않는다. 저장(Server Action) 성공 시
+ * 자동으로 닫힌다.
+ *
+ * Modal이 내부적으로 portal을 쓸 수도 있어(구현 미확정 — components/ui/modal.tsx는 design
+ * 레인 소관) 저장 버튼은 footer 슬롯에서 `form={DETAIL_FORM_ID}` 속성으로 폼과 연결한다.
+ * 네이티브 HTML의 `form` 속성은 버튼이 실제 DOM상 form의 자손이 아니어도 같은 문서 안에서
+ * id만 일치하면 그 form을 제출한다 — footer가 body와 다른 DOM 위치에 렌더돼도 안전하다.
+ */
+export function StudentDetailModal({ member, onClose }: StudentDetailModalProps) {
+  const [state, formAction, isPending] = useActionState(
+    updateStudentActiveStatus,
+    INITIAL_STATE
+  );
+
+  useEffect(() => {
+    if (state.status === 'success') {
+      onClose();
+    }
+  }, [state, onClose]);
+
+  const gradeClassNumber = `${member.grade}학년 ${member.classNumber}반 ${member.studentNumber}번`;
+
+  return (
+    <Modal
+      title="학생 회원 조회"
+      onClose={onClose}
+      footer={
+        <>
+          <Button type="button" variant="ghost" onClick={onClose}>
+            취소
+          </Button>
+          <Button
+            type="submit"
+            form={DETAIL_FORM_ID}
+            variant="primary"
+            disabled={isPending}
+          >
+            {isPending ? '저장 중...' : '저장'}
+          </Button>
+        </>
+      }
+    >
+      <form id={DETAIL_FORM_ID} action={formAction} className="flex flex-col gap-4">
+        <input type="hidden" name="id" value={member.id} />
+
+        <p className="text-body-sm text-foreground-muted">
+          본 화면의 모든 항목은 조회 전용입니다. (단, 사용여부는 수정 가능)
+        </p>
+
+        <Field label="이름">
+          <Input value={member.name} readOnly />
+        </Field>
+        <Field label="ID">
+          <Input value={member.loginId} readOnly />
+        </Field>
+        <Field label="휴대전화 번호">
+          <Input value={member.phoneNumber} readOnly />
+        </Field>
+        <Field label="이메일">
+          <Input value={member.email} readOnly />
+        </Field>
+        <Field label="생년월일">
+          <Input value={member.birthDate} readOnly />
+        </Field>
+        <Field label="보호자 이름">
+          <Input value={member.guardianName} readOnly />
+        </Field>
+        <Field label="보호자 연락처">
+          <Input value={member.guardianPhoneNumber} readOnly />
+        </Field>
+        <Field label="학교">
+          <Input value={member.schoolName} readOnly />
+        </Field>
+        <Field label="학년/반/번호">
+          <Input value={gradeClassNumber} readOnly />
+        </Field>
+
+        <Field label="사용여부">
+          <RadioGroup
+            name="isActive"
+            options={ACTIVE_STATUS_OPTIONS}
+            defaultValue={member.isActive ? 'true' : 'false'}
+          />
+        </Field>
+
+        {state.status === 'error' && (
+          <p className="text-body-sm text-danger">{state.error}</p>
+        )}
+
+        <p className="text-body-sm text-foreground-muted">
+          회원 가입일 : {member.joinedAt}
+        </p>
+      </form>
+    </Modal>
+  );
+}
 
app/(protected)/(basic)/students/_components/student-row-actions.tsx (added)
+++ app/(protected)/(basic)/students/_components/student-row-actions.tsx
@@ -0,0 +1,42 @@
+'use client';
+
+import { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import type { StudentMember } from '@/lib/domain/student-member';
+import { StudentDetailModal } from './student-detail-modal';
+
+interface StudentRowActionsProps {
+  member: StudentMember;
+}
+
+/**
+ * 목록 행의 "관리" 셀 — 조회 팝업의 열림 상태만 소유하는 최말단 상호작용 경계다(테이블 전체를
+ * 클라이언트로 내리지 않기 위해 이 셀만 분리했다 — §2.4 "'use client'는 최말단에만"). 팝업에
+ * 필요한 데이터는 이미 이 행이 갖고 있는 member를 그대로 전달한다(재조회 없음).
+ *
+ * 기획 시안은 "돋보기 아이콘 버튼"을 지정하지만 `components/ui/icons/`에 아직 돋보기 아이콘이
+ * 없다(신규 아이콘 추가는 공용 컴포넌트 신설이라 design 레인 소관, §4.2). 로직 우선 단계라
+ * 아이콘 대신 텍스트 버튼("조회")으로 대체했고, 아이콘 부재는 별도로 보고한다.
+ */
+export function StudentRowActions({ member }: StudentRowActionsProps) {
+  const [isDetailOpen, setIsDetailOpen] = useState(false);
+
+  return (
+    <>
+      <Button
+        type="button"
+        variant="ghost"
+        size="sm"
+        onClick={() => setIsDetailOpen(true)}
+      >
+        조회
+      </Button>
+      {isDetailOpen && (
+        <StudentDetailModal
+          member={member}
+          onClose={() => setIsDetailOpen(false)}
+        />
+      )}
+    </>
+  );
+}
app/(protected)/(basic)/students/_components/student-table.tsx
--- app/(protected)/(basic)/students/_components/student-table.tsx
+++ app/(protected)/(basic)/students/_components/student-table.tsx
@@ -6,8 +6,8 @@
   TableHeaderCell,
   TableRow,
 } from '@/components/ui/table';
-import { Button } from '@/components/ui/button';
 import type { StudentMember } from '@/lib/domain/student-member';
+import { StudentRowActions } from './student-row-actions';
 
 interface StudentTableProps {
   items: StudentMember[];
@@ -36,9 +36,9 @@
  * 현재 페이지 기준 표시 순번(= (page-1)*pageSize + 행 인덱스 + 1)이다. 개인정보 마스킹은
  * 기획 시안에서 명시적으로 해제 지시가 있어 원본 값을 그대로 노출한다.
  *
- * "관리" 열의 조회 버튼은 이 커밋에서는 자리만 잡고 비활성 상태다 — 클릭 시 팝업을 여는
- * 상호작용과 팝업 자체는 다음 커밋(T4)에서 함께 연결한다(그때 이 셀만 클라이언트 최말단
- * 컴포넌트로 교체된다). 이 테이블 자체는 Server Component로 유지한다.
+ * "관리" 열은 행별 조회 팝업 트리거(StudentRowActions)에 위임한다 — 상호작용이 필요한 것은
+ * 그 셀뿐이라 이 테이블 자체는 Server Component로 유지하고 최말단만 클라이언트 경계로 뗀다
+ * (§2.4 "'use client'는 최말단에만").
  */
 export function StudentTable({ items, page, pageSize }: StudentTableProps) {
   return (
@@ -66,9 +66,7 @@
             <TableCell>{member.guardianPhoneNumber}</TableCell>
             <TableCell>{member.joinedAt}</TableCell>
             <TableCell>
-              <Button type="button" variant="ghost" size="sm" disabled>
-                조회
-              </Button>
+              <StudentRowActions member={member} />
             </TableCell>
           </TableRow>
         ))}
Add a comment
List