+++ app/(protected)/(basic)/students/_actions.ts
... | ... | @@ -0,0 +1,49 @@ |
| 1 | +'use server'; | |
| 2 | + | |
| 3 | +import { revalidatePath } from 'next/cache'; | |
| 4 | +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 | + | |
| 11 | +export type UpdateStudentActiveStatusState = | |
| 12 | + | { status: 'idle' } | |
| 13 | + | { status: 'error'; error: string } | |
| 14 | + | { status: 'success' }; | |
| 15 | + | |
| 16 | +const GENERIC_ERROR = '요청을 처리할 수 없습니다. 잠시 후 다시 시도해 주세요.'; | |
| 17 | + | |
| 18 | +/** | |
| 19 | + * 학생 회원 사용여부 변경 Server Action — 조회 팝업에서 유일하게 수정 가능한 필드다. | |
| 20 | + * Server Action은 UI를 거치지 않고 직접 POST될 수 있으므로 인증 확인과 입력 검증을 이 | |
| 21 | + * 함수 내부에서 직접 수행한다(§3 SRP 체크). 성공 시 목록 화면을 재검증해 변경된 사용여부가 | |
| 22 | + * 즉시 반영되게 한다. | |
| 23 | + */ | |
| 24 | +export async function updateStudentActiveStatus( | |
| 25 | + _prevState: UpdateStudentActiveStatusState, | |
| 26 | + formData: FormData | |
| 27 | +): Promise<UpdateStudentActiveStatusState> { | |
| 28 | + await verifySession(); | |
| 29 | + | |
| 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' }; | |
| 49 | +} |
+++ app/(protected)/(basic)/students/_components/student-detail-modal.tsx
... | ... | @@ -0,0 +1,125 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useActionState, useEffect } from 'react'; | |
| 4 | +import { Button } from '@/components/ui/button'; | |
| 5 | +import { Field } from '@/components/ui/field'; | |
| 6 | +import { Input } from '@/components/ui/input'; | |
| 7 | +import { Modal } from '@/components/ui/modal'; | |
| 8 | +import { RadioGroup } from '@/components/ui/radio-group'; | |
| 9 | +import type { StudentMember } from '@/lib/domain/student-member'; | |
| 10 | +import { | |
| 11 | + updateStudentActiveStatus, | |
| 12 | + type UpdateStudentActiveStatusState, | |
| 13 | +} from '../_actions'; | |
| 14 | + | |
| 15 | +interface StudentDetailModalProps { | |
| 16 | + member: StudentMember; | |
| 17 | + onClose: () => void; | |
| 18 | +} | |
| 19 | + | |
| 20 | +const ACTIVE_STATUS_OPTIONS = [ | |
| 21 | + { value: 'true', label: '활성' }, | |
| 22 | + { value: 'false', label: '비활성' }, | |
| 23 | +]; | |
| 24 | + | |
| 25 | +const INITIAL_STATE: UpdateStudentActiveStatusState = { status: 'idle' }; | |
| 26 | +const DETAIL_FORM_ID = 'student-active-status-form'; | |
| 27 | + | |
| 28 | +/** | |
| 29 | + * 학생 회원 조회 팝업 — 사용여부를 제외한 모든 항목은 readOnly다. 데이터는 목록 행에서 이미 | |
| 30 | + * 갖고 있는 member를 props로 그대로 받으므로 재조회하지 않는다. 저장(Server Action) 성공 시 | |
| 31 | + * 자동으로 닫힌다. | |
| 32 | + * | |
| 33 | + * Modal이 내부적으로 portal을 쓸 수도 있어(구현 미확정 — components/ui/modal.tsx는 design | |
| 34 | + * 레인 소관) 저장 버튼은 footer 슬롯에서 `form={DETAIL_FORM_ID}` 속성으로 폼과 연결한다. | |
| 35 | + * 네이티브 HTML의 `form` 속성은 버튼이 실제 DOM상 form의 자손이 아니어도 같은 문서 안에서 | |
| 36 | + * id만 일치하면 그 form을 제출한다 — footer가 body와 다른 DOM 위치에 렌더돼도 안전하다. | |
| 37 | + */ | |
| 38 | +export function StudentDetailModal({ member, onClose }: StudentDetailModalProps) { | |
| 39 | + const [state, formAction, isPending] = useActionState( | |
| 40 | + updateStudentActiveStatus, | |
| 41 | + INITIAL_STATE | |
| 42 | + ); | |
| 43 | + | |
| 44 | + useEffect(() => { | |
| 45 | + if (state.status === 'success') { | |
| 46 | + onClose(); | |
| 47 | + } | |
| 48 | + }, [state, onClose]); | |
| 49 | + | |
| 50 | + const gradeClassNumber = `${member.grade}학년 ${member.classNumber}반 ${member.studentNumber}번`; | |
| 51 | + | |
| 52 | + return ( | |
| 53 | + <Modal | |
| 54 | + title="학생 회원 조회" | |
| 55 | + onClose={onClose} | |
| 56 | + footer={ | |
| 57 | + <> | |
| 58 | + <Button type="button" variant="ghost" onClick={onClose}> | |
| 59 | + 취소 | |
| 60 | + </Button> | |
| 61 | + <Button | |
| 62 | + type="submit" | |
| 63 | + form={DETAIL_FORM_ID} | |
| 64 | + variant="primary" | |
| 65 | + disabled={isPending} | |
| 66 | + > | |
| 67 | + {isPending ? '저장 중...' : '저장'} | |
| 68 | + </Button> | |
| 69 | + </> | |
| 70 | + } | |
| 71 | + > | |
| 72 | + <form id={DETAIL_FORM_ID} action={formAction} className="flex flex-col gap-4"> | |
| 73 | + <input type="hidden" name="id" value={member.id} /> | |
| 74 | + | |
| 75 | + <p className="text-body-sm text-foreground-muted"> | |
| 76 | + 본 화면의 모든 항목은 조회 전용입니다. (단, 사용여부는 수정 가능) | |
| 77 | + </p> | |
| 78 | + | |
| 79 | + <Field label="이름"> | |
| 80 | + <Input value={member.name} readOnly /> | |
| 81 | + </Field> | |
| 82 | + <Field label="ID"> | |
| 83 | + <Input value={member.loginId} readOnly /> | |
| 84 | + </Field> | |
| 85 | + <Field label="휴대전화 번호"> | |
| 86 | + <Input value={member.phoneNumber} readOnly /> | |
| 87 | + </Field> | |
| 88 | + <Field label="이메일"> | |
| 89 | + <Input value={member.email} readOnly /> | |
| 90 | + </Field> | |
| 91 | + <Field label="생년월일"> | |
| 92 | + <Input value={member.birthDate} readOnly /> | |
| 93 | + </Field> | |
| 94 | + <Field label="보호자 이름"> | |
| 95 | + <Input value={member.guardianName} readOnly /> | |
| 96 | + </Field> | |
| 97 | + <Field label="보호자 연락처"> | |
| 98 | + <Input value={member.guardianPhoneNumber} readOnly /> | |
| 99 | + </Field> | |
| 100 | + <Field label="학교"> | |
| 101 | + <Input value={member.schoolName} readOnly /> | |
| 102 | + </Field> | |
| 103 | + <Field label="학년/반/번호"> | |
| 104 | + <Input value={gradeClassNumber} readOnly /> | |
| 105 | + </Field> | |
| 106 | + | |
| 107 | + <Field label="사용여부"> | |
| 108 | + <RadioGroup | |
| 109 | + name="isActive" | |
| 110 | + options={ACTIVE_STATUS_OPTIONS} | |
| 111 | + defaultValue={member.isActive ? 'true' : 'false'} | |
| 112 | + /> | |
| 113 | + </Field> | |
| 114 | + | |
| 115 | + {state.status === 'error' && ( | |
| 116 | + <p className="text-body-sm text-danger">{state.error}</p> | |
| 117 | + )} | |
| 118 | + | |
| 119 | + <p className="text-body-sm text-foreground-muted"> | |
| 120 | + 회원 가입일 : {member.joinedAt} | |
| 121 | + </p> | |
| 122 | + </form> | |
| 123 | + </Modal> | |
| 124 | + ); | |
| 125 | +} |
+++ app/(protected)/(basic)/students/_components/student-row-actions.tsx
... | ... | @@ -0,0 +1,42 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useState } from 'react'; | |
| 4 | +import { Button } from '@/components/ui/button'; | |
| 5 | +import type { StudentMember } from '@/lib/domain/student-member'; | |
| 6 | +import { StudentDetailModal } from './student-detail-modal'; | |
| 7 | + | |
| 8 | +interface StudentRowActionsProps { | |
| 9 | + member: StudentMember; | |
| 10 | +} | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * 목록 행의 "관리" 셀 — 조회 팝업의 열림 상태만 소유하는 최말단 상호작용 경계다(테이블 전체를 | |
| 14 | + * 클라이언트로 내리지 않기 위해 이 셀만 분리했다 — §2.4 "'use client'는 최말단에만"). 팝업에 | |
| 15 | + * 필요한 데이터는 이미 이 행이 갖고 있는 member를 그대로 전달한다(재조회 없음). | |
| 16 | + * | |
| 17 | + * 기획 시안은 "돋보기 아이콘 버튼"을 지정하지만 `components/ui/icons/`에 아직 돋보기 아이콘이 | |
| 18 | + * 없다(신규 아이콘 추가는 공용 컴포넌트 신설이라 design 레인 소관, §4.2). 로직 우선 단계라 | |
| 19 | + * 아이콘 대신 텍스트 버튼("조회")으로 대체했고, 아이콘 부재는 별도로 보고한다. | |
| 20 | + */ | |
| 21 | +export function StudentRowActions({ member }: StudentRowActionsProps) { | |
| 22 | + const [isDetailOpen, setIsDetailOpen] = useState(false); | |
| 23 | + | |
| 24 | + return ( | |
| 25 | + <> | |
| 26 | + <Button | |
| 27 | + type="button" | |
| 28 | + variant="ghost" | |
| 29 | + size="sm" | |
| 30 | + onClick={() => setIsDetailOpen(true)} | |
| 31 | + > | |
| 32 | + 조회 | |
| 33 | + </Button> | |
| 34 | + {isDetailOpen && ( | |
| 35 | + <StudentDetailModal | |
| 36 | + member={member} | |
| 37 | + onClose={() => setIsDetailOpen(false)} | |
| 38 | + /> | |
| 39 | + )} | |
| 40 | + </> | |
| 41 | + ); | |
| 42 | +} |
--- app/(protected)/(basic)/students/_components/student-table.tsx
+++ app/(protected)/(basic)/students/_components/student-table.tsx
... | ... | @@ -6,8 +6,8 @@ |
| 6 | 6 |
TableHeaderCell, |
| 7 | 7 |
TableRow, |
| 8 | 8 |
} from '@/components/ui/table'; |
| 9 |
-import { Button } from '@/components/ui/button';
|
|
| 10 | 9 |
import type { StudentMember } from '@/lib/domain/student-member';
|
| 10 |
+import { StudentRowActions } from './student-row-actions';
|
|
| 11 | 11 |
|
| 12 | 12 |
interface StudentTableProps {
|
| 13 | 13 |
items: StudentMember[]; |
... | ... | @@ -36,9 +36,9 @@ |
| 36 | 36 |
* 현재 페이지 기준 표시 순번(= (page-1)*pageSize + 행 인덱스 + 1)이다. 개인정보 마스킹은 |
| 37 | 37 |
* 기획 시안에서 명시적으로 해제 지시가 있어 원본 값을 그대로 노출한다. |
| 38 | 38 |
* |
| 39 |
- * "관리" 열의 조회 버튼은 이 커밋에서는 자리만 잡고 비활성 상태다 — 클릭 시 팝업을 여는 |
|
| 40 |
- * 상호작용과 팝업 자체는 다음 커밋(T4)에서 함께 연결한다(그때 이 셀만 클라이언트 최말단 |
|
| 41 |
- * 컴포넌트로 교체된다). 이 테이블 자체는 Server Component로 유지한다. |
|
| 39 |
+ * "관리" 열은 행별 조회 팝업 트리거(StudentRowActions)에 위임한다 — 상호작용이 필요한 것은 |
|
| 40 |
+ * 그 셀뿐이라 이 테이블 자체는 Server Component로 유지하고 최말단만 클라이언트 경계로 뗀다 |
|
| 41 |
+ * (§2.4 "'use client'는 최말단에만"). |
|
| 42 | 42 |
*/ |
| 43 | 43 |
export function StudentTable({ items, page, pageSize }: StudentTableProps) {
|
| 44 | 44 |
return ( |
... | ... | @@ -66,9 +66,7 @@ |
| 66 | 66 |
<TableCell>{member.guardianPhoneNumber}</TableCell>
|
| 67 | 67 |
<TableCell>{member.joinedAt}</TableCell>
|
| 68 | 68 |
<TableCell> |
| 69 |
- <Button type="button" variant="ghost" size="sm" disabled> |
|
| 70 |
- 조회 |
|
| 71 |
- </Button> |
|
| 69 |
+ <StudentRowActions member={member} />
|
|
| 72 | 70 |
</TableCell> |
| 73 | 71 |
</TableRow> |
| 74 | 72 |
))} |
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?