'use client'; import { useActionState, useEffect, useRef, useState } from 'react'; import { FoxButton } from '@fox/core/components/fox-button'; import { FoxDescriptionList, type FoxDescriptionItem, } from '@fox/core/components/fox-description-list'; import { FoxModal } from '@fox/core/components/fox-modal'; import { FoxToggleSwitch } from '@fox/core/components/fox-toggle-switch'; import { formatPhoneNumber } from '@/lib/domain/phone-number'; import { EMPTY_FIELD_PLACEHOLDER, formatGradeClassNumber, formatOptionalValue, type StudentMember, } from '@/lib/domain/student-member'; import { updateStudentActiveStatus, type UpdateStudentActiveStatusState, } from '../_actions'; import styles from './student-detail-modal.module.scss'; interface StudentDetailModalProps { member: StudentMember; onClose: () => void; } const INITIAL_STATE: UpdateStudentActiveStatusState = { status: 'idle' }; const DETAIL_FORM_ID = 'student-active-status-form'; /** 시안은 두 값을 " / "로 묶는다("홍라애 / 010-2222-1234"). 둘 다 없으면 `-`다. */ function joinPair(first: string | null, second: string | null) { const parts = [first, second].filter( (value): value is string => value !== null && value !== '' ); return parts.length > 0 ? parts.join(' / ') : '-'; } /** * 학생 회원 조회 팝업 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) modal 5227:809 * * 모든 항목이 조회 전용이다. 데이터는 목록 행이 이미 갖고 있는 member를 그대로 받으므로 * 재조회하지 않는다(백엔드에 단건 조회 API도 아직 없다). 백엔드가 주지 않는 값은 `-`다. * * 시안은 보호자(이름/연락처)와 학교(학교/학년·반·번호)를 각각 한 줄로 묶는다 — 종전처럼 * 항목을 따로 두지 않고 시안의 8줄 구성을 그대로 따랐다. * * 사용여부는 이 팝업에서 유일하게 수정 가능한 항목이다. 토글을 넘기면 화면이 바로 반응하고, * 저장을 누르면 `PUT /api/v1/mngr/user/{userId}/{useYn}`로 나간다. * * 저장 버튼은 `form={DETAIL_FORM_ID}`로 폼과 이어 둔다 — FoxModal이 버튼을 `foot` 슬롯에 * 그려 폼의 자손이 아니게 되는데, 네이티브 `form` 속성은 같은 문서 안에서 id만 맞으면 그 * 폼을 제출한다. */ export function StudentDetailModal({ member, onClose }: StudentDetailModalProps) { const [state, formAction, isPending] = useActionState( updateStudentActiveStatus, INITIAL_STATE ); const formRef = useRef(null); // 토글은 화면에서 바로 반응해야 하므로 이 컴포넌트가 상태를 갖는다 — prop에서 계산하면 // 스위치를 넘겨도 라벨이 그대로다. 저장하면 이 값이 폼에 실려 나간다. const [isActive, setIsActive] = useState(member.isActive ?? false); // 학년·반이 하나도 없으면 `-`가 오는데, 그때는 "학교 / -"가 아니라 학교만 보여야 한다. const formattedGradeClass = formatGradeClassNumber(member); const gradeClassNumber = formattedGradeClass === EMPTY_FIELD_PLACEHOLDER ? null : formattedGradeClass; // 저장에 성공하면 닫는다. 목록은 Server Action의 revalidatePath가 다시 그린다. useEffect(() => { if (state.status === 'success') { onClose(); } }, [state, onClose]); const items: FoxDescriptionItem[] = [ { key: 'name', term: '이름', description: member.name }, { key: 'loginId', term: 'ID', description: member.loginId }, { key: 'phoneNumber', term: '휴대전화 번호', description: formatOptionalValue(formatPhoneNumber(member.phoneNumber)), }, { key: 'email', term: '이메일', description: formatOptionalValue(member.email), }, { key: 'birthDate', term: '생년월일', description: formatOptionalValue(member.birthDate), }, { key: 'guardian', term: '보호자 이름 / 연락처', description: member.guardians.length === 0 ? EMPTY_FIELD_PLACEHOLDER : member.guardians.map((guardian, index) => (
{joinPair(guardian.name, formatPhoneNumber(guardian.phoneNumber))}
)), }, { key: 'school', term: '학교 / 학년·반·번호', description: joinPair(member.schoolName, gradeClassNumber), }, { key: 'isActive', term: '사용여부', description: ( setIsActive(checked)} /> ), }, ]; return ( {state.error}

) : undefined } actions={ <> {/* FoxButton에는 폼을 가리키는 prop이 없다(네이티브 `form` 속성 미노출). 버튼이 foot 슬롯에 그려져 폼의 자손이 아니므로 ref로 직접 제출한다. */} formRef.current?.requestSubmit()} /> } >
); }