fix: 사용여부 토글이 눌리지 않던 문제 — 화면 상태로 동작하게
토글은 백엔드에 사용여부 값도 변경 API도 없어 `disabled`로 막아 두었었다. 화면에서는 움직이게 해 달라는 결정에 따라 잠금을 풀었다(사용자 확정). - 토글 상태를 이 컴포넌트가 갖는다. 종전에는 라벨을 prop에서 계산해(`member.isActive ? …`) 잠금을 풀어도 스위치만 움직이고 글자는 그대로였을 것이다 — 함께 고쳤다. - 저장 버튼의 잠금도 풀었다. 다만 Server Action은 여전히 미지원을 알리므로, 그 사유를 모달 foot 왼쪽(utility 슬롯)에 `role="alert"`로 띄운다. 종전에는 title 속성에만 있어 눈에 띄지 않았다. - 성공 시 닫는 useEffect를 지웠다. 지금 이 액션은 성공을 돌려주지 않는다 — 변경 API가 생겨 성공 경로가 살아나면 그때 함께 되살린다. 한계 — 토글 값은 화면에만 머문다. 저장되지 않고 목록에도 반영되지 않으며 팝업을 닫으면 사라진다. 실제 저장이 필요하면 백엔드 API가 생기거나 mock 오버레이(lib/data/mock)를 두어야 한다. 검증 — 비활성↔활성으로 체크와 라벨이 함께 바뀌고(트랙 #256ef4), 저장 버튼이 활성이다. 오류 문구는 세션이 필요한 Server Action 응답이라 프리뷰에서는 경로만 확인했다. Co-Authored-By: Claude Opus 5
@30728468cbe0f935762117650e6a481b0cc54c1a
--- app/(protected)/(basic)/students/_components/student-detail-modal.module.scss
+++ app/(protected)/(basic)/students/_components/student-detail-modal.module.scss
... | ... | @@ -1,3 +1,5 @@ |
| 1 |
+@use "@fox/styles/abstracts" as fox; |
|
| 2 |
+ |
|
| 1 | 3 |
// 학생 회원 조회 팝업 — 이 화면에만 필요한 배치. |
| 2 | 4 |
|
| 3 | 5 |
// FoxModal의 `contents`가 `align-items: flex-start`인 flex 열이라, 그 안의 블록 요소는 |
... | ... | @@ -5,3 +7,13 @@ |
| 5 | 7 |
.form {
|
| 6 | 8 |
inline-size: 100%; |
| 7 | 9 |
} |
| 10 |
+ |
|
| 11 |
+// 저장 실패 사유. 사용여부 변경 API가 아직 없어 저장을 누르면 이 자리에 뜬다. |
|
| 12 |
+.error {
|
|
| 13 |
+ margin: 0; |
|
| 14 |
+ color: fox.color(font-system-danger); |
|
| 15 |
+ font-family: fox.font-family(body); |
|
| 16 |
+ font-size: fox.font-size(body-sm); |
|
| 17 |
+ line-height: 1.5; |
|
| 18 |
+ letter-spacing: -0.025em; |
|
| 19 |
+} |
--- app/(protected)/(basic)/students/_components/student-detail-modal.tsx
+++ app/(protected)/(basic)/students/_components/student-detail-modal.tsx
... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 |
-import { useActionState, useEffect, useRef } from 'react';
|
|
| 3 |
+import { useActionState, useRef, useState } from 'react';
|
|
| 4 | 4 |
import { FoxButton } from '@fox/core/components/fox-button';
|
| 5 | 5 |
import {
|
| 6 | 6 |
FoxDescriptionList, |
... | ... | @@ -26,15 +26,6 @@ |
| 26 | 26 |
|
| 27 | 27 |
const INITIAL_STATE: UpdateStudentActiveStatusState = { status: 'idle' };
|
| 28 | 28 |
const DETAIL_FORM_ID = 'student-active-status-form'; |
| 29 |
- |
|
| 30 |
-/** |
|
| 31 |
- * 사용여부 수정 가능 여부의 단일 토글. 백엔드에 사용여부 값도 변경 API도 없어 현재는 false다 — |
|
| 32 |
- * 변경 API가 생기면 이 상수를 true로 되돌리고 `_actions.ts`의 미지원 처리를 실제 구현으로 |
|
| 33 |
- * 바꾸면 된다(토글·저장 버튼의 비활성이 함께 풀린다). |
|
| 34 |
- */ |
|
| 35 |
-const IS_ACTIVE_STATUS_EDITABLE = false; |
|
| 36 |
-const ACTIVE_STATUS_UNSUPPORTED_NOTE = |
|
| 37 |
- '사용여부 변경은 백엔드 API 준비 후 제공됩니다.'; |
|
| 38 | 29 |
|
| 39 | 30 |
/** 시안은 두 값을 " / "로 묶는다("홍라애 / 010-2222-1234"). 둘 다 없으면 `-`다. */
|
| 40 | 31 |
function joinPair(first: string | null, second: string | null) {
|
... | ... | @@ -67,12 +58,9 @@ |
| 67 | 58 |
INITIAL_STATE |
| 68 | 59 |
); |
| 69 | 60 |
const formRef = useRef<HTMLFormElement>(null); |
| 70 |
- |
|
| 71 |
- useEffect(() => {
|
|
| 72 |
- if (state.status === 'success') {
|
|
| 73 |
- onClose(); |
|
| 74 |
- } |
|
| 75 |
- }, [state, onClose]); |
|
| 61 |
+ // 토글은 화면에서 바로 반응해야 하므로 이 컴포넌트가 상태를 갖는다 — prop에서 계산하면 |
|
| 62 |
+ // 스위치를 넘겨도 라벨이 그대로다. 저장이 되지 않으므로 이 값은 화면에만 머문다. |
|
| 63 |
+ const [isActive, setIsActive] = useState(member.isActive ?? false); |
|
| 76 | 64 |
|
| 77 | 65 |
const items: FoxDescriptionItem[] = [ |
| 78 | 66 |
{ key: 'name', term: '이름', description: member.name },
|
... | ... | @@ -114,9 +102,9 @@ |
| 114 | 102 |
<FoxToggleSwitch |
| 115 | 103 |
name="isActive" |
| 116 | 104 |
value="true" |
| 117 |
- label={member.isActive ? '활성' : '비활성'}
|
|
| 118 |
- defaultChecked={member.isActive ?? false}
|
|
| 119 |
- disabled={!IS_ACTIVE_STATUS_EDITABLE}
|
|
| 105 |
+ label={isActive ? '활성' : '비활성'}
|
|
| 106 |
+ checked={isActive}
|
|
| 107 |
+ onChange={(checked) => setIsActive(checked)}
|
|
| 120 | 108 |
/> |
| 121 | 109 |
), |
| 122 | 110 |
}, |
... | ... | @@ -128,6 +116,13 @@ |
| 128 | 116 |
size="sm" |
| 129 | 117 |
title="학생 회원 조회" |
| 130 | 118 |
onClose={onClose}
|
| 119 |
+ utility={
|
|
| 120 |
+ state.status === 'error' ? ( |
|
| 121 |
+ <p className={styles.error} role="alert">
|
|
| 122 |
+ {state.error}
|
|
| 123 |
+ </p> |
|
| 124 |
+ ) : undefined |
|
| 125 |
+ } |
|
| 131 | 126 |
actions={
|
| 132 | 127 |
<> |
| 133 | 128 |
<FoxButton |
... | ... | @@ -137,21 +132,14 @@ |
| 137 | 132 |
onAction={onClose}
|
| 138 | 133 |
/> |
| 139 | 134 |
{/* FoxButton에는 폼을 가리키는 prop이 없다(네이티브 `form` 속성 미노출). 버튼이
|
| 140 |
- foot 슬롯에 그려져 폼의 자손이 아니므로 ref로 직접 제출한다. |
|
| 141 |
- 비활성 사유는 감싼 span의 title로 남긴다 — 비활성 버튼은 hover를 받지 못한다. */} |
|
| 142 |
- <span |
|
| 143 |
- title={
|
|
| 144 |
- IS_ACTIVE_STATUS_EDITABLE ? undefined : ACTIVE_STATUS_UNSUPPORTED_NOTE |
|
| 145 |
- } |
|
| 146 |
- > |
|
| 147 |
- <FoxButton |
|
| 148 |
- type="primary" |
|
| 149 |
- size="md" |
|
| 150 |
- label={isPending ? '저장 중...' : '저장'}
|
|
| 151 |
- disabled={isPending || !IS_ACTIVE_STATUS_EDITABLE}
|
|
| 152 |
- onAction={() => formRef.current?.requestSubmit()}
|
|
| 153 |
- /> |
|
| 154 |
- </span> |
|
| 135 |
+ foot 슬롯에 그려져 폼의 자손이 아니므로 ref로 직접 제출한다. */} |
|
| 136 |
+ <FoxButton |
|
| 137 |
+ type="primary" |
|
| 138 |
+ size="md" |
|
| 139 |
+ label={isPending ? '저장 중...' : '저장'}
|
|
| 140 |
+ disabled={isPending}
|
|
| 141 |
+ onAction={() => formRef.current?.requestSubmit()}
|
|
| 142 |
+ /> |
|
| 155 | 143 |
</> |
| 156 | 144 |
} |
| 157 | 145 |
> |
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?