feat: 학생 회원 조회 팝업을 시안대로 구현 (+ FoxDescriptionList)
시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) modal 5227:809. FoxModal이 껍데기를 전부 덮는다 — padding 40 · radius 24 · 닫기 버튼 · tit/contents/foot 슬롯 · `
@640bf4583a30b04685e81b097bcb021f251048f6
+++ @fox/core/components/fox-description-list/fox-description-list.tsx
... | ... | @@ -0,0 +1,68 @@ |
| 1 | +import type { ReactNode } from "react"; | |
| 2 | +import { cx } from "../../utils"; | |
| 3 | +import { FoxFormLabel } from "../fox-form-label"; | |
| 4 | + | |
| 5 | +export interface FoxDescriptionItem { | |
| 6 | + /** 목록 안에서 고유해야 한다. */ | |
| 7 | + key: string; | |
| 8 | + /** 항목 이름. */ | |
| 9 | + term: ReactNode; | |
| 10 | + /** 값. 글자든 토글이든 무엇이든 온다. */ | |
| 11 | + description: ReactNode; | |
| 12 | + /** 참이면 이 줄만 렌더하지 않는다. */ | |
| 13 | + hidden?: boolean; | |
| 14 | +} | |
| 15 | + | |
| 16 | +export interface FoxDescriptionListProps { | |
| 17 | + items: FoxDescriptionItem[]; | |
| 18 | + hidden?: boolean; | |
| 19 | + className?: string; | |
| 20 | +} | |
| 21 | + | |
| 22 | +/** | |
| 23 | + * @fox 조회 목록 — 이름과 값을 한 쌍씩 세로로 쌓는다. 조회 팝업·상세 화면이 쓰는 모양이다. | |
| 24 | + * | |
| 25 | + * 마크업 계약 (React 밖 소비자용): | |
| 26 | + * <dl class="fox-description-list"> | |
| 27 | + * <div class="fox-description-list__item"> | |
| 28 | + * <dt class="fox-description-list__term"><span class="fox-form-label">이름</span></dt> | |
| 29 | + * <dd class="fox-description-list__description">홍길동</dd> | |
| 30 | + * </div> | |
| 31 | + * </dl> | |
| 32 | + * | |
| 33 | + * `<dl>`로 그린다 — 이름·값 쌍이라는 관계가 보조기술에 그대로 전달된다. 이름 쪽은 `<dt>` 안에 | |
| 34 | + * `FoxFormLabel`을 `span`으로 넣어 서식을 디자인시스템과 맞춘다(`<dt>` 자체가 라벨 요소라 | |
| 35 | + * `<label>`을 쓰면 가리킬 대상이 없다). | |
| 36 | + * | |
| 37 | + * 짝을 `<div>`로 감싸는 것은 `<dl>`이 허용하는 유일한 묶음 방법이다 — 줄마다 여백과 구분선을 | |
| 38 | + * 주려면 감싸는 상자가 필요하다. | |
| 39 | + * | |
| 40 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 41 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 42 | + */ | |
| 43 | +export function FoxDescriptionList({ | |
| 44 | + items, | |
| 45 | + hidden = false, | |
| 46 | + className, | |
| 47 | +}: FoxDescriptionListProps) { | |
| 48 | + if (hidden) { | |
| 49 | + return null; | |
| 50 | + } | |
| 51 | + | |
| 52 | + return ( | |
| 53 | + <dl className={cx("fox-description-list", className)}> | |
| 54 | + {items | |
| 55 | + .filter((item) => !item.hidden) | |
| 56 | + .map((item) => ( | |
| 57 | + <div key={item.key} className="fox-description-list__item"> | |
| 58 | + <dt className="fox-description-list__term"> | |
| 59 | + <FoxFormLabel as="span">{item.term}</FoxFormLabel> | |
| 60 | + </dt> | |
| 61 | + <dd className="fox-description-list__description"> | |
| 62 | + {item.description} | |
| 63 | + </dd> | |
| 64 | + </div> | |
| 65 | + ))} | |
| 66 | + </dl> | |
| 67 | + ); | |
| 68 | +} |
+++ @fox/core/components/fox-description-list/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export * from "./fox-description-list"; |
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
... | ... | @@ -56,3 +56,4 @@ |
| 56 | 56 |
export * from "./fox-list-search"; |
| 57 | 57 |
export * from "./fox-list-toolbar"; |
| 58 | 58 |
export * from "./fox-list-container"; |
| 59 |
+export * from "./fox-description-list"; |
+++ @fox/styles/_fox-description-list.scss
... | ... | @@ -0,0 +1,49 @@ |
| 1 | +// FoxDescriptionList — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) modal 5227:809의 form-list | |
| 2 | +// | |
| 3 | +// 마크업 계약 (React 밖 소비자용): | |
| 4 | +// <dl class="fox-description-list"> | |
| 5 | +// <div class="fox-description-list__item"> | |
| 6 | +// <dt class="fox-description-list__term"><span class="fox-form-label">이름</span></dt> | |
| 7 | +// <dd class="fox-description-list__description">홍길동</dd> | |
| 8 | +// </div> | |
| 9 | +// </dl> | |
| 10 | +// | |
| 11 | +// 줄마다 아래에 구분선이 있다 — 시안이 마지막 줄에도 그어 두었으므로 걷어내지 않는다. | |
| 12 | +// | |
| 13 | +// 토큰이 아닌 값: | |
| 14 | +// - line-height / letter-spacing: 시안 텍스트 스타일(body/md)의 값이나 Figma가 변수로 | |
| 15 | +// export하지 않는다. | |
| 16 | + | |
| 17 | +@use "abstracts" as fox; | |
| 18 | + | |
| 19 | +.fox-description-list { | |
| 20 | + display: flex; | |
| 21 | + inline-size: 100%; | |
| 22 | + flex-direction: column; | |
| 23 | + margin: 0; | |
| 24 | + | |
| 25 | + &__item { | |
| 26 | + display: flex; | |
| 27 | + inline-size: 100%; | |
| 28 | + flex-direction: column; | |
| 29 | + align-items: flex-start; | |
| 30 | + gap: fox.gap(3); | |
| 31 | + padding-block: fox.padding(6); | |
| 32 | + border-block-end: fox.border(1) solid fox.color(border-neutral-subtler); | |
| 33 | + } | |
| 34 | + | |
| 35 | + &__term { | |
| 36 | + margin: 0; | |
| 37 | + } | |
| 38 | + | |
| 39 | + &__description { | |
| 40 | + inline-size: 100%; | |
| 41 | + margin: 0; | |
| 42 | + color: fox.color(font-neutral-default); | |
| 43 | + font-family: fox.font-family(body); | |
| 44 | + font-size: fox.font-size(body-md); | |
| 45 | + font-weight: fox.font-weight(regular); | |
| 46 | + line-height: 1.5; | |
| 47 | + letter-spacing: -0.025em; | |
| 48 | + } | |
| 49 | +} |
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
... | ... | @@ -59,3 +59,4 @@ |
| 59 | 59 |
@use "fox-list-toolbar"; |
| 60 | 60 |
@use "fox-list-container"; |
| 61 | 61 |
@use "fox-disclosure"; |
| 62 |
+@use "fox-description-list"; |
--- app/(protected)/(basic)/students/_components/student-detail-modal.tsx
+++ app/(protected)/(basic)/students/_components/student-detail-modal.tsx
... | ... | @@ -1,11 +1,13 @@ |
| 1 | 1 |
'use client'; |
| 2 | 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';
|
|
| 3 |
+import { useActionState, useEffect, useRef } from 'react';
|
|
| 4 |
+import { FoxButton } from '@fox/core/components/fox-button';
|
|
| 5 |
+import {
|
|
| 6 |
+ FoxDescriptionList, |
|
| 7 |
+ type FoxDescriptionItem, |
|
| 8 |
+} from '@fox/core/components/fox-description-list'; |
|
| 9 |
+import { FoxModal } from '@fox/core/components/fox-modal';
|
|
| 10 |
+import { FoxToggleSwitch } from '@fox/core/components/fox-toggle-switch';
|
|
| 9 | 11 |
import {
|
| 10 | 12 |
formatGradeClassNumber, |
| 11 | 13 |
formatOptionalValue, |
... | ... | @@ -21,43 +23,49 @@ |
| 21 | 23 |
onClose: () => void; |
| 22 | 24 |
} |
| 23 | 25 |
|
| 24 |
-const ACTIVE_STATUS_OPTIONS = [ |
|
| 25 |
- { value: 'true', label: '활성' },
|
|
| 26 |
- { value: 'false', label: '비활성' },
|
|
| 27 |
-]; |
|
| 28 |
- |
|
| 29 | 26 |
const INITIAL_STATE: UpdateStudentActiveStatusState = { status: 'idle' };
|
| 30 | 27 |
const DETAIL_FORM_ID = 'student-active-status-form'; |
| 31 | 28 |
|
| 32 | 29 |
/** |
| 33 | 30 |
* 사용여부 수정 가능 여부의 단일 토글. 백엔드에 사용여부 값도 변경 API도 없어 현재는 false다 — |
| 34 | 31 |
* 변경 API가 생기면 이 상수를 true로 되돌리고 `_actions.ts`의 미지원 처리를 실제 구현으로 |
| 35 |
- * 바꾸면 된다(라디오·저장 버튼의 비활성이 함께 풀린다). |
|
| 32 |
+ * 바꾸면 된다(토글·저장 버튼의 비활성이 함께 풀린다). |
|
| 36 | 33 |
*/ |
| 37 | 34 |
const IS_ACTIVE_STATUS_EDITABLE = false; |
| 38 | 35 |
const ACTIVE_STATUS_UNSUPPORTED_NOTE = |
| 39 | 36 |
'사용여부 변경은 백엔드 API 준비 후 제공됩니다.'; |
| 40 | 37 |
|
| 38 |
+/** 시안은 두 값을 " / "로 묶는다("홍라애 / 010-2222-1234"). 둘 다 없으면 `-`다. */
|
|
| 39 |
+function joinPair(first: string | null, second: string | null) {
|
|
| 40 |
+ const parts = [first, second].filter( |
|
| 41 |
+ (value): value is string => value !== null && value !== '' |
|
| 42 |
+ ); |
|
| 43 |
+ return parts.length > 0 ? parts.join(' / ') : '-';
|
|
| 44 |
+} |
|
| 45 |
+ |
|
| 41 | 46 |
/** |
| 42 |
- * 학생 회원 조회 팝업 — 모든 항목이 readOnly다. 데이터는 목록 행에서 이미 갖고 있는 member를 |
|
| 43 |
- * props로 그대로 받으므로 재조회하지 않는다(백엔드에 단건 조회 API도 아직 없다). |
|
| 44 |
- * 백엔드가 주지 않는 항목은 `-`로 표시한다. |
|
| 47 |
+ * 학생 회원 조회 팝업 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) modal 5227:809 |
|
| 45 | 48 |
* |
| 46 |
- * 사용여부는 원래 이 팝업에서 유일하게 수정 가능한 항목이었지만, 백엔드 목록 응답에 값이 없고 |
|
| 47 |
- * 변경 API도 없어 지금은 라디오·저장 버튼을 비활성으로 둔다(`_actions.ts`의 미지원 처리와 짝). |
|
| 48 |
- * 폼·Server Action 배선은 그대로 남겨 두었으므로 변경 API가 생기면 `disabled`만 걷어내면 된다. |
|
| 49 |
- * 저장(Server Action) 성공 시 자동으로 닫히는 동작도 그대로다. |
|
| 49 |
+ * 모든 항목이 조회 전용이다. 데이터는 목록 행이 이미 갖고 있는 member를 그대로 받으므로 |
|
| 50 |
+ * 재조회하지 않는다(백엔드에 단건 조회 API도 아직 없다). 백엔드가 주지 않는 값은 `-`다. |
|
| 50 | 51 |
* |
| 51 |
- * Modal이 내부적으로 portal을 쓸 수도 있어(구현 미확정 — components/ui/modal.tsx는 design |
|
| 52 |
- * 레인 소관) 저장 버튼은 footer 슬롯에서 `form={DETAIL_FORM_ID}` 속성으로 폼과 연결한다.
|
|
| 53 |
- * 네이티브 HTML의 `form` 속성은 버튼이 실제 DOM상 form의 자손이 아니어도 같은 문서 안에서 |
|
| 54 |
- * id만 일치하면 그 form을 제출한다 — footer가 body와 다른 DOM 위치에 렌더돼도 안전하다. |
|
| 52 |
+ * 시안은 보호자(이름/연락처)와 학교(학교/학년·반·번호)를 각각 한 줄로 묶는다 — 종전처럼 |
|
| 53 |
+ * 항목을 따로 두지 않고 시안의 8줄 구성을 그대로 따랐다. |
|
| 54 |
+ * |
|
| 55 |
+ * 사용여부는 이 팝업에서 유일하게 수정 가능한 항목이지만, 백엔드 목록 응답에 값이 없고 변경 |
|
| 56 |
+ * API도 없어 토글·저장 버튼을 비활성으로 둔다(`_actions.ts`의 미지원 처리와 짝). 폼·Server |
|
| 57 |
+ * Action 배선은 남겨 두었으므로 API가 생기면 `IS_ACTIVE_STATUS_EDITABLE`만 되돌리면 된다. |
|
| 58 |
+ * |
|
| 59 |
+ * 저장 버튼은 `form={DETAIL_FORM_ID}`로 폼과 이어 둔다 — FoxModal이 버튼을 `foot` 슬롯에
|
|
| 60 |
+ * 그려 폼의 자손이 아니게 되는데, 네이티브 `form` 속성은 같은 문서 안에서 id만 맞으면 그 |
|
| 61 |
+ * 폼을 제출한다. |
|
| 55 | 62 |
*/ |
| 56 | 63 |
export function StudentDetailModal({ member, onClose }: StudentDetailModalProps) {
|
| 57 | 64 |
const [state, formAction, isPending] = useActionState( |
| 58 | 65 |
updateStudentActiveStatus, |
| 59 | 66 |
INITIAL_STATE |
| 60 | 67 |
); |
| 68 |
+ const formRef = useRef<HTMLFormElement>(null); |
|
| 61 | 69 |
|
| 62 | 70 |
useEffect(() => {
|
| 63 | 71 |
if (state.status === 'success') {
|
... | ... | @@ -65,98 +73,91 @@ |
| 65 | 73 |
} |
| 66 | 74 |
}, [state, onClose]); |
| 67 | 75 |
|
| 68 |
- const gradeClassNumber = formatGradeClassNumber(member); |
|
| 76 |
+ const items: FoxDescriptionItem[] = [ |
|
| 77 |
+ { key: 'name', term: '이름', description: member.name },
|
|
| 78 |
+ { key: 'loginId', term: 'ID', description: member.loginId },
|
|
| 79 |
+ {
|
|
| 80 |
+ key: 'phoneNumber', |
|
| 81 |
+ term: '휴대전화 번호', |
|
| 82 |
+ description: formatOptionalValue(member.phoneNumber), |
|
| 83 |
+ }, |
|
| 84 |
+ {
|
|
| 85 |
+ key: 'email', |
|
| 86 |
+ term: '이메일', |
|
| 87 |
+ description: formatOptionalValue(member.email), |
|
| 88 |
+ }, |
|
| 89 |
+ {
|
|
| 90 |
+ key: 'birthDate', |
|
| 91 |
+ term: '생년월일', |
|
| 92 |
+ description: formatOptionalValue(member.birthDate), |
|
| 93 |
+ }, |
|
| 94 |
+ {
|
|
| 95 |
+ key: 'guardian', |
|
| 96 |
+ term: '보호자 이름 / 연락처', |
|
| 97 |
+ description: joinPair(member.guardianName, member.guardianPhoneNumber), |
|
| 98 |
+ }, |
|
| 99 |
+ {
|
|
| 100 |
+ key: 'school', |
|
| 101 |
+ term: '학교 / 학년·반·번호', |
|
| 102 |
+ description: joinPair( |
|
| 103 |
+ member.schoolName, |
|
| 104 |
+ formatGradeClassNumber(member) === '-' |
|
| 105 |
+ ? null |
|
| 106 |
+ : formatGradeClassNumber(member) |
|
| 107 |
+ ), |
|
| 108 |
+ }, |
|
| 109 |
+ {
|
|
| 110 |
+ key: 'isActive', |
|
| 111 |
+ term: '사용여부', |
|
| 112 |
+ description: ( |
|
| 113 |
+ <FoxToggleSwitch |
|
| 114 |
+ name="isActive" |
|
| 115 |
+ value="true" |
|
| 116 |
+ label={member.isActive ? '활성' : '비활성'}
|
|
| 117 |
+ defaultChecked={member.isActive ?? false}
|
|
| 118 |
+ disabled={!IS_ACTIVE_STATUS_EDITABLE}
|
|
| 119 |
+ /> |
|
| 120 |
+ ), |
|
| 121 |
+ }, |
|
| 122 |
+ ]; |
|
| 69 | 123 |
|
| 70 | 124 |
return ( |
| 71 |
- <Modal |
|
| 125 |
+ <FoxModal |
|
| 126 |
+ open |
|
| 127 |
+ size="sm" |
|
| 72 | 128 |
title="학생 회원 조회" |
| 73 | 129 |
onClose={onClose}
|
| 74 |
- footer={
|
|
| 130 |
+ actions={
|
|
| 75 | 131 |
<> |
| 76 |
- <Button type="button" variant="ghost" onClick={onClose}>
|
|
| 77 |
- 취소 |
|
| 78 |
- </Button> |
|
| 79 |
- <Button |
|
| 80 |
- type="submit" |
|
| 81 |
- form={DETAIL_FORM_ID}
|
|
| 82 |
- variant="primary" |
|
| 83 |
- disabled={isPending || !IS_ACTIVE_STATUS_EDITABLE}
|
|
| 132 |
+ <FoxButton |
|
| 133 |
+ type="default" |
|
| 134 |
+ size="md" |
|
| 135 |
+ label="취소" |
|
| 136 |
+ onAction={onClose}
|
|
| 137 |
+ /> |
|
| 138 |
+ {/* FoxButton에는 폼을 가리키는 prop이 없다(네이티브 `form` 속성 미노출). 버튼이
|
|
| 139 |
+ foot 슬롯에 그려져 폼의 자손이 아니므로 ref로 직접 제출한다. |
|
| 140 |
+ 비활성 사유는 감싼 span의 title로 남긴다 — 비활성 버튼은 hover를 받지 못한다. */} |
|
| 141 |
+ <span |
|
| 84 | 142 |
title={
|
| 85 |
- IS_ACTIVE_STATUS_EDITABLE |
|
| 86 |
- ? undefined |
|
| 87 |
- : ACTIVE_STATUS_UNSUPPORTED_NOTE |
|
| 143 |
+ IS_ACTIVE_STATUS_EDITABLE ? undefined : ACTIVE_STATUS_UNSUPPORTED_NOTE |
|
| 88 | 144 |
} |
| 89 | 145 |
> |
| 90 |
- {isPending ? '저장 중...' : '저장'}
|
|
| 91 |
- </Button> |
|
| 146 |
+ <FoxButton |
|
| 147 |
+ type="primary" |
|
| 148 |
+ size="md" |
|
| 149 |
+ label={isPending ? '저장 중...' : '저장'}
|
|
| 150 |
+ disabled={isPending || !IS_ACTIVE_STATUS_EDITABLE}
|
|
| 151 |
+ onAction={() => formRef.current?.requestSubmit()}
|
|
| 152 |
+ /> |
|
| 153 |
+ </span> |
|
| 92 | 154 |
</> |
| 93 | 155 |
} |
| 94 | 156 |
> |
| 95 |
- <form id={DETAIL_FORM_ID} action={formAction} className="flex flex-col gap-4">
|
|
| 157 |
+ <form ref={formRef} id={DETAIL_FORM_ID} action={formAction}>
|
|
| 96 | 158 |
<input type="hidden" name="id" value={member.id} />
|
| 97 |
- |
|
| 98 |
- <p className="text-body-sm text-foreground-muted"> |
|
| 99 |
- 본 화면의 모든 항목은 조회 전용입니다. |
|
| 100 |
- </p> |
|
| 101 |
- |
|
| 102 |
- <Field label="이름"> |
|
| 103 |
- <Input value={member.name} readOnly />
|
|
| 104 |
- </Field> |
|
| 105 |
- <Field label="ID"> |
|
| 106 |
- <Input value={member.loginId} readOnly />
|
|
| 107 |
- </Field> |
|
| 108 |
- <Field label="휴대전화 번호"> |
|
| 109 |
- <Input value={formatOptionalValue(member.phoneNumber)} readOnly />
|
|
| 110 |
- </Field> |
|
| 111 |
- <Field label="이메일"> |
|
| 112 |
- <Input value={formatOptionalValue(member.email)} readOnly />
|
|
| 113 |
- </Field> |
|
| 114 |
- <Field label="생년월일"> |
|
| 115 |
- <Input value={formatOptionalValue(member.birthDate)} readOnly />
|
|
| 116 |
- </Field> |
|
| 117 |
- <Field label="보호자 이름"> |
|
| 118 |
- <Input value={formatOptionalValue(member.guardianName)} readOnly />
|
|
| 119 |
- </Field> |
|
| 120 |
- <Field label="보호자 연락처"> |
|
| 121 |
- <Input |
|
| 122 |
- value={formatOptionalValue(member.guardianPhoneNumber)}
|
|
| 123 |
- readOnly |
|
| 124 |
- /> |
|
| 125 |
- </Field> |
|
| 126 |
- <Field label="학교"> |
|
| 127 |
- <Input value={formatOptionalValue(member.schoolName)} readOnly />
|
|
| 128 |
- </Field> |
|
| 129 |
- <Field label="학년/반/번호"> |
|
| 130 |
- <Input value={gradeClassNumber} readOnly />
|
|
| 131 |
- </Field> |
|
| 132 |
- |
|
| 133 |
- <Field label="사용여부"> |
|
| 134 |
- <RadioGroup |
|
| 135 |
- name="isActive" |
|
| 136 |
- options={ACTIVE_STATUS_OPTIONS}
|
|
| 137 |
- defaultValue={
|
|
| 138 |
- member.isActive === null |
|
| 139 |
- ? undefined |
|
| 140 |
- : String(member.isActive) |
|
| 141 |
- } |
|
| 142 |
- disabled={!IS_ACTIVE_STATUS_EDITABLE}
|
|
| 143 |
- /> |
|
| 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 |
- )} |
|
| 151 |
- |
|
| 152 |
- {state.status === 'error' && (
|
|
| 153 |
- <p className="text-body-sm text-danger">{state.error}</p>
|
|
| 154 |
- )} |
|
| 155 |
- |
|
| 156 |
- <p className="text-body-sm text-foreground-muted"> |
|
| 157 |
- 회원 가입일 : {formatOptionalValue(member.joinedAt)}
|
|
| 158 |
- </p> |
|
| 159 |
+ <FoxDescriptionList items={items} />
|
|
| 159 | 160 |
</form> |
| 160 |
- </Modal> |
|
| 161 |
+ </FoxModal> |
|
| 161 | 162 |
); |
| 162 | 163 |
} |
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?