임동욱 임동욱 09-10
fix: 회원 목록에 상태 열을 넣고 가맹점 사용여부의 거짓 표시를 없앤다
가맹점: 조회 SQL이 USE_YN을 select하지 않고 MngrVocVo에도 필드가 없어 값이 오지 않는데
`!== 'N'`으로 읽어 늘 「노출」이었다. 「미노출」로 걸러 낸 행까지 전부 「노출」로 보였다.
값이 없으면 판단하지 않고(null) 목록·모달 모두 고르지 않은 상태로 둔다 — 브랜드명·메모·
등록일과 같은 처리다(사용자 확정). 신규 등록만 「노출」로 시작한다.

회원: 학생·관리자 모두 상태를 바꿀 수는 있는데 목록에 그 열이 없어 확인할 자리가 없었다.
학생은 사용여부(useYn), 관리자는 잠김여부(acctLockYn의 반대)를 열로 넣는다. 값이 없는 행은
`-`다.

Co-Authored-By: Claude Opus 5 
@84391bdb828eba2ad0edee43786bba7c51afb308
app/(protected)/(basic)/admins/_components/admin-member-list.tsx
--- app/(protected)/(basic)/admins/_components/admin-member-list.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-list.tsx
@@ -10,7 +10,9 @@
   type FoxListColumn,
 } from '@fox/core/components/fox-list-container';
 import { FoxSelectText } from '@fox/core/components/fox-select-text';
+import { FoxStatusIndicator } from '@fox/core/components/fox-status-indicator';
 import {
+  formatAdminLockStatusLabel,
   formatAdminRoleLabel,
   formatOptionalValue,
   type AdminMember,
@@ -98,6 +100,20 @@
       ),
     },
     {
+      key: 'isLocked',
+      header: '잠김여부',
+      width: 120,
+      render: (row) =>
+        row.isLocked === null ? (
+          formatAdminLockStatusLabel(row.isLocked)
+        ) : (
+          <FoxStatusIndicator
+            type={row.isLocked ? 'danger' : 'success'}
+            label={formatAdminLockStatusLabel(row.isLocked)}
+          />
+        ),
+    },
+    {
       key: 'createdAt',
       header: '생성일',
       width: 160,
app/(protected)/(basic)/students/_components/student-list.tsx
--- app/(protected)/(basic)/students/_components/student-list.tsx
+++ app/(protected)/(basic)/students/_components/student-list.tsx
@@ -9,8 +9,10 @@
   type FoxListColumn,
 } from '@fox/core/components/fox-list-container';
 import { FoxSelectText } from '@fox/core/components/fox-select-text';
+import { FoxStatusIndicator } from '@fox/core/components/fox-status-indicator';
 import { formatPhoneNumber } from '@/lib/domain/phone-number';
 import {
+  formatActiveStatusLabel,
   formatGradeClassNumber,
   type StudentMember,
 } from '@/lib/domain/student-member';
@@ -127,6 +129,20 @@
         ),
     },
     {
+      key: 'isActive',
+      header: '사용여부',
+      width: 120,
+      render: (row) =>
+        row.isActive === null ? (
+          formatActiveStatusLabel(row.isActive)
+        ) : (
+          <FoxStatusIndicator
+            type={row.isActive ? 'success' : 'danger'}
+            label={formatActiveStatusLabel(row.isActive)}
+          />
+        ),
+    },
+    {
       key: 'joinedAt',
       header: '가입일',
       width: 160,
app/(protected)/(basic)/system/merchants/_components/merchant-form-modal.tsx
--- app/(protected)/(basic)/system/merchants/_components/merchant-form-modal.tsx
+++ app/(protected)/(basic)/system/merchants/_components/merchant-form-modal.tsx
@@ -60,7 +60,11 @@
   );
 
   const [categoryCode, setCategoryCode] = useState(merchant?.categoryCode ?? '');
-  const [isVisible, setIsVisible] = useState(merchant?.isVisible ?? true);
+  // 조회 응답에 useYn이 없어 기존 가맹점은 값을 모른다 — 그때는 어느 쪽도 고르지 않는다.
+  // 신규 등록만 「노출」로 시작한다.
+  const [isVisible, setIsVisible] = useState<boolean | null>(
+    merchant === undefined ? true : merchant.isVisible
+  );
   // 주소·좌표는 검색 결과가 채운다(기획 ③ Read Only).
   const [address, setAddress] = useState(merchant?.address ?? '');
   const [postalCode, setPostalCode] = useState(merchant?.postalCode ?? '');
@@ -315,14 +319,14 @@
               disabled={isReadOnly}
               value="Y"
               label="노출"
-              checked={isVisible}
+              checked={isVisible === true}
               onChange={() => setIsVisible(true)}
             />
             <FoxRadio
               disabled={isReadOnly}
               value="N"
               label="미노출"
-              checked={!isVisible}
+              checked={isVisible === false}
               onChange={() => setIsVisible(false)}
             />
           </FoxRadioGroup>
app/(protected)/(basic)/system/merchants/_components/merchant-list.tsx
--- app/(protected)/(basic)/system/merchants/_components/merchant-list.tsx
+++ app/(protected)/(basic)/system/merchants/_components/merchant-list.tsx
@@ -91,12 +91,15 @@
       key: 'isVisible',
       header: '사용여부',
       width: 120,
-      render: (row) => (
-        <FoxStatusIndicator
-          type={row.isVisible ? 'success' : 'danger'}
-          label={formatVisibilityLabel(row.isVisible)}
-        />
-      ),
+      render: (row) =>
+        row.isVisible === null ? (
+          formatVisibilityLabel(row.isVisible)
+        ) : (
+          <FoxStatusIndicator
+            type={row.isVisible ? 'success' : 'danger'}
+            label={formatVisibilityLabel(row.isVisible)}
+          />
+        ),
     },
     {
       key: 'createdAt',
lib/data/repositories/merchant-repository.ts
--- lib/data/repositories/merchant-repository.ts
+++ lib/data/repositories/merchant-repository.ts
@@ -81,12 +81,19 @@
       postalCode: readString(raw, 'frcsPostZip'),
       latitude: readString(raw, 'lat'),
       longitude: readString(raw, 'lng'),
-      // 조회 SQL이 USE_YN을 select하지 않는다(⚠️ 5) — 값이 없으면 노출로 본다.
-      isVisible: readString(raw, 'useYn') !== 'N',
+      // 조회 SQL이 USE_YN을 select하지 않는다(⚠️ 5). 값이 없으면 판단하지 않는다 —
+      // 종전처럼 노출로 단정하면 「미노출」로 걸러 낸 행까지 전부 「노출」로 보인다.
+      isVisible: readUseYn(raw),
     },
   ];
 }
 
+/** 백엔드 `useYn`은 'Y'/'N' 문자열이다. 응답에 없으면 판단하지 않고 null로 둔다. */
+function readUseYn(raw: Record<string, unknown>): boolean | null {
+  const value = raw.useYn;
+  return typeof value === 'string' && value !== '' ? value.toUpperCase() === 'Y' : null;
+}
+
 export async function fetchMerchants(
   query: MerchantQuery
 ): Promise<{ items: Merchant[]; totalCount: number }> {
lib/domain/merchant.ts
--- lib/domain/merchant.ts
+++ lib/domain/merchant.ts
@@ -25,7 +25,11 @@
   postalCode: string;
   latitude: string;
   longitude: string;
-  isVisible: boolean;
+  /**
+   * 조회 응답에 `useYn`이 없다 — 목록 SQL의 SELECT 목록에도 `MngrVocVo`에도 없고 검색 조건
+   * (`searchUseYn`)에만 쓰인다. 그래서 **모르는 값이 정상**이라 `null`을 둔다(위 넷과 같은 이유).
+   */
+  isVisible: boolean | null;
 }
 
 export const EMPTY_FIELD_PLACEHOLDER = '-';
@@ -66,7 +70,10 @@
   );
 }
 
-export function formatVisibilityLabel(isVisible: boolean): string {
+export function formatVisibilityLabel(isVisible: boolean | null): string {
+  if (isVisible === null) {
+    return EMPTY_FIELD_PLACEHOLDER;
+  }
   return isVisible ? '노출' : '미노출';
 }
 
lib/domain/student-member.ts
--- lib/domain/student-member.ts
+++ lib/domain/student-member.ts
@@ -40,7 +40,7 @@
   joinedAt: string | null;
   /** ISO 형식(YYYY-MM-DD) 문자열. */
   birthDate: string | null;
-  /** 사용여부. 백엔드가 값도 변경 API도 제공하지 않아 현재는 항상 null이다. */
+  /** 백엔드 `useYn`. 응답에 없는 행은 null이라 화면에서 `-`로 보인다. */
   isActive: boolean | null;
 };
 
@@ -54,6 +54,14 @@
   return value === null ? EMPTY_FIELD_PLACEHOLDER : String(value);
 }
 
+/** 사용여부 표기. 값이 없으면(백엔드가 `useYn`을 주지 않은 행) `-`다. */
+export function formatActiveStatusLabel(isActive: boolean | null): string {
+  if (isActive === null) {
+    return EMPTY_FIELD_PLACEHOLDER;
+  }
+  return isActive ? '활성' : '비활성';
+}
+
 /**
  * "학년/반/번호" 합성 표기. 표와 조회 팝업이 같은 규칙을 쓰도록 한 곳에 둔다.
  *
Add a comment
List