Merge branch 'develop' of http://git.dataeum.kr/ldu0009/edupay-admin into develop
# Conflicts: # @fox/core/components/index.ts # @fox/styles/components.scss
@0fa903363aae453f73837d60b32a4d547f4f89d5
--- @fox/README.md
+++ @fox/README.md
... | ... | @@ -16,6 +16,7 @@ |
| 16 | 16 |
_mixins.scss fox.pc / fox.mobile |
| 17 | 17 |
core/ |
| 18 | 18 |
components/ React 래퍼 (선택 — 아래 "React 없이 쓰기" 참고) |
| 19 |
+ validation/ 입력 검증기 (프레임워크 무관 — 아래 "입력 검증" 참고) |
|
| 19 | 20 |
utils/ cx() 등 의존성 없는 유틸 |
| 20 | 21 |
dev-test/ 개발 전용 테스트·감사 화면 |
| 21 | 22 |
tools/build-tokens.py Figma JSON → SCSS 변환기 |
... | ... | @@ -64,6 +65,45 @@ |
| 64 | 65 |
6. **폰트를 공급합니다** — 아래 "호스트 앱과의 계약" 참고. 빠뜨리면 컴포넌트가 OS 기본 |
| 65 | 66 |
서체로 그려져 글자 폭이 시안과 어긋납니다. |
| 66 | 67 |
|
| 68 |
+## 입력 검증 |
|
| 69 |
+ |
|
| 70 |
+`@fox/core/validation`은 Angular의 `Validators` 계약을 그대로 옮긴 것입니다. **검증기는 값을 받아 |
|
| 71 |
+통과면 `null`, 아니면 오류 객체를 돌려주는 순수 함수**이고, 오류 객체의 키가 오류의 이름입니다. |
|
| 72 |
+ |
|
| 73 |
+```ts |
|
| 74 |
+import { foxValidators, foxPasswordValidator, foxValidationMessage } from "@fox/core/validation";
|
|
| 75 |
+ |
|
| 76 |
+foxValidators.minLength(10)("abc");
|
|
| 77 |
+// → { minlength: { requiredLength: 10, actualLength: 3 } }
|
|
| 78 |
+``` |
|
| 79 |
+ |
|
| 80 |
+문구는 검증기가 정하지 않습니다(Angular와 같은 이유입니다) — 같은 규칙도 자리에 따라 다른 말로 |
|
| 81 |
+안내해야 하기 때문입니다. `foxValidationMessage(errors, overrides)`가 오류를 문구 하나로 옮기고, |
|
| 82 |
+`overrides`로 자리마다 덮어씁니다. |
|
| 83 |
+ |
|
| 84 |
+`FoxInput`은 이것을 `validators`·`validationMessages`·`validateOn`·`onValidationChange`로 받습니다. |
|
| 85 |
+`type="password"`처럼 규칙이 여러 개인 칸은 `foxPasswordValidator`가 한 벌로 묶어 줍니다. |
|
| 86 |
+ |
|
| 87 |
+```tsx |
|
| 88 |
+<FoxInput |
|
| 89 |
+ type="password" |
|
| 90 |
+ label="비밀번호" |
|
| 91 |
+ message="영문·숫자·특수문자 중 2종류 이상, 10자리 이상" // 평소엔 헬퍼 |
|
| 92 |
+ validators={[foxPasswordValidator({ minLength: 10, kinds: 2, required: true })]}
|
|
| 93 |
+/> |
|
| 94 |
+``` |
|
| 95 |
+ |
|
| 96 |
+- `message`는 평소 **헬퍼**이고, 검증이 걸리면 그 자리에 사유가 대신 들어갑니다. 호출부가 |
|
| 97 |
+ `invalid`를 직접 켠 경우에는 `message`가 이깁니다 — 서버가 돌려준 사유(중복·권한 등)는 화면 |
|
| 98 |
+ 규칙이 알 수 없는 것이라 덮이면 안 됩니다. |
|
| 99 |
+- 오류는 **한 번 포커스를 벗어난 뒤부터** 보입니다(`validateOn="blur"`, 기본값). 한 글자 쳤을 때 |
|
| 100 |
+ "10자 이상"이 뜨면 안내가 아니라 방해가 되기 때문입니다. 벗어난 뒤로는 입력할 때마다 갱신됩니다 |
|
| 101 |
+ (Angular의 `touched`와 같은 판단입니다 — 다만 Angular `updateOn`의 기본값은 `change`입니다). |
|
| 102 |
+- **화면 검증은 안내일 뿐 신뢰 경계가 아닙니다.** 최종 판정은 저장 직전의 서버 검증입니다. |
|
| 103 |
+ |
|
| 104 |
+검증기는 React에 의존하지 않으므로 다른 프레임워크·서버 코드에서도 그대로 씁니다 — 실제로 이 |
|
| 105 |
+저장소는 같은 규칙 값을 화면과 Server Action이 함께 봅니다. |
|
| 106 |
+ |
|
| 67 | 107 |
## React 없이 쓰기 |
| 68 | 108 |
|
| 69 | 109 |
**디자인 레이어는 프레임워크에 의존하지 않습니다.** 토큰도 컴포넌트 스타일도 순수 SCSS이고, |
+++ @fox/core/components/fox-accordion/fox-accordion.tsx
... | ... | @@ -0,0 +1,98 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useId, useState, type ReactNode } from "react"; | |
| 4 | +import { cx } from "../../utils"; | |
| 5 | +import { FoxCaretDownIcon, FoxCaretUpIcon } from "../../icons"; | |
| 6 | + | |
| 7 | +/** `box`는 색을 깐 상자, `line`은 위쪽 선 하나로 나뉘는 목록형이다. */ | |
| 8 | +export type FoxAccordionType = "box" | "line"; | |
| 9 | +export type FoxAccordionSize = "lg" | "md"; | |
| 10 | + | |
| 11 | +export interface FoxAccordionProps { | |
| 12 | + title: ReactNode; | |
| 13 | + children?: ReactNode; | |
| 14 | + type?: FoxAccordionType; | |
| 15 | + size?: FoxAccordionSize; | |
| 16 | + /** 제목 앞자리. 시안은 배지 묶음이다. */ | |
| 17 | + badge?: ReactNode; | |
| 18 | + /** 주면 호출부가 열림을 소유한다. 없으면 컴포넌트가 스스로 여닫는다. */ | |
| 19 | + open?: boolean; | |
| 20 | + defaultOpen?: boolean; | |
| 21 | + onOpenChange?: (open: boolean) => void; | |
| 22 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 23 | + hidden?: boolean; | |
| 24 | + /** 배치 조정용. */ | |
| 25 | + className?: string; | |
| 26 | +} | |
| 27 | + | |
| 28 | +/** | |
| 29 | + * @fox 아코디언. 제목을 누르면 아래 내용이 열린다. | |
| 30 | + * | |
| 31 | + * 열림을 밖에서 쥐면 여러 개 중 하나만 열리게 하는 것도 호출부가 정할 수 있다. 넘기지 | |
| 32 | + * 않으면 스스로 여닫는다. | |
| 33 | + * | |
| 34 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 35 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 36 | + */ | |
| 37 | +export function FoxAccordion({ | |
| 38 | + title, | |
| 39 | + children, | |
| 40 | + type = "box", | |
| 41 | + size = "lg", | |
| 42 | + badge, | |
| 43 | + open, | |
| 44 | + defaultOpen = false, | |
| 45 | + onOpenChange, | |
| 46 | + hidden = false, | |
| 47 | + className, | |
| 48 | +}: FoxAccordionProps) { | |
| 49 | + const autoId = useId(); | |
| 50 | + const [inner, setInner] = useState(defaultOpen); | |
| 51 | + | |
| 52 | + if (hidden) { | |
| 53 | + return null; | |
| 54 | + } | |
| 55 | + | |
| 56 | + const expanded = open ?? inner; | |
| 57 | + const panelId = `${autoId}-panel`; | |
| 58 | + | |
| 59 | + const toggle = () => { | |
| 60 | + if (open === undefined) { | |
| 61 | + setInner(!expanded); | |
| 62 | + } | |
| 63 | + onOpenChange?.(!expanded); | |
| 64 | + }; | |
| 65 | + | |
| 66 | + return ( | |
| 67 | + <div | |
| 68 | + className={cx( | |
| 69 | + "fox-accordion", | |
| 70 | + `fox-accordion--${type}`, | |
| 71 | + `fox-accordion--${size}`, | |
| 72 | + expanded && "fox-accordion--open", | |
| 73 | + className | |
| 74 | + )} | |
| 75 | + > | |
| 76 | + <button | |
| 77 | + className="fox-accordion__tit" | |
| 78 | + type="button" | |
| 79 | + aria-expanded={expanded} | |
| 80 | + aria-controls={panelId} | |
| 81 | + onClick={toggle} | |
| 82 | + > | |
| 83 | + {badge && <span className="fox-accordion__badge">{badge}</span>} | |
| 84 | + <span className="fox-accordion__title">{title}</span> | |
| 85 | + <span className="fox-accordion__icon" aria-hidden="true"> | |
| 86 | + {expanded ? <FoxCaretUpIcon /> : <FoxCaretDownIcon />} | |
| 87 | + </span> | |
| 88 | + </button> | |
| 89 | + | |
| 90 | + {/* 닫혔을 때는 DOM에서 뺀다 — 안의 초점 가능한 것들이 탭 순서에 남으면 안 된다. */} | |
| 91 | + {expanded && ( | |
| 92 | + <div className="fox-accordion__contents" id={panelId}> | |
| 93 | + {children} | |
| 94 | + </div> | |
| 95 | + )} | |
| 96 | + </div> | |
| 97 | + ); | |
| 98 | +} |
+++ @fox/core/components/fox-accordion/index.ts
... | ... | @@ -0,0 +1,6 @@ |
| 1 | +export { | |
| 2 | + FoxAccordion, | |
| 3 | + type FoxAccordionProps, | |
| 4 | + type FoxAccordionType, | |
| 5 | + type FoxAccordionSize, | |
| 6 | +} from "./fox-accordion"; |
+++ @fox/core/components/fox-card/fox-card.tsx
... | ... | @@ -0,0 +1,61 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import type { ReactNode } from "react"; | |
| 4 | +import { cx } from "../../utils"; | |
| 5 | + | |
| 6 | +export type FoxCardSize = "xlg" | "lg" | "md" | "sm"; | |
| 7 | + | |
| 8 | +export interface FoxCardProps { | |
| 9 | + size?: FoxCardSize; | |
| 10 | + /** 위쪽 그림. 시안은 16:9다. */ | |
| 11 | + image?: ReactNode; | |
| 12 | + title?: ReactNode; | |
| 13 | + /** 제목 아래 한두 줄 설명. */ | |
| 14 | + description?: ReactNode; | |
| 15 | + /** 제목·설명 대신 넣을 것. 주면 `title`·`description`은 쓰이지 않는다. */ | |
| 16 | + children?: ReactNode; | |
| 17 | + /** 아래쪽 줄. 시안은 글자 버튼이다. */ | |
| 18 | + actions?: ReactNode; | |
| 19 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 20 | + hidden?: boolean; | |
| 21 | + /** 배치 조정용. */ | |
| 22 | + className?: string; | |
| 23 | +} | |
| 24 | + | |
| 25 | +/** | |
| 26 | + * @fox 카드. 그림·글·동작이 위에서 아래로 쌓인다. | |
| 27 | + * | |
| 28 | + * 크기는 여백과 모서리만 바꾼다 — 폭은 놓이는 자리가 정한다. | |
| 29 | + * | |
| 30 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 31 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 32 | + */ | |
| 33 | +export function FoxCard({ | |
| 34 | + size = "md", | |
| 35 | + image, | |
| 36 | + title, | |
| 37 | + description, | |
| 38 | + children, | |
| 39 | + actions, | |
| 40 | + hidden = false, | |
| 41 | + className, | |
| 42 | +}: FoxCardProps) { | |
| 43 | + if (hidden) { | |
| 44 | + return null; | |
| 45 | + } | |
| 46 | + | |
| 47 | + return ( | |
| 48 | + <div className={cx("fox-card", `fox-card--${size}`, className)}> | |
| 49 | + {image && <div className="fox-card__image">{image}</div>} | |
| 50 | + <div className="fox-card__contents"> | |
| 51 | + {children ?? ( | |
| 52 | + <> | |
| 53 | + {title && <p className="fox-card__title">{title}</p>} | |
| 54 | + {description && <p className="fox-card__description">{description}</p>} | |
| 55 | + </> | |
| 56 | + )} | |
| 57 | + </div> | |
| 58 | + {actions && <div className="fox-card__actions">{actions}</div>} | |
| 59 | + </div> | |
| 60 | + ); | |
| 61 | +} |
+++ @fox/core/components/fox-card/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export { FoxCard, type FoxCardProps, type FoxCardSize } from "./fox-card"; |
--- @fox/core/components/fox-chip-area/fox-chip-area.tsx
+++ @fox/core/components/fox-chip-area/fox-chip-area.tsx
... | ... | @@ -24,6 +24,8 @@ |
| 24 | 24 |
label?: string; |
| 25 | 25 |
/** 묶음 이름 역할을 하는 요소의 id. `label`보다 우선한다 — 화면의 글자와 어긋나지 않는다. */ |
| 26 | 26 |
labelledBy?: string; |
| 27 |
+ /** 칩이 한 줄에 다 들어가지 않으면 다음 줄로 흘린다(`FoxButtonGroup`과 같은 규약). */ |
|
| 28 |
+ wrap?: boolean; |
|
| 27 | 29 |
/** `FoxChip`들. */ |
| 28 | 30 |
children?: ReactNode; |
| 29 | 31 |
/** 배치 조정용. */ |
... | ... | @@ -77,6 +79,7 @@ |
| 77 | 79 |
size, |
| 78 | 80 |
label, |
| 79 | 81 |
labelledBy, |
| 82 |
+ wrap = false, |
|
| 80 | 83 |
children, |
| 81 | 84 |
className, |
| 82 | 85 |
ref, |
... | ... | @@ -93,7 +96,7 @@ |
| 93 | 96 |
role="group" |
| 94 | 97 |
aria-label={labelledBy ? undefined : label}
|
| 95 | 98 |
aria-labelledby={labelledBy}
|
| 96 |
- className={cx("fox-chip-area", className)}
|
|
| 99 |
+ className={cx("fox-chip-area", wrap && "fox-chip-area--wrap", className)}
|
|
| 97 | 100 |
> |
| 98 | 101 |
{overrideChildren(children, overrides)}
|
| 99 | 102 |
</div> |
+++ @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/fox-disclosure/fox-disclosure.tsx
... | ... | @@ -0,0 +1,84 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useId, useState, type ReactNode } from "react"; | |
| 4 | +import { cx } from "../../utils"; | |
| 5 | +import { FoxCaretCircleRightIcon, FoxCaretCircleUpIcon } from "../../icons"; | |
| 6 | + | |
| 7 | +export interface FoxDisclosureProps { | |
| 8 | + title: ReactNode; | |
| 9 | + children?: ReactNode; | |
| 10 | + /** 주면 호출부가 열림을 소유한다. 없으면 컴포넌트가 스스로 여닫는다. */ | |
| 11 | + open?: boolean; | |
| 12 | + defaultOpen?: boolean; | |
| 13 | + onOpenChange?: (open: boolean) => void; | |
| 14 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 15 | + hidden?: boolean; | |
| 16 | + /** 배치 조정용. */ | |
| 17 | + className?: string; | |
| 18 | +} | |
| 19 | + | |
| 20 | +/** | |
| 21 | + * @fox 디스클로저. 제목 한 줄을 누르면 아래 내용이 열린다. | |
| 22 | + * | |
| 23 | + * FoxAccordion과 겹쳐 보이지만 시안이 따로 둔 별개 컴포넌트다 — 바탕·테두리·크기 축이 | |
| 24 | + * 없고 캐럿이 제목 앞에 온다. 목록을 나누는 아코디언과 달리 글 사이에 끼우는 접기다. | |
| 25 | + * | |
| 26 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 27 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 28 | + */ | |
| 29 | +export function FoxDisclosure({ | |
| 30 | + title, | |
| 31 | + children, | |
| 32 | + open, | |
| 33 | + defaultOpen = false, | |
| 34 | + onOpenChange, | |
| 35 | + hidden = false, | |
| 36 | + className, | |
| 37 | +}: FoxDisclosureProps) { | |
| 38 | + const autoId = useId(); | |
| 39 | + const [inner, setInner] = useState(defaultOpen); | |
| 40 | + | |
| 41 | + if (hidden) { | |
| 42 | + return null; | |
| 43 | + } | |
| 44 | + | |
| 45 | + const expanded = open ?? inner; | |
| 46 | + const panelId = `${autoId}-panel`; | |
| 47 | + | |
| 48 | + const toggle = () => { | |
| 49 | + if (open === undefined) { | |
| 50 | + setInner(!expanded); | |
| 51 | + } | |
| 52 | + onOpenChange?.(!expanded); | |
| 53 | + }; | |
| 54 | + | |
| 55 | + return ( | |
| 56 | + <div | |
| 57 | + className={cx( | |
| 58 | + "fox-disclosure", | |
| 59 | + expanded && "fox-disclosure--open", | |
| 60 | + className | |
| 61 | + )} | |
| 62 | + > | |
| 63 | + <button | |
| 64 | + className="fox-disclosure__tit" | |
| 65 | + type="button" | |
| 66 | + aria-expanded={expanded} | |
| 67 | + aria-controls={panelId} | |
| 68 | + onClick={toggle} | |
| 69 | + > | |
| 70 | + <span className="fox-disclosure__icon" aria-hidden="true"> | |
| 71 | + {expanded ? <FoxCaretCircleUpIcon /> : <FoxCaretCircleRightIcon />} | |
| 72 | + </span> | |
| 73 | + <span className="fox-disclosure__title">{title}</span> | |
| 74 | + </button> | |
| 75 | + | |
| 76 | + {/* 닫혔을 때는 DOM에서 뺀다 — 안의 초점 가능한 것들이 탭 순서에 남으면 안 된다. */} | |
| 77 | + {expanded && ( | |
| 78 | + <div className="fox-disclosure__contents" id={panelId}> | |
| 79 | + {children} | |
| 80 | + </div> | |
| 81 | + )} | |
| 82 | + </div> | |
| 83 | + ); | |
| 84 | +} |
+++ @fox/core/components/fox-disclosure/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export { FoxDisclosure, type FoxDisclosureProps } from "./fox-disclosure"; |
--- @fox/core/components/fox-file-upload/fox-file-upload.tsx
+++ @fox/core/components/fox-file-upload/fox-file-upload.tsx
... | ... | @@ -9,7 +9,7 @@ |
| 9 | 9 |
type CSSProperties, |
| 10 | 10 |
type DragEvent, |
| 11 | 11 |
} from "react"; |
| 12 |
-import { FoxFormLabel } from "../fox-form-label";
|
|
| 12 |
+import { FoxFormLabel, type FoxFormLabelRequirement } from "../fox-form-label";
|
|
| 13 | 13 |
import { cx } from "../../utils";
|
| 14 | 14 |
import { FoxButton } from "../fox-button";
|
| 15 | 15 |
import { FoxIconButton } from "../fox-icon-button";
|
... | ... | @@ -47,6 +47,11 @@ |
| 47 | 47 |
accept?: string; |
| 48 | 48 |
multiple?: boolean; |
| 49 | 49 |
/** |
| 50 |
+ * 네이티브 파일 입력의 `name`. 주면 고른 파일이 감싼 `<form>`의 제출에 그대로 실린다 — |
|
| 51 |
+ * 값을 따로 들고 있지 않아도 서버로 넘어간다. |
|
| 52 |
+ */ |
|
| 53 |
+ name?: string; |
|
| 54 |
+ /** |
|
| 50 | 55 |
* `default`·`area`에서 목록을 보여준다. `image`에는 목록이 없다. |
| 51 | 56 |
* 넘기지 않으면 `area`이거나 여러 개를 받을 때만 켜진다 — 파일 하나는 칸이 곧 목록이다. |
| 52 | 57 |
*/ |
... | ... | @@ -55,6 +60,8 @@ |
| 55 | 60 |
droppable?: boolean; |
| 56 | 61 |
|
| 57 | 62 |
label?: string; |
| 63 |
+ /** 라벨 뒤 필수·선택 표시. `label`이 없으면 의미 없다(`FoxInput`·`FoxSelect`와 같은 규약). */ |
|
| 64 |
+ requirement?: FoxFormLabelRequirement; |
|
| 58 | 65 |
description?: string; |
| 59 | 66 |
placeholder?: string; |
| 60 | 67 |
areaText?: string; |
... | ... | @@ -188,9 +195,11 @@ |
| 188 | 195 |
onRemove, |
| 189 | 196 |
accept, |
| 190 | 197 |
multiple = false, |
| 198 |
+ name, |
|
| 191 | 199 |
showList, |
| 192 | 200 |
droppable = false, |
| 193 | 201 |
label, |
| 202 |
+ requirement, |
|
| 194 | 203 |
description, |
| 195 | 204 |
placeholder = "파일을 선택해 주세요.", |
| 196 | 205 |
areaText = DEFAULT_AREA_TEXT, |
... | ... | @@ -318,7 +327,7 @@ |
| 318 | 327 |
onDrop={handleDrop}
|
| 319 | 328 |
> |
| 320 | 329 |
{label && (
|
| 321 |
- <FoxFormLabel as="span" id={`${autoId}-label`}>
|
|
| 330 |
+ <FoxFormLabel as="span" id={`${autoId}-label`} requirement={requirement}>
|
|
| 322 | 331 |
{label}
|
| 323 | 332 |
</FoxFormLabel> |
| 324 | 333 |
)} |
... | ... | @@ -327,6 +336,7 @@ |
| 327 | 336 |
<input |
| 328 | 337 |
ref={pickerRef}
|
| 329 | 338 |
type="file" |
| 339 |
+ name={name}
|
|
| 330 | 340 |
accept={accept}
|
| 331 | 341 |
multiple={multiple}
|
| 332 | 342 |
disabled={disabled}
|
--- @fox/core/components/fox-input/fox-input.tsx
+++ @fox/core/components/fox-input/fox-input.tsx
... | ... | @@ -1,16 +1,25 @@ |
| 1 | 1 |
"use client"; |
| 2 | 2 |
|
| 3 | 3 |
import {
|
| 4 |
+ useEffect, |
|
| 4 | 5 |
useId, |
| 5 | 6 |
useRef, |
| 6 | 7 |
useState, |
| 7 | 8 |
type ChangeEvent, |
| 8 | 9 |
type ComponentPropsWithRef, |
| 10 |
+ type FocusEvent, |
|
| 9 | 11 |
type FormEvent, |
| 10 | 12 |
type ReactNode, |
| 11 | 13 |
} from "react"; |
| 12 | 14 |
import { cx } from "../../utils";
|
| 13 |
-import { FoxFormLabel } from "../fox-form-label";
|
|
| 15 |
+import { FoxFormLabel, type FoxFormLabelRequirement } from "../fox-form-label";
|
|
| 16 |
+import {
|
|
| 17 |
+ foxValidationMessage, |
|
| 18 |
+ foxValidators, |
|
| 19 |
+ type FoxValidationErrors, |
|
| 20 |
+ type FoxValidationMessages, |
|
| 21 |
+ type FoxValidator, |
|
| 22 |
+} from "../../validation"; |
|
| 14 | 23 |
|
| 15 | 24 |
export type FoxInputSize = "lg" | "md" | "sm"; |
| 16 | 25 |
export type FoxInputState = "default" | "focused" | "completed" | "error" | "disabled" | "view"; |
... | ... | @@ -31,12 +40,16 @@ |
| 31 | 40 |
onChange?: (value: string, event?: ChangeEvent<HTMLInputElement>) => void; |
| 32 | 41 |
onInput?: (value: string, event: FormEvent<HTMLInputElement>) => void; |
| 33 | 42 |
label?: string; |
| 43 |
+ /** 라벨 뒤 필수·선택 표시. `label`이 없으면 의미 없다(`FoxSelect`와 같은 규약). */ |
|
| 44 |
+ requirement?: FoxFormLabelRequirement; |
|
| 34 | 45 |
/** 헬퍼 메시지. 색은 상태를 따라간다. */ |
| 35 | 46 |
message?: string; |
| 36 | 47 |
/** 헬퍼 메시지 앞 아이콘. `currentColor`로 그려야 상태 색을 따라간다. */ |
| 37 | 48 |
messageIcon?: ReactNode; |
| 38 | 49 |
/** 입력 오른쪽 아이콘. `currentColor`로 그린 SVG여야 색이 적용된다. */ |
| 39 | 50 |
icon?: ReactNode; |
| 51 |
+ /** 입력 오른쪽에 붙는 글자. 아이콘 슬롯과 달리 크기가 글자에 맞춰진다. */ |
|
| 52 |
+ suffix?: ReactNode; |
|
| 40 | 53 |
/** 포커스 중이고 값이 있으면 지우기 버튼을 보여준다. */ |
| 41 | 54 |
clearable?: boolean; |
| 42 | 55 |
clearLabel?: string; |
... | ... | @@ -44,6 +57,24 @@ |
| 44 | 57 |
invalid?: boolean; |
| 45 | 58 |
/** 주면 모양이 그 상태로 고정된다. 없으면 포커스·값·비활성으로 브라우저가 판단한다. */ |
| 46 | 59 |
state?: FoxInputState; |
| 60 |
+ |
|
| 61 |
+ /** |
|
| 62 |
+ * 값 검증기. 여럿이면 오류가 합쳐지고(`foxValidators.compose`와 같다) 그중 하나가 문구가 된다. |
|
| 63 |
+ * `type="password"`에는 `foxPasswordValidator(...)`를 그대로 얹으면 된다. |
|
| 64 |
+ * |
|
| 65 |
+ * 화면 검증은 **안내일 뿐 신뢰 경계가 아니다** — 저장 직전의 서버 검증이 최종 판정이다. |
|
| 66 |
+ */ |
|
| 67 |
+ validators?: FoxValidator[]; |
|
| 68 |
+ /** 오류 키별 문구 덮어쓰기. 주지 않으면 @fox의 기본 문구가 나온다. */ |
|
| 69 |
+ validationMessages?: FoxValidationMessages; |
|
| 70 |
+ /** |
|
| 71 |
+ * 언제부터 오류를 보여줄지. 기본 `blur` — 한 글자 쳤을 때 "10자 이상"이 뜨면 안내가 아니라 |
|
| 72 |
+ * 방해가 된다. 한 번 벗어난 뒤로는 두 모드 모두 입력할 때마다 갱신된다(Angular의 `touched`와 |
|
| 73 |
+ * 같은 판단이다. 다만 Angular의 `updateOn` 기본값은 `change`라 그 점만 다르다). |
|
| 74 |
+ */ |
|
| 75 |
+ validateOn?: "blur" | "change"; |
|
| 76 |
+ /** 검증 결과가 바뀔 때. 호출부가 저장 버튼을 잠그는 데 쓴다. */ |
|
| 77 |
+ onValidationChange?: (errors: FoxValidationErrors | null) => void; |
|
| 47 | 78 |
/** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ |
| 48 | 79 |
hidden?: boolean; |
| 49 | 80 |
/** 배치 조정용. */ |
... | ... | @@ -65,18 +96,29 @@ |
| 65 | 96 |
* ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` |
| 66 | 97 |
* (또는 개별 파티셜)로 한 번 불러와야 한다. |
| 67 | 98 |
*/ |
| 99 |
+/** 오류 객체를 비교 가능한 문자열로. 매 렌더 새 객체가 나와 참조로는 견줄 수 없다. */ |
|
| 100 |
+function errorKey(errors: FoxValidationErrors | null): string {
|
|
| 101 |
+ return errors ? Object.keys(errors).sort().join("|") : "";
|
|
| 102 |
+} |
|
| 103 |
+ |
|
| 68 | 104 |
export function FoxInput({
|
| 69 | 105 |
size = "md", |
| 70 | 106 |
onChange, |
| 71 | 107 |
onInput, |
| 72 | 108 |
label, |
| 109 |
+ requirement, |
|
| 73 | 110 |
message, |
| 74 | 111 |
messageIcon, |
| 75 | 112 |
icon, |
| 113 |
+ suffix, |
|
| 76 | 114 |
clearable = false, |
| 77 | 115 |
clearLabel = "입력 지우기", |
| 78 | 116 |
invalid = false, |
| 79 | 117 |
state, |
| 118 |
+ validators, |
|
| 119 |
+ validationMessages, |
|
| 120 |
+ validateOn = "blur", |
|
| 121 |
+ onValidationChange, |
|
| 80 | 122 |
hidden = false, |
| 81 | 123 |
className, |
| 82 | 124 |
id, |
... | ... | @@ -91,6 +133,28 @@ |
| 91 | 133 |
const [typedIn, setTypedIn] = useState(() => String(defaultValue ?? "").length > 0); |
| 92 | 134 |
const fieldRef = useRef<HTMLInputElement>(null); |
| 93 | 135 |
|
| 136 |
+ // 값을 밖에서 들지 않는(비제어) 호출부도 있어 검증할 값을 직접 따라간다. |
|
| 137 |
+ const [draft, setDraft] = useState(() => String(defaultValue ?? "")); |
|
| 138 |
+ const [touched, setTouched] = useState(false); |
|
| 139 |
+ |
|
| 140 |
+ const validationErrors = |
|
| 141 |
+ validators && validators.length > 0 |
|
| 142 |
+ ? foxValidators.compose(validators)( |
|
| 143 |
+ value !== undefined ? String(value) : draft |
|
| 144 |
+ ) |
|
| 145 |
+ : null; |
|
| 146 |
+ |
|
| 147 |
+ // 알림은 렌더가 끝난 뒤에 낸다 — 렌더 도중 부모 상태를 바꾸면 React가 경고한다. |
|
| 148 |
+ // 매 렌더 새 객체가 나오므로 의존은 오류 **키 목록**으로 잡는다(값 자체는 ref로 읽는다). |
|
| 149 |
+ const errorsKey = errorKey(validationErrors); |
|
| 150 |
+ const latestRef = useRef({ errors: validationErrors, notify: onValidationChange });
|
|
| 151 |
+ useEffect(() => {
|
|
| 152 |
+ latestRef.current = { errors: validationErrors, notify: onValidationChange };
|
|
| 153 |
+ }); |
|
| 154 |
+ useEffect(() => {
|
|
| 155 |
+ latestRef.current.notify?.(latestRef.current.errors); |
|
| 156 |
+ }, [errorsKey]); |
|
| 157 |
+ |
|
| 94 | 158 |
if (hidden) {
|
| 95 | 159 |
return null; |
| 96 | 160 |
} |
... | ... | @@ -98,7 +162,19 @@ |
| 98 | 162 |
const fieldId = id ?? `${autoId}-field`;
|
| 99 | 163 |
const messageId = `${autoId}-message`;
|
| 100 | 164 |
const hasValue = value !== undefined ? String(value).length > 0 : typedIn; |
| 101 |
- const errored = invalid || state === "error"; |
|
| 165 |
+ |
|
| 166 |
+ const showValidation = validateOn === "change" || touched; |
|
| 167 |
+ const validationText = showValidation |
|
| 168 |
+ ? foxValidationMessage(validationErrors, validationMessages) |
|
| 169 |
+ : undefined; |
|
| 170 |
+ |
|
| 171 |
+ // `message`는 평소엔 **헬퍼**이고 검증이 걸리면 그 문구에 자리를 내준다(시안이 그 자리에 |
|
| 172 |
+ // 규칙 안내를 두고, 규칙을 어기면 같은 자리가 사유로 바뀐다). |
|
| 173 |
+ // 다만 호출부가 `invalid`를 직접 켰으면 그쪽이 이긴다 — 서버가 돌려준 사유는 화면 규칙이 |
|
| 174 |
+ // 알 수 없는 것이라(중복·권한 등) 덮이면 안 된다. |
|
| 175 |
+ const callerErrored = invalid || state === "error"; |
|
| 176 |
+ const shownMessage = callerErrored ? message : (validationText ?? message); |
|
| 177 |
+ const errored = callerErrored || validationText !== undefined; |
|
| 102 | 178 |
|
| 103 | 179 |
// 호출부 ref와 내부 ref를 함께 채운다 — 지우기가 비제어 입력의 DOM 값을 비워야 한다. |
| 104 | 180 |
const attachField = (node: HTMLInputElement | null) => {
|
... | ... | @@ -114,7 +190,13 @@ |
| 114 | 190 |
if (value === undefined) {
|
| 115 | 191 |
setTypedIn(event.target.value.length > 0); |
| 116 | 192 |
} |
| 193 |
+ setDraft(event.target.value); |
|
| 117 | 194 |
onChange?.(event.target.value, event); |
| 195 |
+ }; |
|
| 196 |
+ |
|
| 197 |
+ const handleBlur = (event: FocusEvent<HTMLInputElement>) => {
|
|
| 198 |
+ setTouched(true); |
|
| 199 |
+ rest.onBlur?.(event); |
|
| 118 | 200 |
}; |
| 119 | 201 |
|
| 120 | 202 |
const handleInput = (event: FormEvent<HTMLInputElement>) => {
|
... | ... | @@ -129,6 +211,7 @@ |
| 129 | 211 |
field.focus(); |
| 130 | 212 |
} |
| 131 | 213 |
setTypedIn(false); |
| 214 |
+ setDraft("");
|
|
| 132 | 215 |
onChange?.("");
|
| 133 | 216 |
}; |
| 134 | 217 |
|
... | ... | @@ -138,7 +221,9 @@ |
| 138 | 221 |
data-state={state}
|
| 139 | 222 |
> |
| 140 | 223 |
{label && (
|
| 141 |
- <FoxFormLabel htmlFor={fieldId}>{label}</FoxFormLabel>
|
|
| 224 |
+ <FoxFormLabel htmlFor={fieldId} requirement={requirement}>
|
|
| 225 |
+ {label}
|
|
| 226 |
+ </FoxFormLabel> |
|
| 142 | 227 |
)} |
| 143 | 228 |
|
| 144 | 229 |
<div className="fox-input__box"> |
... | ... | @@ -150,9 +235,10 @@ |
| 150 | 235 |
value={value}
|
| 151 | 236 |
defaultValue={defaultValue}
|
| 152 | 237 |
aria-invalid={errored || undefined}
|
| 153 |
- aria-describedby={message ? messageId : undefined}
|
|
| 238 |
+ aria-describedby={shownMessage ? messageId : undefined}
|
|
| 154 | 239 |
onChange={handleChange}
|
| 155 | 240 |
onInput={handleInput}
|
| 241 |
+ onBlur={handleBlur}
|
|
| 156 | 242 |
/> |
| 157 | 243 |
|
| 158 | 244 |
{clearable && hasValue && (
|
... | ... | @@ -170,6 +256,8 @@ |
| 170 | 256 |
</button> |
| 171 | 257 |
)} |
| 172 | 258 |
|
| 259 |
+ {suffix && <span className="fox-input__suffix">{suffix}</span>}
|
|
| 260 |
+ |
|
| 173 | 261 |
{icon && (
|
| 174 | 262 |
<span className="fox-input__icon" aria-hidden="true"> |
| 175 | 263 |
{icon}
|
... | ... | @@ -177,14 +265,14 @@ |
| 177 | 265 |
)} |
| 178 | 266 |
</div> |
| 179 | 267 |
|
| 180 |
- {message && (
|
|
| 268 |
+ {shownMessage && (
|
|
| 181 | 269 |
<p className="fox-input__message" id={messageId}>
|
| 182 | 270 |
{messageIcon && (
|
| 183 | 271 |
<span className="fox-input__message-icon" aria-hidden="true"> |
| 184 | 272 |
{messageIcon}
|
| 185 | 273 |
</span> |
| 186 | 274 |
)} |
| 187 |
- {message}
|
|
| 275 |
+ {shownMessage}
|
|
| 188 | 276 |
</p> |
| 189 | 277 |
)} |
| 190 | 278 |
</div> |
+++ @fox/core/components/fox-list-container/fox-list-container.tsx
... | ... | @@ -0,0 +1,289 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useState, type ReactNode } from "react"; | |
| 4 | +import { cx } from "../../utils"; | |
| 5 | +import { FoxPageHeader } from "../fox-page-header"; | |
| 6 | +import type { FoxBreadcrumbItem } from "../fox-breadcrumb"; | |
| 7 | +import { FoxTable } from "../fox-table"; | |
| 8 | +import { FoxTableRow } from "../fox-table-row"; | |
| 9 | +import { FoxTableCell } from "../fox-table-cell"; | |
| 10 | +import type { FoxTableCellAlign } from "../fox-table-cell"; | |
| 11 | +import { FoxPagination } from "../fox-pagination"; | |
| 12 | +import { FoxListCount, FoxListToolbar } from "../fox-list-toolbar"; | |
| 13 | +import { FoxListSearch } from "../fox-list-search"; | |
| 14 | +import type { FoxSelectItem } from "../fox-select"; | |
| 15 | + | |
| 16 | +/** 열 하나의 정의. `render`가 있으면 그 결과가 셀 내용이 된다. */ | |
| 17 | +export interface FoxListColumn<T> { | |
| 18 | + /** 열 식별자. `render`가 없으면 `row[key]`를 그대로 그린다. */ | |
| 19 | + key: string; | |
| 20 | + header: ReactNode; | |
| 21 | + /** 셀을 직접 그린다 — 배지·버튼 무엇이든 넣을 수 있다. */ | |
| 22 | + render?: (row: T, index: number) => ReactNode; | |
| 23 | + width?: string | number; | |
| 24 | + align?: FoxTableCellAlign; | |
| 25 | + /** 참이면 그 열의 본문 글자가 진해진다(시안의 강조 열). */ | |
| 26 | + emphasis?: boolean; | |
| 27 | +} | |
| 28 | + | |
| 29 | +/** | |
| 30 | + * 목록 상태의 변경분. 컨테이너는 **바뀐 것만** 담아 넘긴다 — 호출부가 기존 값과 합쳐 | |
| 31 | + * 쓰라는 뜻이다. 화면마다 필요한 정렬·필터 키가 달라 열려 있는 모양이다. | |
| 32 | + */ | |
| 33 | +export interface FoxListQueryPatch { | |
| 34 | + page?: number; | |
| 35 | + keyword?: string; | |
| 36 | + searchField?: string; | |
| 37 | + [key: string]: unknown; | |
| 38 | +} | |
| 39 | + | |
| 40 | +export interface FoxListContainerProps<T> { | |
| 41 | + // ── 머리말 ──────────────────────────────────────────────────────────────── | |
| 42 | + title?: ReactNode; | |
| 43 | + breadcrumb?: FoxBreadcrumbItem[]; | |
| 44 | + headerActions?: ReactNode; | |
| 45 | + /** | |
| 46 | + * 머리말과 목록 사이. 시안에서 이 자리에 오는 것은 `FoxTab`이다 — 목록 전체를 갈아 끼우는 | |
| 47 | + * 전환이라 도구 줄(정렬·검색)보다 위에 선다. | |
| 48 | + */ | |
| 49 | + tabs?: ReactNode; | |
| 50 | + | |
| 51 | + // ── 표 ──────────────────────────────────────────────────────────────────── | |
| 52 | + columns: FoxListColumn<T>[]; | |
| 53 | + rows: T[]; | |
| 54 | + /** 행의 안정적인 키. 인덱스를 쓰면 정렬·페이지 이동에서 어긋난다. */ | |
| 55 | + rowKey: (row: T, index: number) => string; | |
| 56 | + caption?: string; | |
| 57 | + /** 행이 없을 때 표 안에 보여줄 말. */ | |
| 58 | + emptyMessage?: ReactNode; | |
| 59 | + /** 주면 행 전체가 눌린다 — 관리 열 없이 행을 눌러 상세를 여는 시안에 쓴다. */ | |
| 60 | + onRowSelect?: (row: T, index: number) => void; | |
| 61 | + | |
| 62 | + // ── 도구 줄 ─────────────────────────────────────────────────────────────── | |
| 63 | + /** 주면 "총 N건"이 나온다. 없으면 그 자리를 비운다. */ | |
| 64 | + totalCount?: number; | |
| 65 | + toolbarLeading?: ReactNode; | |
| 66 | + sorts?: ReactNode; | |
| 67 | + toolbarActions?: ReactNode; | |
| 68 | + filter?: ReactNode; | |
| 69 | + | |
| 70 | + /** 검색 대상 목록. 주면 검색 상자가 나온다. 대상이 하나뿐이면 빈 배열로 두면 된다. */ | |
| 71 | + searchFields?: FoxSelectItem[]; | |
| 72 | + /** 지금 **적용된** 검색 조건이다(입력 중인 값이 아니다). 초안은 컨테이너가 따로 든다. */ | |
| 73 | + searchField?: string; | |
| 74 | + keyword?: string; | |
| 75 | + searchPlaceholder?: string; | |
| 76 | + /** 검색 상자를 아예 숨긴다. */ | |
| 77 | + searchHidden?: boolean; | |
| 78 | + | |
| 79 | + // ── 페이지 ──────────────────────────────────────────────────────────────── | |
| 80 | + page?: number; | |
| 81 | + totalPages?: number; | |
| 82 | + | |
| 83 | + // ── 이벤트 ──────────────────────────────────────────────────────────────── | |
| 84 | + /** | |
| 85 | + * 주면 페이지 이동이 **링크**가 된다 — 목록이 Server Component로 남고 새로고침·뒤로가기· | |
| 86 | + * URL 공유가 그대로 동작한다. 이 저장소의 목록 화면들이 쓰는 방식이다. | |
| 87 | + */ | |
| 88 | + buildHref?: (patch: FoxListQueryPatch) => string; | |
| 89 | + /** `buildHref`가 없을 때(모달 안 목록 등) 쓰는 콜백. 둘 다 주면 링크가 이긴다. */ | |
| 90 | + onQueryChange?: (patch: FoxListQueryPatch) => void; | |
| 91 | + | |
| 92 | + hidden?: boolean; | |
| 93 | + className?: string; | |
| 94 | +} | |
| 95 | + | |
| 96 | +/** | |
| 97 | + * @fox 목록 화면 뼈대 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) container (3019:8608) | |
| 98 | + * | |
| 99 | + * 머리말 · 도구 줄 · 표 · 페이지네이션을 한 벌로 묶는다. **데이터를 가져오지도, 상태를 갖지도 | |
| 100 | + * 않는다** — 받은 것을 그리고 바뀐 것을 알릴 뿐이라 어떤 목록에도 그대로 쓸 수 있다. | |
| 101 | + * | |
| 102 | + * 표는 `columns` 정의로 그린다. `render`가 ReactNode를 돌려주므로 셀 안에 배지든 버튼 묶음이든 | |
| 103 | + * 들어간다(시안이 그렇다) — 값의 형식을 컨테이너가 알 필요가 없다. | |
| 104 | + * | |
| 105 | + * 이벤트는 두 갈래다. `buildHref`를 주면 링크로 그려 URL이 상태가 되고, `onQueryChange`만 | |
| 106 | + * 주면 콜백으로 알린다. FoxPagination이 이미 같은 방식이라 그 관례를 따랐다. | |
| 107 | + * | |
| 108 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 109 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 110 | + */ | |
| 111 | +export function FoxListContainer<T>({ | |
| 112 | + title, | |
| 113 | + breadcrumb, | |
| 114 | + headerActions, | |
| 115 | + tabs, | |
| 116 | + columns, | |
| 117 | + rows, | |
| 118 | + rowKey, | |
| 119 | + caption, | |
| 120 | + emptyMessage = "조회된 내용이 없습니다.", | |
| 121 | + onRowSelect, | |
| 122 | + totalCount, | |
| 123 | + toolbarLeading, | |
| 124 | + sorts, | |
| 125 | + toolbarActions, | |
| 126 | + filter, | |
| 127 | + searchFields, | |
| 128 | + searchField, | |
| 129 | + keyword, | |
| 130 | + searchPlaceholder, | |
| 131 | + searchHidden = false, | |
| 132 | + page = 1, | |
| 133 | + totalPages = 1, | |
| 134 | + buildHref, | |
| 135 | + onQueryChange, | |
| 136 | + hidden = false, | |
| 137 | + className, | |
| 138 | +}: FoxListContainerProps<T>) { | |
| 139 | + // 검색 상자는 **누르기 전까지의 입력 초안**을 여기서 든다. `keyword`·`searchField`는 이미 | |
| 140 | + // 검색이 반영된 결과(주소·서버 응답)라, 그대로 FoxListSearch에 제어 값으로 내려보내면 | |
| 141 | + // 변경 핸들러가 없어 글자도 검색 대상도 바뀌지 않는다. | |
| 142 | + const applied = { keyword: keyword ?? "", field: searchField }; | |
| 143 | + const [draft, setDraft] = useState(applied); | |
| 144 | + const [lastApplied, setLastApplied] = useState(applied); | |
| 145 | + | |
| 146 | + // 검색이 끝나 밖의 값이 바뀌면 초안을 그 값으로 되돌린다 — 뒤로가기·초기화처럼 화면이 아니라 | |
| 147 | + // 주소가 검색어를 바꾸는 경로에서도 입력창이 따라간다. effect가 아니라 렌더 중에 맞추는 것은 | |
| 148 | + // React가 권하는 방식이다(한 번 더 그리지 않고 이 렌더에 반영된다). | |
| 149 | + if ( | |
| 150 | + lastApplied.keyword !== applied.keyword || | |
| 151 | + lastApplied.field !== applied.field | |
| 152 | + ) { | |
| 153 | + setLastApplied(applied); | |
| 154 | + setDraft(applied); | |
| 155 | + } | |
| 156 | + | |
| 157 | + if (hidden) { | |
| 158 | + return null; | |
| 159 | + } | |
| 160 | + | |
| 161 | + const hasToolbar = | |
| 162 | + totalCount !== undefined || | |
| 163 | + toolbarLeading !== undefined || | |
| 164 | + sorts !== undefined || | |
| 165 | + toolbarActions !== undefined || | |
| 166 | + filter !== undefined || | |
| 167 | + !searchHidden; | |
| 168 | + | |
| 169 | + return ( | |
| 170 | + <div className={cx("fox-list-container", className)}> | |
| 171 | + {(title !== undefined || breadcrumb !== undefined) && ( | |
| 172 | + <FoxPageHeader | |
| 173 | + title={title} | |
| 174 | + breadcrumb={breadcrumb} | |
| 175 | + actions={headerActions} | |
| 176 | + /> | |
| 177 | + )} | |
| 178 | + | |
| 179 | + {tabs} | |
| 180 | + | |
| 181 | + <section className="fox-list-container__section"> | |
| 182 | + {hasToolbar && ( | |
| 183 | + <FoxListToolbar | |
| 184 | + count={ | |
| 185 | + totalCount !== undefined ? ( | |
| 186 | + <FoxListCount total={totalCount} /> | |
| 187 | + ) : undefined | |
| 188 | + } | |
| 189 | + leading={toolbarLeading} | |
| 190 | + sorts={sorts} | |
| 191 | + search={ | |
| 192 | + searchHidden ? undefined : ( | |
| 193 | + <FoxListSearch | |
| 194 | + fields={searchFields} | |
| 195 | + field={draft.field} | |
| 196 | + onFieldChange={(next) => | |
| 197 | + setDraft((current) => ({ ...current, field: next })) | |
| 198 | + } | |
| 199 | + keyword={draft.keyword} | |
| 200 | + onKeywordChange={(next) => | |
| 201 | + setDraft((current) => ({ ...current, keyword: next })) | |
| 202 | + } | |
| 203 | + placeholder={searchPlaceholder} | |
| 204 | + onSearch={(nextKeyword, nextField) => | |
| 205 | + onQueryChange?.({ | |
| 206 | + keyword: nextKeyword, | |
| 207 | + searchField: nextField, | |
| 208 | + // 검색 결과는 항상 첫 장부터 본다 — 남아 있던 페이지 번호를 지운다. | |
| 209 | + page: 1, | |
| 210 | + }) | |
| 211 | + } | |
| 212 | + /> | |
| 213 | + ) | |
| 214 | + } | |
| 215 | + actions={toolbarActions} | |
| 216 | + filter={filter} | |
| 217 | + /> | |
| 218 | + )} | |
| 219 | + | |
| 220 | + {/* 열이 많으면 표가 화면보다 넓어진다. 눌러 담지 않고 이 래퍼가 가로로 스크롤한다. */} | |
| 221 | + <div className="fox-list-container__table"> | |
| 222 | + {/* `thead`/`tbody`로 감싸지 않는다 — FoxTable이 스스로 만들고, 머리행은 `head`만 보고 | |
| 223 | + 제자리로 보낸다. 감싸면 표 안에 표 구조가 이중으로 생긴다. */} | |
| 224 | + <FoxTable type="list" caption={caption}> | |
| 225 | + <FoxTableRow head> | |
| 226 | + {columns.map((column) => ( | |
| 227 | + <FoxTableCell | |
| 228 | + key={column.key} | |
| 229 | + head | |
| 230 | + width={column.width} | |
| 231 | + align={column.align} | |
| 232 | + > | |
| 233 | + {column.header} | |
| 234 | + </FoxTableCell> | |
| 235 | + ))} | |
| 236 | + </FoxTableRow> | |
| 237 | + | |
| 238 | + {rows.length === 0 ? ( | |
| 239 | + <FoxTableRow> | |
| 240 | + <FoxTableCell colSpan={columns.length} align="center"> | |
| 241 | + {emptyMessage} | |
| 242 | + </FoxTableCell> | |
| 243 | + </FoxTableRow> | |
| 244 | + ) : ( | |
| 245 | + rows.map((row, index) => ( | |
| 246 | + <FoxTableRow | |
| 247 | + key={rowKey(row, index)} | |
| 248 | + onSelect={ | |
| 249 | + onRowSelect ? () => onRowSelect(row, index) : undefined | |
| 250 | + } | |
| 251 | + > | |
| 252 | + {columns.map((column) => ( | |
| 253 | + <FoxTableCell | |
| 254 | + key={column.key} | |
| 255 | + align={column.align} | |
| 256 | + emphasis={column.emphasis} | |
| 257 | + label={ | |
| 258 | + typeof column.header === "string" | |
| 259 | + ? column.header | |
| 260 | + : undefined | |
| 261 | + } | |
| 262 | + > | |
| 263 | + {column.render | |
| 264 | + ? column.render(row, index) | |
| 265 | + : String( | |
| 266 | + (row as Record<string, unknown>)[column.key] ?? "" | |
| 267 | + )} | |
| 268 | + </FoxTableCell> | |
| 269 | + ))} | |
| 270 | + </FoxTableRow> | |
| 271 | + )) | |
| 272 | + )} | |
| 273 | + </FoxTable> | |
| 274 | + </div> | |
| 275 | + | |
| 276 | + {totalPages > 1 && ( | |
| 277 | + <FoxPagination | |
| 278 | + currentPage={page} | |
| 279 | + totalPages={totalPages} | |
| 280 | + buildHref={buildHref ? (next) => buildHref({ page: next }) : undefined} | |
| 281 | + onPageChange={ | |
| 282 | + buildHref ? undefined : (next) => onQueryChange?.({ page: next }) | |
| 283 | + } | |
| 284 | + /> | |
| 285 | + )} | |
| 286 | + </section> | |
| 287 | + </div> | |
| 288 | + ); | |
| 289 | +} |
+++ @fox/core/components/fox-list-container/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export * from "./fox-list-container"; |
+++ @fox/core/components/fox-list-search/fox-list-search.tsx
... | ... | @@ -0,0 +1,123 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useState, type FormEvent } from "react"; | |
| 4 | +import { cx } from "../../utils"; | |
| 5 | +import { FoxSelectText } from "../fox-select-text"; | |
| 6 | +import type { FoxSelectItem } from "../fox-select"; | |
| 7 | +import { FoxMagnifyingGlassIcon } from "../../icons"; | |
| 8 | + | |
| 9 | +export interface FoxListSearchProps { | |
| 10 | + /** 앞쪽 셀렉트에 넣을 검색 대상. 비우면 셀렉트 자체가 나오지 않는다. */ | |
| 11 | + fields?: FoxSelectItem[]; | |
| 12 | + /** 선택된 검색 대상(제어). */ | |
| 13 | + field?: string; | |
| 14 | + onFieldChange?: (field: string) => void; | |
| 15 | + /** 입력값(제어). 주지 않으면 컴포넌트가 스스로 들고 있다. */ | |
| 16 | + keyword?: string; | |
| 17 | + onKeywordChange?: (keyword: string) => void; | |
| 18 | + /** 돋보기를 누르거나 Enter를 쳤을 때. 현재 검색어와 대상을 함께 넘긴다. */ | |
| 19 | + onSearch?: (keyword: string, field?: string) => void; | |
| 20 | + placeholder?: string; | |
| 21 | + /** 글자가 없으므로 이 값이 검색 버튼의 접근성 이름이 된다. */ | |
| 22 | + label?: string; | |
| 23 | + disabled?: boolean; | |
| 24 | + hidden?: boolean; | |
| 25 | + className?: string; | |
| 26 | +} | |
| 27 | + | |
| 28 | +/** | |
| 29 | + * @fox 목록 검색 상자 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) list-toolbar의 search | |
| 30 | + * | |
| 31 | + * 마크업 계약 (React 밖 소비자용): | |
| 32 | + * <form class="fox-list-search"> | |
| 33 | + * <div class="fox-list-search__box"> | |
| 34 | + * <span class="fox-list-search__field">…select-txt…</span> <!-- 선택 --> | |
| 35 | + * <input class="fox-list-search__input"> | |
| 36 | + * <button class="fox-list-search__submit">…svg…</button> | |
| 37 | + * </div> | |
| 38 | + * </form> | |
| 39 | + * | |
| 40 | + * `FoxInput`을 쓰지 않는다 — 시안은 입력칸 **앞쪽**에 검색 대상 셀렉트가 들어가는데 FoxInput에 | |
| 41 | + * 그 슬롯이 없다. 크기·radius·테두리는 시안이 지정한 값이 FoxInput md와 같지만, 좌우 여백만 | |
| 42 | + * `form/padding/sm`으로 더 좁다(안에 셀렉트와 버튼이 들어가기 때문이다). | |
| 43 | + * | |
| 44 | + * `<form>`이라 Enter가 그대로 제출이 된다 — 키 이벤트를 따로 듣지 않는다. | |
| 45 | + * | |
| 46 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 47 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 48 | + */ | |
| 49 | +export function FoxListSearch({ | |
| 50 | + fields, | |
| 51 | + field, | |
| 52 | + onFieldChange, | |
| 53 | + keyword, | |
| 54 | + onKeywordChange, | |
| 55 | + onSearch, | |
| 56 | + placeholder = "검색어를 입력하세요.", | |
| 57 | + label = "검색", | |
| 58 | + disabled = false, | |
| 59 | + hidden = false, | |
| 60 | + className, | |
| 61 | +}: FoxListSearchProps) { | |
| 62 | + const [innerKeyword, setInnerKeyword] = useState(keyword ?? ""); | |
| 63 | + const controlled = keyword !== undefined; | |
| 64 | + const value = controlled ? keyword : innerKeyword; | |
| 65 | + | |
| 66 | + if (hidden) { | |
| 67 | + return null; | |
| 68 | + } | |
| 69 | + | |
| 70 | + function handleChange(next: string) { | |
| 71 | + if (!controlled) { | |
| 72 | + setInnerKeyword(next); | |
| 73 | + } | |
| 74 | + onKeywordChange?.(next); | |
| 75 | + } | |
| 76 | + | |
| 77 | + function handleSubmit(event: FormEvent<HTMLFormElement>) { | |
| 78 | + event.preventDefault(); | |
| 79 | + onSearch?.(value, field); | |
| 80 | + } | |
| 81 | + | |
| 82 | + return ( | |
| 83 | + <form | |
| 84 | + className={cx("fox-list-search", className)} | |
| 85 | + role="search" | |
| 86 | + onSubmit={handleSubmit} | |
| 87 | + > | |
| 88 | + <div className="fox-list-search__box"> | |
| 89 | + {fields && fields.length > 0 && ( | |
| 90 | + <span className="fox-list-search__field"> | |
| 91 | + <FoxSelectText | |
| 92 | + size="sm" | |
| 93 | + options={fields} | |
| 94 | + value={field} | |
| 95 | + onValueChange={onFieldChange} | |
| 96 | + ariaLabel="검색 대상" | |
| 97 | + disabled={disabled} | |
| 98 | + /> | |
| 99 | + </span> | |
| 100 | + )} | |
| 101 | + <input | |
| 102 | + className="fox-list-search__input" | |
| 103 | + type="search" | |
| 104 | + value={value} | |
| 105 | + placeholder={placeholder} | |
| 106 | + disabled={disabled} | |
| 107 | + aria-label={placeholder} | |
| 108 | + onChange={(event) => handleChange(event.target.value)} | |
| 109 | + /> | |
| 110 | + {/* 시안의 이 버튼은 btn-ico 인스턴스가 아니라 로컬 프레임(32×32)이라 FoxIconButton의 | |
| 111 | + 크기 체계(24·28·40·56)와 맞지 않는다. 그래서 직접 그린다. */} | |
| 112 | + <button | |
| 113 | + className="fox-list-search__submit" | |
| 114 | + type="submit" | |
| 115 | + aria-label={label} | |
| 116 | + disabled={disabled} | |
| 117 | + > | |
| 118 | + <FoxMagnifyingGlassIcon /> | |
| 119 | + </button> | |
| 120 | + </div> | |
| 121 | + </form> | |
| 122 | + ); | |
| 123 | +} |
+++ @fox/core/components/fox-list-search/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export * from "./fox-list-search"; |
+++ @fox/core/components/fox-list-toolbar/fox-list-toolbar.tsx
... | ... | @@ -0,0 +1,98 @@ |
| 1 | +import type { ReactNode } from "react"; | |
| 2 | +import { cx } from "../../utils"; | |
| 3 | + | |
| 4 | +export interface FoxListToolbarProps { | |
| 5 | + /** 좌측 첫 자리. 보통 "총 N건"이다 — `FoxListCount`가 시안 서식을 그린다. */ | |
| 6 | + count?: ReactNode; | |
| 7 | + /** 총 건수 옆 액션(전체 선택·선택 삭제 등). */ | |
| 8 | + leading?: ReactNode; | |
| 9 | + /** 우측 정렬·조건 셀렉트 묶음. */ | |
| 10 | + sorts?: ReactNode; | |
| 11 | + /** 우측 검색 상자. */ | |
| 12 | + search?: ReactNode; | |
| 13 | + /** 우측 끝 액션(등록·엑셀 등). */ | |
| 14 | + actions?: ReactNode; | |
| 15 | + /** 아래 줄의 필터. 주지 않으면 그 줄 자체가 렌더되지 않는다(구분선도 함께 사라진다). */ | |
| 16 | + filter?: ReactNode; | |
| 17 | + hidden?: boolean; | |
| 18 | + className?: string; | |
| 19 | +} | |
| 20 | + | |
| 21 | +/** | |
| 22 | + * @fox 목록 도구 줄 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) list-toolbar (3002:8151) | |
| 23 | + * | |
| 24 | + * 마크업 계약 (React 밖 소비자용): | |
| 25 | + * <div class="fox-list-toolbar"> | |
| 26 | + * <div class="fox-list-toolbar__base"> | |
| 27 | + * <div class="fox-list-toolbar__left">…총 N건 · 액션…</div> | |
| 28 | + * <div class="fox-list-toolbar__right">…셀렉트 · 검색 · 액션…</div> | |
| 29 | + * </div> | |
| 30 | + * <div class="fox-list-toolbar__filter">…필터…</div> <!-- 선택 --> | |
| 31 | + * </div> | |
| 32 | + * | |
| 33 | + * **자리만 잡는다.** 무엇이 들어갈지는 화면마다 다르므로 전부 슬롯으로 받는다 — 셀렉트가 | |
| 34 | + * 몇 개인지, 액션이 버튼인지 아이콘인지 이 컴포넌트는 알지 못한다. | |
| 35 | + * | |
| 36 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 37 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 38 | + */ | |
| 39 | +export function FoxListToolbar({ | |
| 40 | + count, | |
| 41 | + leading, | |
| 42 | + sorts, | |
| 43 | + search, | |
| 44 | + actions, | |
| 45 | + filter, | |
| 46 | + hidden = false, | |
| 47 | + className, | |
| 48 | +}: FoxListToolbarProps) { | |
| 49 | + if (hidden) { | |
| 50 | + return null; | |
| 51 | + } | |
| 52 | + | |
| 53 | + return ( | |
| 54 | + <div className={cx("fox-list-toolbar", className)}> | |
| 55 | + <div className="fox-list-toolbar__base"> | |
| 56 | + <div className="fox-list-toolbar__left"> | |
| 57 | + {count} | |
| 58 | + {leading} | |
| 59 | + </div> | |
| 60 | + <div className="fox-list-toolbar__right"> | |
| 61 | + {sorts && <div className="fox-list-toolbar__sorts">{sorts}</div>} | |
| 62 | + {search} | |
| 63 | + {actions} | |
| 64 | + </div> | |
| 65 | + </div> | |
| 66 | + | |
| 67 | + {filter && <div className="fox-list-toolbar__filter">{filter}</div>} | |
| 68 | + </div> | |
| 69 | + ); | |
| 70 | +} | |
| 71 | + | |
| 72 | +export interface FoxListCountProps { | |
| 73 | + /** 전체 건수. */ | |
| 74 | + total: number; | |
| 75 | + /** 숫자 앞뒤 문구. 기본은 시안의 "총 N건". */ | |
| 76 | + prefix?: ReactNode; | |
| 77 | + suffix?: ReactNode; | |
| 78 | + className?: string; | |
| 79 | +} | |
| 80 | + | |
| 81 | +/** | |
| 82 | + * 총 건수 표시 — 시안이 숫자만 굵고 다른 색이라 별도 조각으로 둔다. 천 단위 구분은 | |
| 83 | + * 호출부의 로케일을 타지 않도록 `toLocaleString("ko-KR")`로 고정한다. | |
| 84 | + */ | |
| 85 | +export function FoxListCount({ | |
| 86 | + total, | |
| 87 | + prefix = "총 ", | |
| 88 | + suffix = "건", | |
| 89 | + className, | |
| 90 | +}: FoxListCountProps) { | |
| 91 | + return ( | |
| 92 | + <p className={cx("fox-list-count", className)}> | |
| 93 | + {prefix} | |
| 94 | + <b className="fox-list-count__value">{total.toLocaleString("ko-KR")}</b> | |
| 95 | + {suffix} | |
| 96 | + </p> | |
| 97 | + ); | |
| 98 | +} |
+++ @fox/core/components/fox-list-toolbar/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export * from "./fox-list-toolbar"; |
+++ @fox/core/components/fox-modal/fox-modal.tsx
... | ... | @@ -0,0 +1,119 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useEffect, useRef, type ReactNode } from "react"; | |
| 4 | +import { cx } from "../../utils"; | |
| 5 | +import { FoxXIcon } from "../../icons"; | |
| 6 | + | |
| 7 | +export type FoxModalSize = "sm" | "md" | "lg" | "full"; | |
| 8 | + | |
| 9 | +export interface FoxModalProps { | |
| 10 | + /** 열림 여부. 호출부가 소유한다 — 모달이 스스로 닫지 않는다. */ | |
| 11 | + open: boolean; | |
| 12 | + /** 닫기 버튼·Esc·바깥 클릭에서 부른다. */ | |
| 13 | + onClose: () => void; | |
| 14 | + children?: ReactNode; | |
| 15 | + size?: FoxModalSize; | |
| 16 | + /** 넘기지 않으면 제목 줄을 그리지 않는다. */ | |
| 17 | + title?: ReactNode; | |
| 18 | + /** 아래쪽 오른편. 보통 버튼 묶음이다. */ | |
| 19 | + actions?: ReactNode; | |
| 20 | + /** 아래쪽 왼편. 시안은 "오늘 하루 보지 않기" 같은 체크박스 자리다. */ | |
| 21 | + utility?: ReactNode; | |
| 22 | + /** 닫기 버튼을 감춘다. */ | |
| 23 | + closable?: boolean; | |
| 24 | + closeLabel?: string; | |
| 25 | + /** 바깥을 눌러도 닫히지 않게 한다. */ | |
| 26 | + dismissible?: boolean; | |
| 27 | + /** 배치 조정용. */ | |
| 28 | + className?: string; | |
| 29 | +} | |
| 30 | + | |
| 31 | +/** | |
| 32 | + * @fox 모달. 네이티브 `<dialog>`를 쓴다 — 초점 가두기·Esc 닫기·뒷배경·맨 위 레이어를 | |
| 33 | + * 브라우저가 처리하므로 그걸 직접 구현하지 않는다. | |
| 34 | + * | |
| 35 | + * 열림 여부는 호출부가 소유한다. 사용자가 Esc를 누르거나 바깥을 눌러도 모달이 스스로 | |
| 36 | + * 닫지 않고 `onClose`만 부른다 — 닫을지 말지는 호출부가 정한다. | |
| 37 | + * | |
| 38 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 39 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 40 | + */ | |
| 41 | +export function FoxModal({ | |
| 42 | + open, | |
| 43 | + onClose, | |
| 44 | + children, | |
| 45 | + size = "md", | |
| 46 | + title, | |
| 47 | + actions, | |
| 48 | + utility, | |
| 49 | + closable = true, | |
| 50 | + closeLabel = "닫기", | |
| 51 | + dismissible = true, | |
| 52 | + className, | |
| 53 | +}: FoxModalProps) { | |
| 54 | + const ref = useRef<HTMLDialogElement>(null); | |
| 55 | + | |
| 56 | + // `open` 속성만 세우면 뒷배경도 초점 가두기도 없는 그냥 상자가 된다. 모달로 띄우려면 | |
| 57 | + // `showModal()`을 불러야 한다. | |
| 58 | + useEffect(() => { | |
| 59 | + const dialog = ref.current; | |
| 60 | + if (!dialog) { | |
| 61 | + return; | |
| 62 | + } | |
| 63 | + if (open && !dialog.open) { | |
| 64 | + dialog.showModal(); | |
| 65 | + } else if (!open && dialog.open) { | |
| 66 | + dialog.close(); | |
| 67 | + } | |
| 68 | + }, [open]); | |
| 69 | + | |
| 70 | + return ( | |
| 71 | + <dialog | |
| 72 | + ref={ref} | |
| 73 | + className={cx("fox-modal", `fox-modal--${size}`, className)} | |
| 74 | + // Esc는 브라우저가 닫아 버리므로 막고, 닫을지는 호출부가 정하게 한다. | |
| 75 | + onCancel={(event) => { | |
| 76 | + event.preventDefault(); | |
| 77 | + onClose(); | |
| 78 | + }} | |
| 79 | + onClick={(event) => { | |
| 80 | + // `<dialog>`는 뒷배경을 눌러도 자기 자신이 대상이 된다 — 상자 밖을 눌렀는지는 | |
| 81 | + // 좌표로 가린다. | |
| 82 | + if (!dismissible || event.target !== event.currentTarget) { | |
| 83 | + return; | |
| 84 | + } | |
| 85 | + const box = event.currentTarget.getBoundingClientRect(); | |
| 86 | + const outside = | |
| 87 | + event.clientX < box.left || | |
| 88 | + event.clientX > box.right || | |
| 89 | + event.clientY < box.top || | |
| 90 | + event.clientY > box.bottom; | |
| 91 | + if (outside) { | |
| 92 | + onClose(); | |
| 93 | + } | |
| 94 | + }} | |
| 95 | + > | |
| 96 | + <div className="fox-modal__body"> | |
| 97 | + {/* 제목이 없어도 줄은 남긴다 — 그래야 닫기 버튼과 내용이 겹치지 않는다. | |
| 98 | + 빈 제목을 심으면 이름 없는 heading이 되므로 `<h2>`는 있을 때만 낸다. */} | |
| 99 | + <div className="fox-modal__tit">{title && <h2 className="fox-modal__title">{title}</h2>}</div> | |
| 100 | + <div className="fox-modal__contents">{children}</div> | |
| 101 | + </div> | |
| 102 | + | |
| 103 | + {(utility || actions) && ( | |
| 104 | + <div className="fox-modal__foot"> | |
| 105 | + {utility && <div className="fox-modal__utility">{utility}</div>} | |
| 106 | + {actions && <div className="fox-modal__actions">{actions}</div>} | |
| 107 | + </div> | |
| 108 | + )} | |
| 109 | + | |
| 110 | + {closable && ( | |
| 111 | + <button className="fox-modal__close" type="button" aria-label={closeLabel} onClick={onClose}> | |
| 112 | + <span className="fox-modal__close-icon" aria-hidden="true"> | |
| 113 | + <FoxXIcon /> | |
| 114 | + </span> | |
| 115 | + </button> | |
| 116 | + )} | |
| 117 | + </dialog> | |
| 118 | + ); | |
| 119 | +} |
+++ @fox/core/components/fox-modal/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export { FoxModal, type FoxModalProps, type FoxModalSize } from "./fox-modal"; |
+++ @fox/core/components/fox-page-header/fox-page-header.tsx
... | ... | @@ -0,0 +1,51 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import type { ReactNode } from "react"; | |
| 4 | +import { cx } from "../../utils"; | |
| 5 | +import { FoxBreadcrumb, type FoxBreadcrumbItem } from "../fox-breadcrumb"; | |
| 6 | + | |
| 7 | +export interface FoxPageHeaderProps { | |
| 8 | + /** 화면 제목. 페이지에 하나뿐인 이름이라 `<h1>`으로 나간다. */ | |
| 9 | + title: ReactNode; | |
| 10 | + /** 제목 위에 붙는 현재 위치. 넘기지 않으면 영역을 그리지 않는다. */ | |
| 11 | + breadcrumb?: FoxBreadcrumbItem[]; | |
| 12 | + /** | |
| 13 | + * 제목 오른쪽 자리. 시안에는 들어간 것이 없지만 배치가 자리를 비워 두고 있어 | |
| 14 | + * (제목이 남는 폭을 먹고 24px 간격이 있다) 화면이 버튼 같은 것을 얹을 수 있게 연다. | |
| 15 | + */ | |
| 16 | + actions?: ReactNode; | |
| 17 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 18 | + hidden?: boolean; | |
| 19 | + /** 배치 조정용. */ | |
| 20 | + className?: string; | |
| 21 | +} | |
| 22 | + | |
| 23 | +/** | |
| 24 | + * @fox 화면 머리말. 현재 위치와 제목을 얹고 본문과의 간격까지 갖는다. | |
| 25 | + * | |
| 26 | + * 현재 위치는 `FoxBreadcrumb`를 그대로 쓴다 — 시안에서도 breadcrumb 인스턴스다. | |
| 27 | + * | |
| 28 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 29 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 30 | + */ | |
| 31 | +export function FoxPageHeader({ | |
| 32 | + title, | |
| 33 | + breadcrumb, | |
| 34 | + actions, | |
| 35 | + hidden = false, | |
| 36 | + className, | |
| 37 | +}: FoxPageHeaderProps) { | |
| 38 | + if (hidden) { | |
| 39 | + return null; | |
| 40 | + } | |
| 41 | + | |
| 42 | + return ( | |
| 43 | + <div className={cx("fox-page-header", className)}> | |
| 44 | + <div className="fox-page-header__main"> | |
| 45 | + {breadcrumb && breadcrumb.length > 0 && <FoxBreadcrumb items={breadcrumb} />} | |
| 46 | + <h1 className="fox-page-header__title">{title}</h1> | |
| 47 | + </div> | |
| 48 | + {actions && <div className="fox-page-header__actions">{actions}</div>} | |
| 49 | + </div> | |
| 50 | + ); | |
| 51 | +} |
+++ @fox/core/components/fox-page-header/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export { FoxPageHeader, type FoxPageHeaderProps } from "./fox-page-header"; |
--- @fox/core/components/fox-select-text/fox-select-text.tsx
+++ @fox/core/components/fox-select-text/fox-select-text.tsx
... | ... | @@ -36,6 +36,11 @@ |
| 36 | 36 |
* ("2026년 8월")이라 무엇을 고르는 자리인지는 소리로 전달되지 않는다 — `"조회 기간"`처럼
|
| 37 | 37 |
* 적어 준다. |
| 38 | 38 |
*/ |
| 39 |
+ /** |
|
| 40 |
+ * 오른쪽 표시를 바꾼다. 기본은 펼침을 뜻하는 캐럿이고, 시안이 다른 뜻을 담을 때만 준다 |
|
| 41 |
+ * (예: 정렬 셀렉트의 위아래 화살표). 장식이라 `aria-hidden`인 자리다. |
|
| 42 |
+ */ |
|
| 43 |
+ icon?: ReactNode; |
|
| 39 | 44 |
ariaLabel?: string; |
| 40 | 45 |
/** 이름 역할을 하는 요소의 id. `ariaLabel`보다 우선한다. */ |
| 41 | 46 |
labelledBy?: string; |
... | ... | @@ -76,6 +81,7 @@ |
| 76 | 81 |
onValueChange, |
| 77 | 82 |
placeholder = "선택해주세요", |
| 78 | 83 |
disabled = false, |
| 84 |
+ icon, |
|
| 79 | 85 |
ariaLabel, |
| 80 | 86 |
labelledBy, |
| 81 | 87 |
id, |
... | ... | @@ -219,7 +225,7 @@ |
| 219 | 225 |
> |
| 220 | 226 |
<span className="fox-select-text__value">{selectedOption?.label ?? placeholder}</span>
|
| 221 | 227 |
<span className="fox-select-text__icon" aria-hidden="true"> |
| 222 |
- <ChevronMark /> |
|
| 228 |
+ {icon ?? <ChevronMark />}
|
|
| 223 | 229 |
</span> |
| 224 | 230 |
</button> |
| 225 | 231 |
|
--- @fox/core/components/fox-table-row/fox-table-row.tsx
+++ @fox/core/components/fox-table-row/fox-table-row.tsx
... | ... | @@ -16,6 +16,14 @@ |
| 16 | 16 |
head?: boolean; |
| 17 | 17 |
/** 참이면 고른 줄이 된다 — 바탕과 글자가 파랗게 바뀐다(시안 list 표). */ |
| 18 | 18 |
selected?: boolean; |
| 19 |
+ /** |
|
| 20 |
+ * 주면 행 전체가 눌린다 — 시안이 관리 열 없이 행을 눌러 상세를 여는 경우다. |
|
| 21 |
+ * |
|
| 22 |
+ * `<tr>`에 `role="button"`을 씌우지 않는다 — 그러면 보조기술에서 행이 표의 일부가 아니게 |
|
| 23 |
+ * 되어 열 이름 읽기가 끊긴다. 대신 초점을 받게만 하고 Enter·Space를 직접 듣는다. 행의 |
|
| 24 |
+ * 이름은 셀 내용이 대신하므로 눌러야 할 행이 무엇인지 알 수 있다. |
|
| 25 |
+ */ |
|
| 26 |
+ onSelect?: () => void; |
|
| 19 | 27 |
/** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ |
| 20 | 28 |
hidden?: boolean; |
| 21 | 29 |
/** 배치 조정용. */ |
... | ... | @@ -52,6 +60,7 @@ |
| 52 | 60 |
children, |
| 53 | 61 |
head = false, |
| 54 | 62 |
selected = false, |
| 63 |
+ onSelect, |
|
| 55 | 64 |
hidden = false, |
| 56 | 65 |
className, |
| 57 | 66 |
}: FoxTableRowProps) {
|
... | ... | @@ -59,15 +68,31 @@ |
| 59 | 68 |
return null; |
| 60 | 69 |
} |
| 61 | 70 |
|
| 71 |
+ const clickable = Boolean(onSelect) && !head; |
|
| 72 |
+ |
|
| 62 | 73 |
return ( |
| 63 | 74 |
<tr |
| 64 | 75 |
className={cx(
|
| 65 | 76 |
"fox-table-row", |
| 66 | 77 |
head && "fox-table-row--head", |
| 67 | 78 |
selected && "fox-table-row--selected", |
| 79 |
+ clickable && "fox-table-row--clickable", |
|
| 68 | 80 |
className |
| 69 | 81 |
)} |
| 70 | 82 |
aria-selected={selected || undefined}
|
| 83 |
+ tabIndex={clickable ? 0 : undefined}
|
|
| 84 |
+ onClick={clickable ? onSelect : undefined}
|
|
| 85 |
+ onKeyDown={
|
|
| 86 |
+ clickable |
|
| 87 |
+ ? (event) => {
|
|
| 88 |
+ // Space는 기본 동작이 스크롤이라 막는다. |
|
| 89 |
+ if (event.key === "Enter" || event.key === " ") {
|
|
| 90 |
+ event.preventDefault(); |
|
| 91 |
+ onSelect?.(); |
|
| 92 |
+ } |
|
| 93 |
+ } |
|
| 94 |
+ : undefined |
|
| 95 |
+ } |
|
| 71 | 96 |
> |
| 72 | 97 |
{head ? markCells(children) : children}
|
| 73 | 98 |
</tr> |
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
... | ... | @@ -1,3 +1,4 @@ |
| 1 |
+export * from "./fox-accordion"; |
|
| 1 | 2 |
export * from "./fox-address"; |
| 2 | 3 |
export * from "./fox-alert"; |
| 3 | 4 |
export * from "./fox-badge"; |
... | ... | @@ -9,6 +10,7 @@ |
| 9 | 10 |
export * from "./fox-button"; |
| 10 | 11 |
export * from "./fox-button-group"; |
| 11 | 12 |
export * from "./fox-button-panel"; |
| 13 |
+export * from "./fox-card"; |
|
| 12 | 14 |
export * from "./fox-carousel-area"; |
| 13 | 15 |
export * from "./fox-carousel-button"; |
| 14 | 16 |
export * from "./fox-carousel-indicator"; |
... | ... | @@ -21,6 +23,7 @@ |
| 21 | 23 |
export * from "./fox-chip-select-option"; |
| 22 | 24 |
export * from "./fox-conditional-option"; |
| 23 | 25 |
export * from "./fox-contextual-trigger"; |
| 26 |
+export * from "./fox-disclosure"; |
|
| 24 | 27 |
export * from "./fox-email"; |
| 25 | 28 |
export * from "./fox-file-upload"; |
| 26 | 29 |
export * from "./fox-form-label"; |
... | ... | @@ -28,6 +31,8 @@ |
| 28 | 31 |
export * from "./fox-icon-button"; |
| 29 | 32 |
export * from "./fox-input"; |
| 30 | 33 |
export * from "./fox-link-button"; |
| 34 |
+export * from "./fox-modal"; |
|
| 35 |
+export * from "./fox-page-header"; |
|
| 31 | 36 |
export * from "./fox-pagination"; |
| 32 | 37 |
export * from "./fox-phone-number"; |
| 33 | 38 |
export * from "./fox-popover"; |
... | ... | @@ -60,4 +65,7 @@ |
| 60 | 65 |
export * from "./fox-tooltip-rich"; |
| 61 | 66 |
export * from "./fox-toggle-quantity"; |
| 62 | 67 |
export * from "./fox-toggle-switch"; |
| 63 |
- |
|
| 68 |
+export * from "./fox-list-search"; |
|
| 69 |
+export * from "./fox-list-toolbar"; |
|
| 70 |
+export * from "./fox-list-container"; |
|
| 71 |
+export * from "./fox-description-list"; |
+++ @fox/core/validation/fox-validation-messages.ts
... | ... | @@ -0,0 +1,97 @@ |
| 1 | +/** | |
| 2 | + * 오류 객체 → 화면 문구. | |
| 3 | + * | |
| 4 | + * 검증기와 분리한 이유는 Angular가 `Validators`에 문구를 두지 않은 이유와 같다 — 같은 규칙도 | |
| 5 | + * 자리에 따라 다른 말로 안내해야 한다("10자 이상"과 "비밀번호는 10자 이상"). 검증기는 무엇이 | |
| 6 | + * 틀렸는지만 말하고, 그것을 무슨 말로 옮길지는 여기서(또는 호출부의 `overrides`로) 정한다. | |
| 7 | + * | |
| 8 | + * 한 번에 **한 문구만** 낸다 — 입력 칸 아래 헬퍼 자리가 한 줄이고, 오류를 쌓아 보여주면 어느 | |
| 9 | + * 것부터 고쳐야 할지 알기 어렵다. 순서는 `MESSAGE_ORDER`가 정한다(비어 있음 → 길이 → 조합). | |
| 10 | + */ | |
| 11 | + | |
| 12 | +import type { FoxValidationErrors } from './fox-validators'; | |
| 13 | + | |
| 14 | +/** 오류 키별 기본 문구. 값이 필요한 것은 오류 객체의 맥락을 받아 만든다. */ | |
| 15 | +type MessageFactory = (detail: unknown) => string; | |
| 16 | + | |
| 17 | +function detailOf<T>(detail: unknown): T { | |
| 18 | + return detail as T; | |
| 19 | +} | |
| 20 | + | |
| 21 | +const DEFAULT_MESSAGES: Record<string, MessageFactory> = { | |
| 22 | + required: () => '필수 입력 항목입니다.', | |
| 23 | + minlength: (detail) => | |
| 24 | + `${detailOf<{ requiredLength: number }>(detail).requiredLength}자 이상 입력해 주세요.`, | |
| 25 | + maxlength: (detail) => | |
| 26 | + `${detailOf<{ requiredLength: number }>(detail).requiredLength}자 이내로 입력해 주세요.`, | |
| 27 | + pattern: () => '형식이 올바르지 않습니다.', | |
| 28 | + email: () => '이메일 형식이 올바르지 않습니다.', | |
| 29 | + characterKinds: (detail) => | |
| 30 | + `영문, 숫자, 특수문자 중 ${ | |
| 31 | + detailOf<{ requiredKinds: number }>(detail).requiredKinds | |
| 32 | + }종류 이상을 섞어 주세요.`, | |
| 33 | + repeatedCharacters: (detail) => | |
| 34 | + `같은 문자를 ${detailOf<{ max: number }>(detail).max}번까지만 연달아 쓸 수 있습니다.`, | |
| 35 | + sequentialCharacters: (detail) => | |
| 36 | + `연속된 문자나 숫자를 ${ | |
| 37 | + detailOf<{ max: number }>(detail).max | |
| 38 | + }자를 넘겨 쓸 수 없습니다.`, | |
| 39 | + notContaining: (detail) => | |
| 40 | + `${detailOf<{ forbidden: string }>(detail).forbidden}은(는) 포함할 수 없습니다.`, | |
| 41 | + equalTo: () => '값이 일치하지 않습니다.', | |
| 42 | +}; | |
| 43 | + | |
| 44 | +/** 먼저 고쳐야 하는 것부터. 여기 없는 키는 뒤에 남은 순서대로 본다. */ | |
| 45 | +const MESSAGE_ORDER = [ | |
| 46 | + 'required', | |
| 47 | + 'minlength', | |
| 48 | + 'maxlength', | |
| 49 | + 'pattern', | |
| 50 | + 'email', | |
| 51 | + 'characterKinds', | |
| 52 | + 'repeatedCharacters', | |
| 53 | + 'sequentialCharacters', | |
| 54 | + 'notContaining', | |
| 55 | + 'equalTo', | |
| 56 | +]; | |
| 57 | + | |
| 58 | +/** 오류 키 → 문구 덮어쓰기. 문자열이면 그대로, 함수면 맥락을 받아 만든다. */ | |
| 59 | +export type FoxValidationMessages = Record< | |
| 60 | + string, | |
| 61 | + string | ((detail: unknown) => string) | |
| 62 | +>; | |
| 63 | + | |
| 64 | +/** | |
| 65 | + * 오류 객체에서 보여줄 문구 하나를 고른다. 오류가 없으면 `undefined`. | |
| 66 | + * 덮어쓰기에 없는 키는 기본 문구로 떨어지고, 기본 문구에도 없으면 키를 그대로 쓰지 않고 | |
| 67 | + * `undefined`를 돌려준다 — 정체불명의 영문 키를 사용자에게 보이지 않기 위해서다. | |
| 68 | + */ | |
| 69 | +export function foxValidationMessage( | |
| 70 | + errors: FoxValidationErrors | null, | |
| 71 | + overrides: FoxValidationMessages = {} | |
| 72 | +): string | undefined { | |
| 73 | + if (!errors) { | |
| 74 | + return undefined; | |
| 75 | + } | |
| 76 | + | |
| 77 | + const keys = Object.keys(errors); | |
| 78 | + const ordered = [ | |
| 79 | + ...MESSAGE_ORDER.filter((key) => keys.includes(key)), | |
| 80 | + ...keys.filter((key) => !MESSAGE_ORDER.includes(key)), | |
| 81 | + ]; | |
| 82 | + | |
| 83 | + for (const key of ordered) { | |
| 84 | + const override = overrides[key]; | |
| 85 | + if (typeof override === 'string') { | |
| 86 | + return override; | |
| 87 | + } | |
| 88 | + if (typeof override === 'function') { | |
| 89 | + return override(errors[key]); | |
| 90 | + } | |
| 91 | + const fallback = DEFAULT_MESSAGES[key]; | |
| 92 | + if (fallback) { | |
| 93 | + return fallback(errors[key]); | |
| 94 | + } | |
| 95 | + } | |
| 96 | + return undefined; | |
| 97 | +} |
+++ @fox/core/validation/fox-validators.ts
... | ... | @@ -0,0 +1,235 @@ |
| 1 | +/** | |
| 2 | + * @fox 입력 검증기 — Angular `Validators`의 계약을 그대로 옮겼다. | |
| 3 | + * | |
| 4 | + * 검증기는 **값을 받아 통과면 `null`, 아니면 오류 객체**를 돌려주는 순수 함수다. 오류 객체의 | |
| 5 | + * 키가 곧 오류의 이름이고(`{ minlength: … }`), 값에는 문구를 만드는 데 필요한 맥락을 담는다 | |
| 6 | + * (`{ requiredLength, actualLength }`). Angular가 이 모양을 쓰는 이유가 여기서도 그대로다 — | |
| 7 | + * **문구를 검증기가 정하지 않기 때문에** 같은 규칙을 화면마다 다른 말로 안내할 수 있다. | |
| 8 | + * 기본 문구는 `fox-validation-messages.ts`가 따로 갖는다. | |
| 9 | + * | |
| 10 | + * Angular와 다른 점은 하나다: 인자가 `AbstractControl`이 아니라 문자열이다. @fox에는 폼 모델이 | |
| 11 | + * 없고 검증 대상이 입력 칸의 값 하나뿐이라, 컨트롤 객체를 두면 감싸는 비용만 늘어난다. | |
| 12 | + * | |
| 13 | + * `required`를 뺀 모든 검증기는 **빈 값을 통과시킨다**(Angular와 같다) — "비어 있음"의 판정은 | |
| 14 | + * `required` 하나가 맡아야 빈 칸에 오류가 두 개씩 뜨지 않는다. | |
| 15 | + */ | |
| 16 | + | |
| 17 | +export type FoxValidationErrors = Record<string, unknown>; | |
| 18 | + | |
| 19 | +/** 통과면 `null`, 아니면 오류 객체. */ | |
| 20 | +export type FoxValidator = (value: string) => FoxValidationErrors | null; | |
| 21 | + | |
| 22 | +function isEmpty(value: string): boolean { | |
| 23 | + return value.trim().length === 0; | |
| 24 | +} | |
| 25 | + | |
| 26 | +/** 값이 비어 있으면 `{ required: true }`. */ | |
| 27 | +function required(value: string): FoxValidationErrors | null { | |
| 28 | + return isEmpty(value) ? { required: true } : null; | |
| 29 | +} | |
| 30 | + | |
| 31 | +function minLength(requiredLength: number): FoxValidator { | |
| 32 | + return (value) => { | |
| 33 | + if (isEmpty(value) || value.length >= requiredLength) { | |
| 34 | + return null; | |
| 35 | + } | |
| 36 | + return { minlength: { requiredLength, actualLength: value.length } }; | |
| 37 | + }; | |
| 38 | +} | |
| 39 | + | |
| 40 | +function maxLength(requiredLength: number): FoxValidator { | |
| 41 | + return (value) => { | |
| 42 | + if (value.length <= requiredLength) { | |
| 43 | + return null; | |
| 44 | + } | |
| 45 | + return { maxlength: { requiredLength, actualLength: value.length } }; | |
| 46 | + }; | |
| 47 | +} | |
| 48 | + | |
| 49 | +function pattern(requiredPattern: RegExp): FoxValidator { | |
| 50 | + return (value) => { | |
| 51 | + if (isEmpty(value) || requiredPattern.test(value)) { | |
| 52 | + return null; | |
| 53 | + } | |
| 54 | + return { pattern: { requiredPattern: requiredPattern.source, actualValue: value } }; | |
| 55 | + }; | |
| 56 | +} | |
| 57 | + | |
| 58 | +/** 오탈자를 걸러내는 수준만 본다 — 이메일의 완전한 문법은 정규식으로 판정할 수 없다. */ | |
| 59 | +function email(value: string): FoxValidationErrors | null { | |
| 60 | + if (isEmpty(value) || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { | |
| 61 | + return null; | |
| 62 | + } | |
| 63 | + return { email: true }; | |
| 64 | +} | |
| 65 | + | |
| 66 | +/** 비밀번호 조합에 쓰는 문자 종류. 대문자는 소문자와 한 종류로 센다(시안이 그렇게 묻는다). */ | |
| 67 | +const CHARACTER_KIND_PATTERNS = { | |
| 68 | + letter: /[a-zA-Z]/, | |
| 69 | + digit: /\d/, | |
| 70 | + symbol: /[^a-zA-Z0-9]/, | |
| 71 | +} as const; | |
| 72 | + | |
| 73 | +export type FoxCharacterKind = keyof typeof CHARACTER_KIND_PATTERNS; | |
| 74 | + | |
| 75 | +/** 영문·숫자·특수문자 중 몇 종류가 섞였는지 센다. */ | |
| 76 | +function countCharacterKinds(value: string): FoxCharacterKind[] { | |
| 77 | + return (Object.keys(CHARACTER_KIND_PATTERNS) as FoxCharacterKind[]).filter( | |
| 78 | + (kind) => CHARACTER_KIND_PATTERNS[kind].test(value) | |
| 79 | + ); | |
| 80 | +} | |
| 81 | + | |
| 82 | +/** "영문·숫자·특수문자 중 N종류 이상" — 비밀번호 규칙의 가장 흔한 형태다. */ | |
| 83 | +function characterKinds(requiredKinds: number): FoxValidator { | |
| 84 | + return (value) => { | |
| 85 | + if (isEmpty(value)) { | |
| 86 | + return null; | |
| 87 | + } | |
| 88 | + const kinds = countCharacterKinds(value); | |
| 89 | + if (kinds.length >= requiredKinds) { | |
| 90 | + return null; | |
| 91 | + } | |
| 92 | + return { characterKinds: { requiredKinds, actualKinds: kinds.length, kinds } }; | |
| 93 | + }; | |
| 94 | +} | |
| 95 | + | |
| 96 | +/** 같은 문자가 `max`회를 넘겨 연달아 오면 오류(`aaa`). */ | |
| 97 | +function noRepeatedCharacters(max: number): FoxValidator { | |
| 98 | + return (value) => { | |
| 99 | + if (isEmpty(value)) { | |
| 100 | + return null; | |
| 101 | + } | |
| 102 | + let run = 1; | |
| 103 | + for (let i = 1; i < value.length; i += 1) { | |
| 104 | + run = value[i] === value[i - 1] ? run + 1 : 1; | |
| 105 | + if (run > max) { | |
| 106 | + return { repeatedCharacters: { max, character: value[i] } }; | |
| 107 | + } | |
| 108 | + } | |
| 109 | + return null; | |
| 110 | + }; | |
| 111 | +} | |
| 112 | + | |
| 113 | +/** 코드값이 잇따르는 문자가 `max`회를 넘기면 오류(`abcd`·`4321`). 오름·내림 둘 다 본다. */ | |
| 114 | +function noSequentialCharacters(max: number): FoxValidator { | |
| 115 | + return (value) => { | |
| 116 | + if (isEmpty(value)) { | |
| 117 | + return null; | |
| 118 | + } | |
| 119 | + let ascending = 1; | |
| 120 | + let descending = 1; | |
| 121 | + for (let i = 1; i < value.length; i += 1) { | |
| 122 | + const step = value.charCodeAt(i) - value.charCodeAt(i - 1); | |
| 123 | + ascending = step === 1 ? ascending + 1 : 1; | |
| 124 | + descending = step === -1 ? descending + 1 : 1; | |
| 125 | + if (ascending > max || descending > max) { | |
| 126 | + return { sequentialCharacters: { max, at: i - max } }; | |
| 127 | + } | |
| 128 | + } | |
| 129 | + return null; | |
| 130 | + }; | |
| 131 | +} | |
| 132 | + | |
| 133 | +/** | |
| 134 | + * 지정한 문자열을 품고 있으면 오류. 비밀번호에 ID·이름을 넣지 못하게 하는 규칙이다. | |
| 135 | + * | |
| 136 | + * 값을 함수로도 받는 이유는 그 대상이 **다른 칸의 지금 값**이기 때문이다 — 배열로 굳혀 두면 | |
| 137 | + * ID를 고친 뒤에도 옛 ID로 검사한다. | |
| 138 | + */ | |
| 139 | +function notContaining( | |
| 140 | + forbidden: string[] | (() => string[]), | |
| 141 | + options: { caseSensitive?: boolean } = {} | |
| 142 | +): FoxValidator { | |
| 143 | + return (value) => { | |
| 144 | + if (isEmpty(value)) { | |
| 145 | + return null; | |
| 146 | + } | |
| 147 | + const list = typeof forbidden === 'function' ? forbidden() : forbidden; | |
| 148 | + const haystack = options.caseSensitive ? value : value.toLowerCase(); | |
| 149 | + | |
| 150 | + for (const raw of list) { | |
| 151 | + const needle = options.caseSensitive ? raw : raw.toLowerCase(); | |
| 152 | + if (needle.length > 0 && haystack.includes(needle)) { | |
| 153 | + return { notContaining: { forbidden: raw } }; | |
| 154 | + } | |
| 155 | + } | |
| 156 | + return null; | |
| 157 | + }; | |
| 158 | +} | |
| 159 | + | |
| 160 | +/** 다른 칸과 값이 같아야 한다(비밀번호 확인). 대상이 바뀌므로 함수로 받는다. */ | |
| 161 | +function equalTo(other: () => string): FoxValidator { | |
| 162 | + return (value) => (isEmpty(value) || value === other() ? null : { equalTo: true }); | |
| 163 | +} | |
| 164 | + | |
| 165 | +/** | |
| 166 | + * 여러 검증기를 하나로 합친다. **통과하지 못한 것들의 오류 객체를 전부 병합해** 돌려준다 | |
| 167 | + * (Angular의 `Validators.compose`와 같다) — 첫 오류에서 멈추지 않아야 "10자 이상"과 | |
| 168 | + * "2종류 이상"을 함께 안내할 수 있다. | |
| 169 | + */ | |
| 170 | +function compose(validators: FoxValidator[]): FoxValidator { | |
| 171 | + return (value) => { | |
| 172 | + const merged = validators.reduce<FoxValidationErrors>((acc, validate) => { | |
| 173 | + const errors = validate(value); | |
| 174 | + return errors ? { ...acc, ...errors } : acc; | |
| 175 | + }, {}); | |
| 176 | + return Object.keys(merged).length > 0 ? merged : null; | |
| 177 | + }; | |
| 178 | +} | |
| 179 | + | |
| 180 | +export const foxValidators = { | |
| 181 | + required, | |
| 182 | + minLength, | |
| 183 | + maxLength, | |
| 184 | + pattern, | |
| 185 | + email, | |
| 186 | + characterKinds, | |
| 187 | + noRepeatedCharacters, | |
| 188 | + noSequentialCharacters, | |
| 189 | + notContaining, | |
| 190 | + equalTo, | |
| 191 | + compose, | |
| 192 | +}; | |
| 193 | + | |
| 194 | +export interface FoxPasswordPolicy { | |
| 195 | + /** 기본 8. 시안이 더 길게 요구하면 그 값을 준다. */ | |
| 196 | + minLength?: number; | |
| 197 | + maxLength?: number; | |
| 198 | + /** 영문·숫자·특수문자 중 최소 몇 종류를 섞을지. 기본 2. */ | |
| 199 | + kinds?: number; | |
| 200 | + /** 같은 문자 연속 허용 횟수. 주지 않으면 검사하지 않는다. */ | |
| 201 | + maxRepeated?: number; | |
| 202 | + /** 잇따르는 문자 허용 길이(`abc`). 주지 않으면 검사하지 않는다. */ | |
| 203 | + maxSequential?: number; | |
| 204 | + /** 비밀번호에 들어가면 안 되는 값(보통 ID·이름). 지금 값을 읽도록 함수로 줄 수 있다. */ | |
| 205 | + forbidden?: string[] | (() => string[]); | |
| 206 | + /** 참이면 비어 있는 것도 오류다. 수정 화면처럼 "비우면 유지"인 자리에서는 끈다. */ | |
| 207 | + required?: boolean; | |
| 208 | +} | |
| 209 | + | |
| 210 | +/** | |
| 211 | + * 비밀번호 칸의 규칙 한 벌. `type="password"` 입력에 그대로 얹으라고 둔 조합이다 — | |
| 212 | + * 규칙 자체는 위 검증기들이고, 이 함수는 자주 쓰는 묶음에 이름을 붙인 것뿐이다. | |
| 213 | + */ | |
| 214 | +export function foxPasswordValidator(policy: FoxPasswordPolicy = {}): FoxValidator { | |
| 215 | + const { | |
| 216 | + minLength: min = 8, | |
| 217 | + maxLength: max, | |
| 218 | + kinds = 2, | |
| 219 | + maxRepeated, | |
| 220 | + maxSequential, | |
| 221 | + forbidden, | |
| 222 | + required: isRequired = false, | |
| 223 | + } = policy; | |
| 224 | + | |
| 225 | + const validators: FoxValidator[] = []; | |
| 226 | + if (isRequired) validators.push(required); | |
| 227 | + validators.push(minLength(min)); | |
| 228 | + if (max !== undefined) validators.push(maxLength(max)); | |
| 229 | + validators.push(characterKinds(kinds)); | |
| 230 | + if (maxRepeated !== undefined) validators.push(noRepeatedCharacters(maxRepeated)); | |
| 231 | + if (maxSequential !== undefined) validators.push(noSequentialCharacters(maxSequential)); | |
| 232 | + if (forbidden !== undefined) validators.push(notContaining(forbidden)); | |
| 233 | + | |
| 234 | + return compose(validators); | |
| 235 | +} |
+++ @fox/core/validation/index.ts
... | ... | @@ -0,0 +1,12 @@ |
| 1 | +export { | |
| 2 | + foxValidators, | |
| 3 | + foxPasswordValidator, | |
| 4 | + type FoxValidator, | |
| 5 | + type FoxValidationErrors, | |
| 6 | + type FoxCharacterKind, | |
| 7 | + type FoxPasswordPolicy, | |
| 8 | +} from "./fox-validators"; | |
| 9 | +export { | |
| 10 | + foxValidationMessage, | |
| 11 | + type FoxValidationMessages, | |
| 12 | +} from "./fox-validation-messages"; |
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
... | ... | @@ -69,6 +69,11 @@ |
| 69 | 69 |
import { FoxTooltip } from "../core/components/fox-tooltip";
|
| 70 | 70 |
import { FoxTooltipRich } from "../core/components/fox-tooltip-rich";
|
| 71 | 71 |
import { FoxToggleQuantity } from "../core/components/fox-toggle-quantity";
|
| 72 |
+import { FoxPageHeader } from "../core/components/fox-page-header";
|
|
| 73 |
+import { FoxModal } from "../core/components/fox-modal";
|
|
| 74 |
+import { FoxAccordion } from "../core/components/fox-accordion";
|
|
| 75 |
+import { FoxCard } from "../core/components/fox-card";
|
|
| 76 |
+import { FoxDisclosure } from "../core/components/fox-disclosure";
|
|
| 72 | 77 |
import styles from "./dev-test.module.scss"; |
| 73 | 78 |
|
| 74 | 79 |
export interface ComponentExample {
|
... | ... | @@ -609,10 +614,72 @@ |
| 609 | 614 |
); |
| 610 | 615 |
} |
| 611 | 616 |
|
| 612 |
-/** |
|
| 613 |
- * 토스트는 상태를 갖지 않는다 — 언제 뜨고 언제 사라질지, 화면 어디에 놓일지가 전부 호출부 |
|
| 614 |
- * 몫이라는 것을 보여 주는 예제다. 여기서는 3초 뒤에 스스로 지우고, 자리는 감싼 div가 잡는다. |
|
| 615 |
- */ |
|
| 617 |
+function ModalDemo({
|
|
| 618 |
+ size, |
|
| 619 |
+ title, |
|
| 620 |
+ withUtility = false, |
|
| 621 |
+ withActions = true, |
|
| 622 |
+ closable = true, |
|
| 623 |
+ withImage = false, |
|
| 624 |
+ openLabel, |
|
| 625 |
+}: {
|
|
| 626 |
+ size: "sm" | "md" | "lg" | "full"; |
|
| 627 |
+ title?: string; |
|
| 628 |
+ withUtility?: boolean; |
|
| 629 |
+ withActions?: boolean; |
|
| 630 |
+ closable?: boolean; |
|
| 631 |
+ withImage?: boolean; |
|
| 632 |
+ openLabel?: string; |
|
| 633 |
+}) {
|
|
| 634 |
+ const [open, setOpen] = useState(false); |
|
| 635 |
+ return ( |
|
| 636 |
+ <> |
|
| 637 |
+ <FoxButton size="md" type="secondary" label={openLabel ?? `${size} 열기`} onAction={() => setOpen(true)} />
|
|
| 638 |
+ <FoxModal |
|
| 639 |
+ open={open}
|
|
| 640 |
+ onClose={() => setOpen(false)}
|
|
| 641 |
+ size={size}
|
|
| 642 |
+ title={title}
|
|
| 643 |
+ closable={closable}
|
|
| 644 |
+ utility={
|
|
| 645 |
+ withUtility ? ( |
|
| 646 |
+ <FoxCheckbox size="md" label="오늘 하루 보지 않기" /> |
|
| 647 |
+ ) : undefined |
|
| 648 |
+ } |
|
| 649 |
+ actions={
|
|
| 650 |
+ withActions ? ( |
|
| 651 |
+ <> |
|
| 652 |
+ <FoxButton size="md" label="취소" onAction={() => setOpen(false)} />
|
|
| 653 |
+ <FoxButton size="md" type="primary" label="확인" onAction={() => setOpen(false)} />
|
|
| 654 |
+ </> |
|
| 655 |
+ ) : undefined |
|
| 656 |
+ } |
|
| 657 |
+ > |
|
| 658 |
+ {/* 시안의 contents 슬롯에는 그림이 들어간 예가 있다 — 슬롯이라 무엇이든 놓을 수 있다. */}
|
|
| 659 |
+ {withImage && <DemoThumb />}
|
|
| 660 |
+ <p> |
|
| 661 |
+ 대화 상자는 사용자에게 작업에 대해 알리고 중요한 정보를 포함하거나 결정이 필요하거나 |
|
| 662 |
+ 여러 작업을 포함할 수 있습니다. |
|
| 663 |
+ </p> |
|
| 664 |
+ </FoxModal> |
|
| 665 |
+ </> |
|
| 666 |
+ ); |
|
| 667 |
+} |
|
| 668 |
+ |
|
| 669 |
+function DemoThumb() {
|
|
| 670 |
+ return ( |
|
| 671 |
+ <svg viewBox="0 0 320 180" preserveAspectRatio="xMidYMid slice" aria-hidden="true"> |
|
| 672 |
+ <rect width="320" height="180" fill="var(--fox-color-surface-neutral-gray-strong)" /> |
|
| 673 |
+ <circle cx="232" cy="52" r="26" fill="var(--fox-color-element-theme-primary)" opacity="0.35" /> |
|
| 674 |
+ <path |
|
| 675 |
+ d="M0 148l72-52 54 38 60-46 134 92v0H0z" |
|
| 676 |
+ fill="var(--fox-color-element-neutral-default)" |
|
| 677 |
+ opacity="0.45" |
|
| 678 |
+ /> |
|
| 679 |
+ </svg> |
|
| 680 |
+ ); |
|
| 681 |
+} |
|
| 682 |
+ |
|
| 616 | 683 |
function ToastDemo() {
|
| 617 | 684 |
const [open, setOpen] = useState(false); |
| 618 | 685 |
|
... | ... | @@ -3418,6 +3485,229 @@ |
| 3418 | 3485 |
], |
| 3419 | 3486 |
}, |
| 3420 | 3487 |
{
|
| 3488 |
+ id: "fox-page-header", |
|
| 3489 |
+ name: "FoxPageHeader", |
|
| 3490 |
+ description: |
|
| 3491 |
+ "Figma 시안(page-tit-area 3002:6823, 관리자페이지 파일)을 옮겼습니다. 현재 위치와 제목을 얹고 본문과의 간격(아래 여백)까지 갖습니다 — 시안이 그렇게 묶어 놓아 화면마다 따로 띄우지 않아도 됩니다. 제목은 화면에 하나뿐인 이름이라 <h1>으로 나갑니다.", |
|
| 3492 |
+ variants: [ |
|
| 3493 |
+ {
|
|
| 3494 |
+ label: "시안 그대로 — 현재 위치 + 제목", |
|
| 3495 |
+ node: <FoxPageHeader breadcrumb={BREADCRUMB_ITEMS} title="리스트 페이지" />,
|
|
| 3496 |
+ }, |
|
| 3497 |
+ {
|
|
| 3498 |
+ label: "현재 위치 없이 제목만", |
|
| 3499 |
+ node: <FoxPageHeader title="리스트 페이지" />, |
|
| 3500 |
+ }, |
|
| 3501 |
+ {
|
|
| 3502 |
+ label: "오른쪽 자리 (actions) — 시안엔 없지만 배치가 자리를 비워 둡니다", |
|
| 3503 |
+ node: ( |
|
| 3504 |
+ <FoxPageHeader |
|
| 3505 |
+ breadcrumb={BREADCRUMB_ITEMS}
|
|
| 3506 |
+ title="리스트 페이지" |
|
| 3507 |
+ actions={
|
|
| 3508 |
+ <FoxButtonGroup size="md"> |
|
| 3509 |
+ <FoxButton size="md" type="secondary" label="엑셀 다운로드" /> |
|
| 3510 |
+ <FoxButton size="md" type="primary" label="등록" /> |
|
| 3511 |
+ </FoxButtonGroup> |
|
| 3512 |
+ } |
|
| 3513 |
+ /> |
|
| 3514 |
+ ), |
|
| 3515 |
+ }, |
|
| 3516 |
+ {
|
|
| 3517 |
+ label: "제목이 길어질 때 — 오른쪽 자리를 침범하지 않고 접힙니다", |
|
| 3518 |
+ node: ( |
|
| 3519 |
+ <FoxPageHeader |
|
| 3520 |
+ breadcrumb={BREADCRUMB_ITEMS}
|
|
| 3521 |
+ title="아주 긴 화면 제목이 들어가면 이렇게 여러 줄로 접힙니다 아주 긴 화면 제목" |
|
| 3522 |
+ actions={<FoxButton size="md" type="primary" label="등록" />}
|
|
| 3523 |
+ /> |
|
| 3524 |
+ ), |
|
| 3525 |
+ }, |
|
| 3526 |
+ ], |
|
| 3527 |
+ }, |
|
| 3528 |
+ {
|
|
| 3529 |
+ id: "fox-modal", |
|
| 3530 |
+ name: "FoxModal", |
|
| 3531 |
+ description: |
|
| 3532 |
+ "Figma 시안(modal 190:1136)을 옮겼습니다. 네이티브 <dialog>를 showModal()로 띄워 초점 가두기·Esc 닫기·뒷배경·맨 위 레이어를 브라우저가 처리하게 합니다. 열림 여부는 호출부가 소유하고, Esc나 바깥 클릭에도 모달이 스스로 닫지 않고 onClose만 부릅니다. 창을 768px 아래로 좁히면 네 크기가 같은 폭으로 모이고 여백·모서리·닫기 버튼도 함께 줄어듭니다.", |
|
| 3533 |
+ variants: [ |
|
| 3534 |
+ {
|
|
| 3535 |
+ label: "크기 — sm 400 · md 560 · lg 760 · full", |
|
| 3536 |
+ node: ( |
|
| 3537 |
+ <div className={styles.demoRow}>
|
|
| 3538 |
+ <ModalDemo size="sm" title="타이틀" /> |
|
| 3539 |
+ <ModalDemo size="md" title="타이틀" /> |
|
| 3540 |
+ <ModalDemo size="lg" title="타이틀" /> |
|
| 3541 |
+ <ModalDemo size="full" title="타이틀" /> |
|
| 3542 |
+ </div> |
|
| 3543 |
+ ), |
|
| 3544 |
+ }, |
|
| 3545 |
+ {
|
|
| 3546 |
+ label: "그림이 들어갈 때 — contents는 슬롯이라 무엇이든 놓입니다", |
|
| 3547 |
+ node: ( |
|
| 3548 |
+ <div className={styles.demoRow}>
|
|
| 3549 |
+ <ModalDemo size="md" title="타이틀" withImage openLabel="그림 있는 모달 열기" /> |
|
| 3550 |
+ <ModalDemo size="lg" title="타이틀" withImage openLabel="lg 그림 모달 열기" /> |
|
| 3551 |
+ </div> |
|
| 3552 |
+ ), |
|
| 3553 |
+ }, |
|
| 3554 |
+ {
|
|
| 3555 |
+ label: "아래쪽 왼편 (utility) — 시안의 체크박스 자리", |
|
| 3556 |
+ node: ( |
|
| 3557 |
+ <div className={styles.demoRow}>
|
|
| 3558 |
+ <ModalDemo size="md" title="타이틀" withUtility /> |
|
| 3559 |
+ </div> |
|
| 3560 |
+ ), |
|
| 3561 |
+ }, |
|
| 3562 |
+ {
|
|
| 3563 |
+ label: "제목 없이 / 버튼 없이 / 닫기 없이", |
|
| 3564 |
+ node: ( |
|
| 3565 |
+ <div className={styles.demoRow}>
|
|
| 3566 |
+ <ModalDemo size="sm" /> |
|
| 3567 |
+ <ModalDemo size="sm" title="타이틀" withActions={false} />
|
|
| 3568 |
+ <ModalDemo size="sm" title="타이틀" closable={false} />
|
|
| 3569 |
+ </div> |
|
| 3570 |
+ ), |
|
| 3571 |
+ }, |
|
| 3572 |
+ ], |
|
| 3573 |
+ }, |
|
| 3574 |
+ {
|
|
| 3575 |
+ id: "fox-accordion", |
|
| 3576 |
+ name: "FoxAccordion", |
|
| 3577 |
+ description: |
|
| 3578 |
+ "Figma 시안(accordion 194:3563 box · 194:3680 line)을 옮겼습니다. box는 색을 깐 상자로 제목이 강조 색이고, line은 위쪽 선 하나로 나뉘는 목록형입니다. 열림을 밖에서 쥐면 여러 개 중 하나만 열리게 하는 것도 호출부가 정할 수 있고, 안 넘기면 스스로 여닫습니다. 닫히면 내용이 DOM에서 빠져 탭 순서에 남지 않습니다.", |
|
| 3579 |
+ variants: [ |
|
| 3580 |
+ {
|
|
| 3581 |
+ label: "box — lg · md", |
|
| 3582 |
+ node: ( |
|
| 3583 |
+ <div className={styles.demoStack}>
|
|
| 3584 |
+ <FoxAccordion size="lg" title="아코디언 타이틀 영역" defaultOpen |
|
| 3585 |
+ badge={<FoxBadgeGroup size="md"><FoxBadge size="md">레이블</FoxBadge></FoxBadgeGroup>}>
|
|
| 3586 |
+ <p>펼쳐진 내용이 들어가는 자리입니다.</p> |
|
| 3587 |
+ </FoxAccordion> |
|
| 3588 |
+ <FoxAccordion size="md" title="아코디언 타이틀 영역" |
|
| 3589 |
+ badge={<FoxBadgeGroup size="sm"><FoxBadge size="sm">레이블</FoxBadge></FoxBadgeGroup>}>
|
|
| 3590 |
+ <p>펼쳐진 내용이 들어가는 자리입니다.</p> |
|
| 3591 |
+ </FoxAccordion> |
|
| 3592 |
+ </div> |
|
| 3593 |
+ ), |
|
| 3594 |
+ }, |
|
| 3595 |
+ {
|
|
| 3596 |
+ label: "line — 이어 붙이면 위쪽 선이 칸막이가 됩니다", |
|
| 3597 |
+ node: ( |
|
| 3598 |
+ <div className={styles.demoField}>
|
|
| 3599 |
+ <FoxAccordion type="line" size="lg" title="아코디언 타이틀 영역" defaultOpen> |
|
| 3600 |
+ <p>펼쳐진 내용이 들어가는 자리입니다.</p> |
|
| 3601 |
+ </FoxAccordion> |
|
| 3602 |
+ <FoxAccordion type="line" size="lg" title="아코디언 타이틀 영역" /> |
|
| 3603 |
+ <FoxAccordion type="line" size="md" title="아코디언 타이틀 영역" /> |
|
| 3604 |
+ </div> |
|
| 3605 |
+ ), |
|
| 3606 |
+ }, |
|
| 3607 |
+ {
|
|
| 3608 |
+ label: "배지 없이", |
|
| 3609 |
+ node: ( |
|
| 3610 |
+ <div className={styles.demoStack}>
|
|
| 3611 |
+ <FoxAccordion title="아코디언 타이틀 영역"> |
|
| 3612 |
+ <p>펼쳐진 내용이 들어가는 자리입니다.</p> |
|
| 3613 |
+ </FoxAccordion> |
|
| 3614 |
+ </div> |
|
| 3615 |
+ ), |
|
| 3616 |
+ }, |
|
| 3617 |
+ ], |
|
| 3618 |
+ }, |
|
| 3619 |
+ {
|
|
| 3620 |
+ id: "fox-disclosure", |
|
| 3621 |
+ name: "FoxDisclosure", |
|
| 3622 |
+ description: |
|
| 3623 |
+ "Figma 시안(disclosure 391:4082 닫힘 · 391:4083 열림)을 옮겼습니다. 캐럿이 제목 앞에 오고 바탕도 테두리도 없어, 글 사이에 끼우는 가벼운 접기입니다 — 목록을 나누는 FoxAccordion과는 다른 컴포넌트입니다. 시안에 크기 축도 계열 축도 없어 변형이 없고 상태는 닫힘·열림 둘뿐입니다(캐럿이 오른쪽 → 위로 바뀝니다). 열림을 밖에서 쥘 수 있고 안 넘기면 스스로 여닫습니다. 닫히면 내용이 DOM에서 빠져 탭 순서에 남지 않습니다.", |
|
| 3624 |
+ variants: [ |
|
| 3625 |
+ {
|
|
| 3626 |
+ label: "시안 그대로 — 닫힘 · 열림 (눌러서 여닫아 보세요)", |
|
| 3627 |
+ node: ( |
|
| 3628 |
+ <div className={styles.demoStack}>
|
|
| 3629 |
+ <FoxDisclosure title="디스클로저 레이블"> |
|
| 3630 |
+ <p>펼쳐진 내용이 들어가는 자리입니다.</p> |
|
| 3631 |
+ </FoxDisclosure> |
|
| 3632 |
+ <FoxDisclosure title="디스클로저 레이블" defaultOpen> |
|
| 3633 |
+ <p>펼쳐진 내용이 들어가는 자리입니다.</p> |
|
| 3634 |
+ </FoxDisclosure> |
|
| 3635 |
+ </div> |
|
| 3636 |
+ ), |
|
| 3637 |
+ }, |
|
| 3638 |
+ {
|
|
| 3639 |
+ label: "내용은 슬롯이라 무엇이든 놓입니다", |
|
| 3640 |
+ node: ( |
|
| 3641 |
+ <div className={styles.demoStack}>
|
|
| 3642 |
+ <FoxDisclosure title="첨부된 그림 보기" defaultOpen> |
|
| 3643 |
+ <DemoThumb /> |
|
| 3644 |
+ </FoxDisclosure> |
|
| 3645 |
+ </div> |
|
| 3646 |
+ ), |
|
| 3647 |
+ }, |
|
| 3648 |
+ ], |
|
| 3649 |
+ }, |
|
| 3650 |
+ {
|
|
| 3651 |
+ id: "fox-card", |
|
| 3652 |
+ name: "FoxCard", |
|
| 3653 |
+ description: |
|
| 3654 |
+ "Figma 시안(card 441:6006)을 옮겼습니다. 크기 넷은 여백과 모서리만 바꿉니다 — 폭은 놓이는 자리가 정합니다(시안은 320px 자리에 뒀습니다). 그림은 16:9로 잘리고 상자가 모서리를 넘지 않게 자릅니다.", |
|
| 3655 |
+ variants: [ |
|
| 3656 |
+ {
|
|
| 3657 |
+ label: "그림이 있을 때 — 16:9로 잘리고 위쪽 모서리를 넘지 않습니다", |
|
| 3658 |
+ node: ( |
|
| 3659 |
+ <div className={styles.demoRow}>
|
|
| 3660 |
+ {(["xlg", "md"] as const).map((size) => (
|
|
| 3661 |
+ <div key={size} style={{ inlineSize: 320 }}>
|
|
| 3662 |
+ <FoxCard |
|
| 3663 |
+ size={size}
|
|
| 3664 |
+ image={<DemoThumb />}
|
|
| 3665 |
+ title="타이틀 영역" |
|
| 3666 |
+ description="간단한 설명이 들어가는 영역입니다." |
|
| 3667 |
+ actions={<FoxTextButton size="md" label="액션1" />}
|
|
| 3668 |
+ /> |
|
| 3669 |
+ </div> |
|
| 3670 |
+ ))} |
|
| 3671 |
+ </div> |
|
| 3672 |
+ ), |
|
| 3673 |
+ }, |
|
| 3674 |
+ {
|
|
| 3675 |
+ label: "크기 — xlg · lg · md · sm (여백과 모서리가 달라집니다)", |
|
| 3676 |
+ node: ( |
|
| 3677 |
+ <div className={styles.demoRow}>
|
|
| 3678 |
+ {(["xlg", "lg", "md", "sm"] as const).map((size) => (
|
|
| 3679 |
+ <div key={size} style={{ inlineSize: 320 }}>
|
|
| 3680 |
+ <FoxCard |
|
| 3681 |
+ size={size}
|
|
| 3682 |
+ title="타이틀 영역" |
|
| 3683 |
+ description="간단한 설명이 들어가는 영역입니다." |
|
| 3684 |
+ actions={<FoxTextButton size="md" label="액션1" />}
|
|
| 3685 |
+ /> |
|
| 3686 |
+ </div> |
|
| 3687 |
+ ))} |
|
| 3688 |
+ </div> |
|
| 3689 |
+ ), |
|
| 3690 |
+ }, |
|
| 3691 |
+ {
|
|
| 3692 |
+ label: "그림 없이 / 동작 없이 / 설명 없이", |
|
| 3693 |
+ node: ( |
|
| 3694 |
+ <div className={styles.demoRow}>
|
|
| 3695 |
+ <div style={{ inlineSize: 320 }}>
|
|
| 3696 |
+ <FoxCard title="타이틀 영역" description="간단한 설명이 들어가는 영역입니다." |
|
| 3697 |
+ actions={<FoxTextButton size="md" label="액션1" />} />
|
|
| 3698 |
+ </div> |
|
| 3699 |
+ <div style={{ inlineSize: 320 }}>
|
|
| 3700 |
+ <FoxCard title="타이틀 영역" description="간단한 설명이 들어가는 영역입니다." /> |
|
| 3701 |
+ </div> |
|
| 3702 |
+ <div style={{ inlineSize: 320 }}>
|
|
| 3703 |
+ <FoxCard title="타이틀 영역" actions={<FoxTextButton size="md" label="액션1" />} />
|
|
| 3704 |
+ </div> |
|
| 3705 |
+ </div> |
|
| 3706 |
+ ), |
|
| 3707 |
+ }, |
|
| 3708 |
+ ], |
|
| 3709 |
+ }, |
|
| 3710 |
+ {
|
|
| 3421 | 3711 |
id: "fox-toast", |
| 3422 | 3712 |
name: "FoxToast", |
| 3423 | 3713 |
description: |
+++ @fox/styles/_fox-accordion.scss
... | ... | @@ -0,0 +1,155 @@ |
| 1 | +// FoxAccordion — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) | |
| 2 | +// accordion (194:3563 box · 194:3680 line · 섹션 392:5034) | |
| 3 | +// | |
| 4 | +// 마크업 계약 (React 밖 소비자용): | |
| 5 | +// <div class="fox-accordion fox-accordion--box fox-accordion--lg fox-accordion--open"> | |
| 6 | +// <button class="fox-accordion__tit" type="button" aria-expanded="true" aria-controls="…"> | |
| 7 | +// <span class="fox-accordion__badge">…fox-badge-group…</span> <!-- 선택 --> | |
| 8 | +// <span class="fox-accordion__title">제목</span> | |
| 9 | +// <span class="fox-accordion__icon">…svg…</span> | |
| 10 | +// </button> | |
| 11 | +// <div class="fox-accordion__contents" id="…">…</div> <!-- 열렸을 때만 --> | |
| 12 | +// </div> | |
| 13 | +// | |
| 14 | +// 계열 둘이 성격이 다르다. | |
| 15 | +// - box: 색을 깐 상자. 여백·모서리를 상자가 갖고 제목이 강조 색이다. | |
| 16 | +// - line: 위쪽 선 하나로 나뉘는 목록형. 바탕이 없고 제목 줄이 세로 여백을 갖는다. | |
| 17 | +// | |
| 18 | +// 크기는 여백·글자·모서리를 함께 바꾼다(시안 lg·md). | |
| 19 | +// | |
| 20 | +// 토큰이 아닌 값: | |
| 21 | +// - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로 | |
| 22 | +// export하지 않는다. | |
| 23 | +// - 버튼 초기화(appearance/cursor/색 상속): 구조 규칙. | |
| 24 | + | |
| 25 | +@use "abstracts" as fox; | |
| 26 | + | |
| 27 | +.fox-accordion { | |
| 28 | + display: flex; | |
| 29 | + flex-direction: column; | |
| 30 | + align-items: flex-start; | |
| 31 | + inline-size: 100%; | |
| 32 | + | |
| 33 | + // 제목 줄은 통째로 누르는 자리다. | |
| 34 | + &__tit { | |
| 35 | + display: flex; | |
| 36 | + align-items: center; | |
| 37 | + gap: fox.gap(3); | |
| 38 | + inline-size: 100%; | |
| 39 | + margin: 0; | |
| 40 | + padding: 0; | |
| 41 | + border: none; | |
| 42 | + background: none; | |
| 43 | + font-family: fox.font-family(title); | |
| 44 | + font-weight: fox.font-weight(bold); | |
| 45 | + line-height: 1.4; | |
| 46 | + letter-spacing: -0.025em; | |
| 47 | + text-align: start; | |
| 48 | + cursor: pointer; | |
| 49 | + } | |
| 50 | + | |
| 51 | + &__badge { | |
| 52 | + display: inline-flex; | |
| 53 | + flex-shrink: 0; | |
| 54 | + } | |
| 55 | + | |
| 56 | + // 제목이 남는 폭을 먹고 아이콘은 오른쪽 끝에 붙는다. | |
| 57 | + &__title { | |
| 58 | + flex: 1 0 0; | |
| 59 | + min-inline-size: 0; | |
| 60 | + word-break: break-word; | |
| 61 | + } | |
| 62 | + | |
| 63 | + &__icon { | |
| 64 | + display: inline-flex; | |
| 65 | + flex-shrink: 0; | |
| 66 | + color: fox.color(icon-neutral-default); | |
| 67 | + | |
| 68 | + > * { | |
| 69 | + inline-size: fox.icon(4); | |
| 70 | + block-size: fox.icon(4); | |
| 71 | + } | |
| 72 | + } | |
| 73 | + | |
| 74 | + &__contents { | |
| 75 | + display: flex; | |
| 76 | + flex-direction: column; | |
| 77 | + align-items: flex-start; | |
| 78 | + inline-size: 100%; | |
| 79 | + color: fox.color(font-neutral-default); | |
| 80 | + font-family: fox.font-family(body); | |
| 81 | + font-size: fox.font-size(body-md); | |
| 82 | + font-weight: fox.font-weight(regular); | |
| 83 | + line-height: 1.5; | |
| 84 | + letter-spacing: -0.025em; | |
| 85 | + } | |
| 86 | + | |
| 87 | + // ── box 계열 ────────────────────────────────────────────────────────────── | |
| 88 | + &--box { | |
| 89 | + background: fox.color(surface-theme-secondary); | |
| 90 | + } | |
| 91 | + | |
| 92 | + &--box &__title { | |
| 93 | + color: fox.color(font-theme-secondary); | |
| 94 | + } | |
| 95 | + | |
| 96 | + &--box#{&}--lg { | |
| 97 | + gap: fox.gap(7); | |
| 98 | + padding-inline: fox.padding(9); | |
| 99 | + padding-block: fox.padding(8); | |
| 100 | + border-radius: fox.radius(5); | |
| 101 | + } | |
| 102 | + | |
| 103 | + &--box#{&}--md { | |
| 104 | + gap: fox.gap(5); | |
| 105 | + padding-inline: fox.padding(7); | |
| 106 | + padding-block: fox.padding(6); | |
| 107 | + border-radius: fox.radius(4); | |
| 108 | + } | |
| 109 | + | |
| 110 | + &--box#{&}--lg &__contents { | |
| 111 | + gap: fox.gap(7); | |
| 112 | + } | |
| 113 | + | |
| 114 | + &--box#{&}--md &__contents { | |
| 115 | + gap: fox.gap(5); | |
| 116 | + } | |
| 117 | + | |
| 118 | + // ── line 계열 ───────────────────────────────────────────────────────────── | |
| 119 | + // 여러 개를 이어 붙이면 위쪽 선이 칸막이가 된다. | |
| 120 | + &--line { | |
| 121 | + border-block-start: fox.border(1) solid fox.color(border-neutral-subtler); | |
| 122 | + background: fox.color(action-secondary); | |
| 123 | + } | |
| 124 | + | |
| 125 | + &--line &__title { | |
| 126 | + color: fox.color(font-neutral-strong); | |
| 127 | + } | |
| 128 | + | |
| 129 | + &--line#{&}--lg &__tit { | |
| 130 | + padding-block: fox.padding(8); | |
| 131 | + } | |
| 132 | + | |
| 133 | + &--line#{&}--md &__tit { | |
| 134 | + padding-block: fox.padding(6); | |
| 135 | + } | |
| 136 | + | |
| 137 | + &--line#{&}--lg &__contents { | |
| 138 | + gap: fox.gap(7); | |
| 139 | + padding-block-end: fox.padding(8); | |
| 140 | + } | |
| 141 | + | |
| 142 | + &--line#{&}--md &__contents { | |
| 143 | + gap: fox.gap(5); | |
| 144 | + padding-block-end: fox.padding(6); | |
| 145 | + } | |
| 146 | + | |
| 147 | + // ── 크기 (글자) ─────────────────────────────────────────────────────────── | |
| 148 | + &--lg &__title { | |
| 149 | + font-size: fox.font-size(heading-sm); | |
| 150 | + } | |
| 151 | + | |
| 152 | + &--md &__title { | |
| 153 | + font-size: fox.font-size(heading-xsm); | |
| 154 | + } | |
| 155 | +} |
+++ @fox/styles/_fox-card.scss
... | ... | @@ -0,0 +1,104 @@ |
| 1 | +// FoxCard — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) | |
| 2 | +// card (441:6006 · 섹션 463:5649) | |
| 3 | +// | |
| 4 | +// 마크업 계약 (React 밖 소비자용): | |
| 5 | +// <div class="fox-card fox-card--md"> | |
| 6 | +// <div class="fox-card__image">…img…</div> <!-- 선택 --> | |
| 7 | +// <div class="fox-card__contents"> | |
| 8 | +// <p class="fox-card__title">타이틀</p> | |
| 9 | +// <p class="fox-card__description">설명</p> | |
| 10 | +// </div> | |
| 11 | +// <div class="fox-card__actions">…fox-text-button…</div> <!-- 선택 --> | |
| 12 | +// </div> | |
| 13 | +// | |
| 14 | +// 크기 넷은 여백과 모서리만 바꾼다 — 폭은 놓이는 자리가 정한다(시안은 320px 자리에 뒀다). | |
| 15 | +// | |
| 16 | +// 그림은 16:9로 잘린다. 상자가 `overflow: clip`이라 위쪽 모서리를 그림이 넘지 않는다. | |
| 17 | +// | |
| 18 | +// 아래쪽 줄은 좌우·아래 여백만 갖는다 — 위쪽은 글 영역의 아래 여백이 이미 띄워 준다. | |
| 19 | +// | |
| 20 | +// 토큰이 아닌 값: | |
| 21 | +// - 그림 비율 16/9: 시안 값이나 Figma가 변수로 export하지 않는다(사용자 승인). | |
| 22 | +// - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로 | |
| 23 | +// export하지 않는다. | |
| 24 | +// - object-fit / overflow: 구조 규칙. | |
| 25 | + | |
| 26 | +@use "abstracts" as fox; | |
| 27 | + | |
| 28 | +.fox-card { | |
| 29 | + box-sizing: border-box; | |
| 30 | + display: flex; | |
| 31 | + flex-direction: column; | |
| 32 | + align-items: flex-start; | |
| 33 | + inline-size: 100%; | |
| 34 | + overflow: clip; | |
| 35 | + border: fox.border(1) solid fox.color(border-neutral-subtle); | |
| 36 | + background: fox.color(surface-neutral-default); | |
| 37 | + | |
| 38 | + &__image { | |
| 39 | + inline-size: 100%; | |
| 40 | + // 시안 값이나 변수로 export되지 않는다 — 파일 상단 참고. | |
| 41 | + aspect-ratio: 16 / 9; | |
| 42 | + | |
| 43 | + > * { | |
| 44 | + inline-size: 100%; | |
| 45 | + block-size: 100%; | |
| 46 | + object-fit: cover; | |
| 47 | + } | |
| 48 | + } | |
| 49 | + | |
| 50 | + &__contents { | |
| 51 | + display: flex; | |
| 52 | + flex-direction: column; | |
| 53 | + align-items: flex-start; | |
| 54 | + gap: fox.gap(2); | |
| 55 | + inline-size: 100%; | |
| 56 | + word-break: break-word; | |
| 57 | + } | |
| 58 | + | |
| 59 | + &__title { | |
| 60 | + inline-size: 100%; | |
| 61 | + margin: 0; | |
| 62 | + color: fox.color(font-neutral-strong); | |
| 63 | + font-family: fox.font-family(title); | |
| 64 | + font-size: fox.font-size(heading-sm); | |
| 65 | + font-weight: fox.font-weight(bold); | |
| 66 | + line-height: 1.4; | |
| 67 | + letter-spacing: -0.025em; | |
| 68 | + } | |
| 69 | + | |
| 70 | + &__description { | |
| 71 | + inline-size: 100%; | |
| 72 | + margin: 0; | |
| 73 | + color: fox.color(font-neutral-default); | |
| 74 | + font-family: fox.font-family(body); | |
| 75 | + font-size: fox.font-size(body-md); | |
| 76 | + font-weight: fox.font-weight(regular); | |
| 77 | + line-height: 1.5; | |
| 78 | + letter-spacing: -0.025em; | |
| 79 | + } | |
| 80 | + | |
| 81 | + // 위쪽은 글 영역의 아래 여백이 이미 띄워 준다. | |
| 82 | + &__actions { | |
| 83 | + display: flex; | |
| 84 | + align-items: flex-start; | |
| 85 | + gap: fox.gap(5); | |
| 86 | + inline-size: 100%; | |
| 87 | + } | |
| 88 | + | |
| 89 | + // ── 크기 ────────────────────────────────────────────────────────────────── | |
| 90 | + @each $size in (xlg, lg, md, sm) { | |
| 91 | + &--#{$size} { | |
| 92 | + border-radius: fox.card(radius-#{$size}); | |
| 93 | + } | |
| 94 | + | |
| 95 | + &--#{$size} > .fox-card__contents { | |
| 96 | + padding: fox.card(padding-#{$size}); | |
| 97 | + } | |
| 98 | + | |
| 99 | + &--#{$size} > .fox-card__actions { | |
| 100 | + padding-inline: fox.card(padding-#{$size}); | |
| 101 | + padding-block-end: fox.card(padding-#{$size}); | |
| 102 | + } | |
| 103 | + } | |
| 104 | +} |
--- @fox/styles/_fox-chip-area.scss
+++ @fox/styles/_fox-chip-area.scss
... | ... | @@ -15,8 +15,8 @@ |
| 15 | 15 |
// `align-items: center`가 필요한 이유는 칩의 크기(높이)가 섞일 수 있어서다. `FoxChipArea`의 |
| 16 | 16 |
// `size`로 통일하면 섞이지 않지만, 통일하지 않고 쓰는 것도 막지 않는다. |
| 17 | 17 |
// |
| 18 |
-// ⚠️ 줄바꿈이 없다(`flex-wrap`을 주지 않는다) — 시안이 한 줄만 그린다. 폭이 모자라면 칩이 |
|
| 19 |
-// 눌리는 게 아니라 넘친다. 칩이 여러 줄로 흘러야 하는 자리가 나오면 시안에 먼저 추가한다. |
|
| 18 |
+// 기본은 한 줄이다 — 폭이 모자라면 칩이 눌리는 게 아니라 넘친다. 여러 줄로 흘러야 하는 자리는 |
|
| 19 |
+// `--wrap`을 건다(시안 ADM_ADM_102_p의 "메뉴 선택"이 칩을 두 줄로 감는다). |
|
| 20 | 20 |
|
| 21 | 21 |
@use "@fox/styles/abstracts" as fox; |
| 22 | 22 |
|
... | ... | @@ -24,4 +24,8 @@ |
| 24 | 24 |
display: inline-flex; |
| 25 | 25 |
align-items: center; |
| 26 | 26 |
gap: fox.gap(2); |
| 27 |
+ |
|
| 28 |
+ &--wrap {
|
|
| 29 |
+ flex-wrap: wrap; |
|
| 30 |
+ } |
|
| 27 | 31 |
} |
+++ @fox/styles/_fox-description-list.scss
... | ... | @@ -0,0 +1,52 @@ |
| 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 | + // flex 부모가 `align-items: flex-start`여도 가로로 꽉 차게 한다 — 이 목록은 줄마다 구분선을 | |
| 25 | + // 긋기 때문에 폭이 내용에 맞춰 줄면 선이 중간에서 끊긴 것처럼 보인다. | |
| 26 | + align-self: stretch; | |
| 27 | + | |
| 28 | + &__item { | |
| 29 | + display: flex; | |
| 30 | + inline-size: 100%; | |
| 31 | + flex-direction: column; | |
| 32 | + align-items: flex-start; | |
| 33 | + gap: fox.gap(3); | |
| 34 | + padding-block: fox.padding(6); | |
| 35 | + border-block-end: fox.border(1) solid fox.color(border-neutral-subtler); | |
| 36 | + } | |
| 37 | + | |
| 38 | + &__term { | |
| 39 | + margin: 0; | |
| 40 | + } | |
| 41 | + | |
| 42 | + &__description { | |
| 43 | + inline-size: 100%; | |
| 44 | + margin: 0; | |
| 45 | + color: fox.color(font-neutral-default); | |
| 46 | + font-family: fox.font-family(body); | |
| 47 | + font-size: fox.font-size(body-md); | |
| 48 | + font-weight: fox.font-weight(regular); | |
| 49 | + line-height: 1.5; | |
| 50 | + letter-spacing: -0.025em; | |
| 51 | + } | |
| 52 | +} |
+++ @fox/styles/_fox-disclosure.scss
... | ... | @@ -0,0 +1,66 @@ |
| 1 | +// FoxDisclosure — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) | |
| 2 | +// disclosure (391:4082 닫힘 · 391:4083 열림 · 섹션 391:4102) | |
| 3 | +// | |
| 4 | +// 마크업 계약 (React 밖 소비자용): | |
| 5 | +// <div class="fox-disclosure fox-disclosure--open"> | |
| 6 | +// <button class="fox-disclosure__tit" type="button" aria-expanded="true" aria-controls="…"> | |
| 7 | +// <span class="fox-disclosure__icon">…svg…</span> | |
| 8 | +// <span class="fox-disclosure__title">제목</span> | |
| 9 | +// </button> | |
| 10 | +// <div class="fox-disclosure__contents" id="…">…</div> <!-- 열렸을 때만 --> | |
| 11 | +// </div> | |
| 12 | +// | |
| 13 | +// 캐럿이 제목 앞에 오고 바탕·테두리가 없다. 시안에 크기 축도 계열 축도 없어 변형이 없다. | |
| 14 | +// | |
| 15 | +// 토큰이 아닌 값: | |
| 16 | +// - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로 | |
| 17 | +// export하지 않는다. | |
| 18 | +// - 버튼 초기화(appearance/cursor/색 상속): 구조 규칙. | |
| 19 | + | |
| 20 | +@use "abstracts" as fox; | |
| 21 | + | |
| 22 | +.fox-disclosure { | |
| 23 | + display: flex; | |
| 24 | + flex-direction: column; | |
| 25 | + align-items: flex-start; | |
| 26 | + gap: fox.gap(3); | |
| 27 | + inline-size: 100%; | |
| 28 | + | |
| 29 | + // 제목 줄은 통째로 누르는 자리다. 글자 폭만 차지한다 — 시안이 hug다. | |
| 30 | + &__tit { | |
| 31 | + display: flex; | |
| 32 | + align-items: center; | |
| 33 | + gap: fox.gap(2); | |
| 34 | + margin: 0; | |
| 35 | + padding: 0; | |
| 36 | + border: none; | |
| 37 | + background: none; | |
| 38 | + text-align: start; | |
| 39 | + cursor: pointer; | |
| 40 | + } | |
| 41 | + | |
| 42 | + &__icon { | |
| 43 | + display: inline-flex; | |
| 44 | + flex-shrink: 0; | |
| 45 | + inline-size: fox.icon(4); | |
| 46 | + block-size: fox.icon(4); | |
| 47 | + color: fox.color(element-neutral-strong); | |
| 48 | + } | |
| 49 | + | |
| 50 | + &__title { | |
| 51 | + color: fox.color(font-neutral-strong); | |
| 52 | + font-family: fox.font-family(body); | |
| 53 | + font-size: fox.font-size(body-md); | |
| 54 | + font-weight: fox.font-weight(regular); | |
| 55 | + line-height: 1.5; | |
| 56 | + letter-spacing: -0.025em; | |
| 57 | + word-break: break-word; | |
| 58 | + } | |
| 59 | + | |
| 60 | + &__contents { | |
| 61 | + display: flex; | |
| 62 | + flex-direction: column; | |
| 63 | + align-items: flex-start; | |
| 64 | + inline-size: 100%; | |
| 65 | + } | |
| 66 | +} |
--- @fox/styles/_fox-file-upload.scss
+++ @fox/styles/_fox-file-upload.scss
... | ... | @@ -127,10 +127,12 @@ |
| 127 | 127 |
block-size: fox.icon(6); |
| 128 | 128 |
} |
| 129 | 129 |
|
| 130 |
+ // 잘라내지 않고 상자 안에 다 보이게 담는다 — 고른 그림이 어떻게 생겼는지 확인하는 |
|
| 131 |
+ // 자리라 일부만 보이면 확인이 되지 않는다. |
|
| 130 | 132 |
&__thumb-image {
|
| 131 | 133 |
inline-size: 100%; |
| 132 | 134 |
block-size: 100%; |
| 133 |
- object-fit: cover; |
|
| 135 |
+ object-fit: contain; |
|
| 134 | 136 |
} |
| 135 | 137 |
|
| 136 | 138 |
&__thumb-remove {
|
--- @fox/styles/_fox-input.scss
+++ @fox/styles/_fox-input.scss
... | ... | @@ -7,6 +7,7 @@ |
| 7 | 7 |
// <div class="fox-input__box"> |
| 8 | 8 |
// <input class="fox-input__field" id="…" placeholder="…"> |
| 9 | 9 |
// <button class="fox-input__clear" type="button">…svg…</button> <!-- 선택 --> |
| 10 |
+// <span class="fox-input__suffix">/ 총 39개</span> <!-- 선택 --> |
|
| 10 | 11 |
// <span class="fox-input__icon">…svg…</span> <!-- 선택 --> |
| 11 | 12 |
// </div> |
| 12 | 13 |
// <p class="fox-input__message">…</p> <!-- 선택 --> |
... | ... | @@ -69,6 +70,13 @@ |
| 69 | 70 |
cursor: pointer; |
| 70 | 71 |
} |
| 71 | 72 |
|
| 73 |
+ // 아이콘 슬롯과 달리 정사각형으로 못박지 않는다 — 글자만큼 차지하고 줄어들지 않는다. |
|
| 74 |
+ &__suffix {
|
|
| 75 |
+ flex-shrink: 0; |
|
| 76 |
+ color: fox.color(font-neutral-default); |
|
| 77 |
+ white-space: nowrap; |
|
| 78 |
+ } |
|
| 79 |
+ |
|
| 72 | 80 |
&__message {
|
| 73 | 81 |
@include field.message; |
| 74 | 82 |
} |
... | ... | @@ -84,7 +92,8 @@ |
| 84 | 92 |
border-radius: fox.form(radius-lg); |
| 85 | 93 |
} |
| 86 | 94 |
|
| 87 |
- &--lg &__field {
|
|
| 95 |
+ &--lg &__field, |
|
| 96 |
+ &--lg &__suffix {
|
|
| 88 | 97 |
font-size: fox.font-size(label-lg); |
| 89 | 98 |
} |
| 90 | 99 |
|
... | ... | @@ -100,7 +109,8 @@ |
| 100 | 109 |
border-radius: fox.form(radius-md); |
| 101 | 110 |
} |
| 102 | 111 |
|
| 103 |
- &--md &__field {
|
|
| 112 |
+ &--md &__field, |
|
| 113 |
+ &--md &__suffix {
|
|
| 104 | 114 |
font-size: fox.font-size(label-md); |
| 105 | 115 |
} |
| 106 | 116 |
|
... | ... | @@ -116,7 +126,8 @@ |
| 116 | 126 |
border-radius: fox.form(radius-sm); |
| 117 | 127 |
} |
| 118 | 128 |
|
| 119 |
- &--sm &__field {
|
|
| 129 |
+ &--sm &__field, |
|
| 130 |
+ &--sm &__suffix {
|
|
| 120 | 131 |
font-size: fox.font-size(label-sm); |
| 121 | 132 |
} |
| 122 | 133 |
|
+++ @fox/styles/_fox-list-container.scss
... | ... | @@ -0,0 +1,41 @@ |
| 1 | +// FoxListContainer — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) container (3019:8608) | |
| 2 | +// | |
| 3 | +// 마크업 계약 (React 밖 소비자용): | |
| 4 | +// <div class="fox-list-container"> | |
| 5 | +// …fox-page-header… | |
| 6 | +// <section class="fox-list-container__section"> | |
| 7 | +// …fox-list-toolbar… …fox-table… …fox-pagination… | |
| 8 | +// </section> | |
| 9 | +// </div> | |
| 10 | +// | |
| 11 | +// 세로로만 쌓는다 — 도구 줄과 페이지네이션이 각자 자기 여백을 갖고 있어 여기서 간격을 더하지 | |
| 12 | +// 않는다(더하면 시안보다 벌어진다). | |
| 13 | + | |
| 14 | +@use "abstracts" as fox; | |
| 15 | + | |
| 16 | +.fox-list-container { | |
| 17 | + display: flex; | |
| 18 | + inline-size: 100%; | |
| 19 | + flex-direction: column; | |
| 20 | + | |
| 21 | + &__section { | |
| 22 | + display: flex; | |
| 23 | + inline-size: 100%; | |
| 24 | + flex-direction: column; | |
| 25 | + } | |
| 26 | + | |
| 27 | + // 열이 많은 표는 화면보다 넓다. 눌러 담으면 시안 폭이 무너지므로 여기서 가로로 스크롤한다. | |
| 28 | + &__table { | |
| 29 | + inline-size: 100%; | |
| 30 | + overflow-x: auto; | |
| 31 | + } | |
| 32 | +} | |
| 33 | + | |
| 34 | +// 표가 지정된 열 폭 아래로 눌리지 않게 한다 — 넓을 때만 넘치고, 좁으면 100%로 앉는다. | |
| 35 | +// 좁은 화면에서는 목록표가 카드로 바뀌어(FoxTable의 mobile 규칙) 스크롤이 필요 없으므로 | |
| 36 | +// PC에서만 건다. | |
| 37 | +@include fox.pc { | |
| 38 | + .fox-list-container__table > .fox-table { | |
| 39 | + min-inline-size: max-content; | |
| 40 | + } | |
| 41 | +} |
+++ @fox/styles/_fox-list-search.scss
... | ... | @@ -0,0 +1,92 @@ |
| 1 | +// FoxListSearch — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) list-toolbar의 search (5111:4099) | |
| 2 | +// | |
| 3 | +// 마크업 계약 (React 밖 소비자용): | |
| 4 | +// <form class="fox-list-search" role="search"> | |
| 5 | +// <div class="fox-list-search__box"> | |
| 6 | +// <span class="fox-list-search__field">…select-txt…</span> <!-- 선택 --> | |
| 7 | +// <input class="fox-list-search__input"> | |
| 8 | +// <button class="fox-list-search__submit">…svg…</button> | |
| 9 | +// </div> | |
| 10 | +// </form> | |
| 11 | +// | |
| 12 | +// 상자 규격은 FoxInput md와 같지만(높이 40 · radius 8 · 테두리 input-border) 좌우 여백만 | |
| 13 | +// `form/padding/sm`으로 좁다 — 안에 셀렉트와 버튼이 들어가기 때문이다. | |
| 14 | +// | |
| 15 | +// 토큰이 아닌 값: | |
| 16 | +// - 폭 320, 제출 버튼 32: 시안 값이나 Figma가 변수로 export하지 않는다. | |
| 17 | + | |
| 18 | +@use "abstracts" as fox; | |
| 19 | +@use "form-field" as field; | |
| 20 | + | |
| 21 | +.fox-list-search { | |
| 22 | + display: flex; | |
| 23 | + flex-shrink: 0; | |
| 24 | + align-items: center; | |
| 25 | + gap: fox.gap(3); | |
| 26 | + // 시안 값이나 변수로 export되지 않는다. | |
| 27 | + inline-size: 32rem; | |
| 28 | + | |
| 29 | + &__box { | |
| 30 | + display: flex; | |
| 31 | + inline-size: 100%; | |
| 32 | + align-items: center; | |
| 33 | + gap: fox.gap(2); | |
| 34 | + block-size: fox.form(height-md); | |
| 35 | + padding-inline: fox.form(padding-sm); | |
| 36 | + border: fox.border(1) solid fox.color(input-border); | |
| 37 | + border-radius: fox.form(radius-md); | |
| 38 | + background: fox.color(input-surface); | |
| 39 | + } | |
| 40 | + | |
| 41 | + &__field { | |
| 42 | + display: inline-flex; | |
| 43 | + flex-shrink: 0; | |
| 44 | + align-items: center; | |
| 45 | + } | |
| 46 | + | |
| 47 | + &__input { | |
| 48 | + @include field.field(label-md); | |
| 49 | + | |
| 50 | + // 네이티브 `search`가 붙이는 기본 장식을 지운다 — 지우기 X는 제출 버튼과 겹친다. | |
| 51 | + &::-webkit-search-cancel-button, | |
| 52 | + &::-webkit-search-decoration { | |
| 53 | + appearance: none; | |
| 54 | + } | |
| 55 | + } | |
| 56 | + | |
| 57 | + &__submit { | |
| 58 | + display: inline-flex; | |
| 59 | + flex-shrink: 0; | |
| 60 | + align-items: center; | |
| 61 | + justify-content: center; | |
| 62 | + // 시안 값이나 변수로 export되지 않는다. | |
| 63 | + inline-size: 3.2rem; | |
| 64 | + block-size: 3.2rem; | |
| 65 | + padding: 0; | |
| 66 | + border: none; | |
| 67 | + // 시안은 secondary 계열 버튼이다 — 옆의 "엑셀 다운로드"와 같은 색을 쓴다. | |
| 68 | + border-radius: fox.form(radius-md); | |
| 69 | + background: fox.color(button-secondary-surface); | |
| 70 | + color: fox.color(button-secondary-font); | |
| 71 | + cursor: pointer; | |
| 72 | + | |
| 73 | + &:hover:not(:disabled) { | |
| 74 | + background: fox.color(button-secondary-surface-hover); | |
| 75 | + } | |
| 76 | + | |
| 77 | + &:active:not(:disabled) { | |
| 78 | + background: fox.color(button-secondary-surface-pressed); | |
| 79 | + } | |
| 80 | + | |
| 81 | + &:disabled { | |
| 82 | + background: fox.color(button-disabled-surface); | |
| 83 | + color: fox.color(font-neutral-disabled-strong); | |
| 84 | + cursor: default; | |
| 85 | + } | |
| 86 | + } | |
| 87 | + | |
| 88 | + &__submit > svg { | |
| 89 | + inline-size: fox.form(icon-md); | |
| 90 | + block-size: fox.form(icon-md); | |
| 91 | + } | |
| 92 | +} |
+++ @fox/styles/_fox-list-toolbar.scss
... | ... | @@ -0,0 +1,82 @@ |
| 1 | +// FoxListToolbar — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) list-toolbar (3002:8151) | |
| 2 | +// | |
| 3 | +// 마크업 계약 (React 밖 소비자용): | |
| 4 | +// <div class="fox-list-toolbar"> | |
| 5 | +// <div class="fox-list-toolbar__base"> | |
| 6 | +// <div class="fox-list-toolbar__left">…</div> | |
| 7 | +// <div class="fox-list-toolbar__right">…</div> | |
| 8 | +// </div> | |
| 9 | +// <div class="fox-list-toolbar__filter">…</div> <!-- 선택 --> | |
| 10 | +// </div> | |
| 11 | +// | |
| 12 | +// 좌우가 각각 `flex: 1`이라 반씩 나눠 갖고, 우측 내용은 오른쪽 끝으로 몰린다. | |
| 13 | +// | |
| 14 | +// 토큰이 아닌 값: | |
| 15 | +// - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로 export하지 않는다. | |
| 16 | + | |
| 17 | +@use "abstracts" as fox; | |
| 18 | + | |
| 19 | +.fox-list-toolbar { | |
| 20 | + display: flex; | |
| 21 | + inline-size: 100%; | |
| 22 | + flex-direction: column; | |
| 23 | + align-items: flex-start; | |
| 24 | + padding-block-end: fox.spacing(bottom-md); | |
| 25 | + | |
| 26 | + &__base { | |
| 27 | + display: flex; | |
| 28 | + inline-size: 100%; | |
| 29 | + flex-shrink: 0; | |
| 30 | + align-items: center; | |
| 31 | + } | |
| 32 | + | |
| 33 | + &__left, | |
| 34 | + &__right { | |
| 35 | + display: flex; | |
| 36 | + flex: 1 0 0; | |
| 37 | + min-inline-size: 0; | |
| 38 | + align-items: center; | |
| 39 | + gap: fox.gap(4); | |
| 40 | + } | |
| 41 | + | |
| 42 | + &__right { | |
| 43 | + justify-content: flex-end; | |
| 44 | + } | |
| 45 | + | |
| 46 | + &__sorts { | |
| 47 | + display: flex; | |
| 48 | + flex-shrink: 0; | |
| 49 | + align-items: center; | |
| 50 | + gap: fox.gap(3); | |
| 51 | + } | |
| 52 | + | |
| 53 | + // 아래 줄 — 위 줄과 가로 구분선으로 나뉜다. | |
| 54 | + &__filter { | |
| 55 | + display: flex; | |
| 56 | + inline-size: 100%; | |
| 57 | + flex-shrink: 0; | |
| 58 | + align-items: center; | |
| 59 | + gap: fox.gap(3); | |
| 60 | + margin-block-start: fox.padding(5); | |
| 61 | + padding-block-start: fox.padding(5); | |
| 62 | + border-block-start: fox.border(1) solid fox.color(border-neutral-subtler); | |
| 63 | + } | |
| 64 | +} | |
| 65 | + | |
| 66 | +// 총 건수 — 숫자만 굵고 색이 다르다. | |
| 67 | +.fox-list-count { | |
| 68 | + flex-shrink: 0; | |
| 69 | + margin: 0; | |
| 70 | + color: fox.color(font-neutral-default); | |
| 71 | + font-family: fox.font-family(body); | |
| 72 | + font-size: fox.font-size(label-md); | |
| 73 | + font-weight: fox.font-weight(regular); | |
| 74 | + line-height: 1; | |
| 75 | + letter-spacing: -0.025em; | |
| 76 | + white-space: nowrap; | |
| 77 | + | |
| 78 | + &__value { | |
| 79 | + color: fox.color(font-theme-primary); | |
| 80 | + font-weight: fox.font-weight(bold); | |
| 81 | + } | |
| 82 | +} |
+++ @fox/styles/_fox-modal.scss
... | ... | @@ -0,0 +1,210 @@ |
| 1 | +// FoxModal — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) | |
| 2 | +// modal (190:1136 · 섹션 391:4640) | |
| 3 | +// | |
| 4 | +// 마크업 계약 (React 밖 소비자용): | |
| 5 | +// <dialog class="fox-modal fox-modal--md"> | |
| 6 | +// <div class="fox-modal__body"> | |
| 7 | +// <div class="fox-modal__tit"><h2 class="fox-modal__title">타이틀</h2></div> <!-- 선택 --> | |
| 8 | +// <div class="fox-modal__contents">…</div> | |
| 9 | +// </div> | |
| 10 | +// <div class="fox-modal__foot"> <!-- 선택 --> | |
| 11 | +// <div class="fox-modal__utility">…</div> <!-- 선택 --> | |
| 12 | +// <div class="fox-modal__actions">…</div> <!-- 선택 --> | |
| 13 | +// </div> | |
| 14 | +// <button class="fox-modal__close" type="button" aria-label="닫기">…svg…</button> | |
| 15 | +// </dialog> | |
| 16 | +// `<dialog>`는 `showModal()`로 열어야 뒷배경과 초점 가두기가 생긴다. | |
| 17 | +// | |
| 18 | +// 크기 넷(sm·md·lg·full)이 모두 폭 토큰이다. 모바일에서는 넷 다 같은 폭(348px)으로 | |
| 19 | +// 모이고 여백·모서리·닫기 버튼도 함께 줄어든다 — 반응형 토큰이 그렇게 정의돼 있다. | |
| 20 | +// | |
| 21 | +// 제목 줄에만 오른쪽 여백을 준다 — 닫기 버튼이 그 자리에 겹쳐 앉기 때문이다. 본문과 | |
| 22 | +// 아래쪽은 그 여백이 필요 없다. | |
| 23 | +// | |
| 24 | +// 닫기 버튼은 64px 칸의 아래쪽에 아이콘을 붙인다(시안 items-end). 그래야 아이콘이 제목 | |
| 25 | +// 글줄과 눈높이가 맞는다. 상자 테두리 위에 얹히도록 1px 밖으로 내민다. | |
| 26 | +// | |
| 27 | +// 토큰이 아닌 값: | |
| 28 | +// - 아래쪽 버튼의 최소 폭 8rem: 시안 값이나 Figma가 변수로 export하지 않는다(사용자 승인). | |
| 29 | +// - 그림자 색: 시안이 테마 토큰이 아니라 `alpha/black10` 원시값에 직접 물려 놓았다 | |
| 30 | +// (fox-carousel-button과 같은 사정). | |
| 31 | +// - 뒷배경을 덮는 방식(`::backdrop`)과 가운데 정렬: 구조 규칙. | |
| 32 | +// - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로 | |
| 33 | +// export하지 않는다. | |
| 34 | + | |
| 35 | +@use "abstracts" as fox; | |
| 36 | + | |
| 37 | +.fox-modal { | |
| 38 | + box-sizing: border-box; | |
| 39 | + // `<dialog>`는 닫혀 있을 때 브라우저가 숨기는데, `display`를 주면 그 기본값을 덮어 | |
| 40 | + // 닫힌 모달이 화면에 그대로 남는다. 열렸을 때만 배치를 준다. | |
| 41 | + display: none; | |
| 42 | + flex-direction: column; | |
| 43 | + align-items: flex-start; | |
| 44 | + // `<dialog>`의 브라우저 기본 여백·테두리를 지우고 가운데에 놓는다. | |
| 45 | + max-inline-size: calc(100% - #{fox.padding(7)} * 2); | |
| 46 | + max-block-size: calc(100% - #{fox.padding(7)} * 2); | |
| 47 | + margin: auto; | |
| 48 | + padding: fox.modal(padding-default); | |
| 49 | + // 세로만 스크롤한다. `overflow: auto`로 두면 닫기 버튼이 테두리를 덮으려고 일부러 1px | |
| 50 | + // 밖으로 나간 것(`__close`의 `inset-inline-end: -1px`)까지 넘침으로 세어 가로 스크롤바가 | |
| 51 | + // 생긴다 — 그 버튼은 배경도 테두리도 없고 아이콘이 8px 안쪽이라 x축을 잘라도 보이는 변화가 | |
| 52 | + // 없다. | |
| 53 | + overflow-x: hidden; | |
| 54 | + overflow-y: auto; | |
| 55 | + border: fox.border(1) solid fox.color(border-neutral-subtle); | |
| 56 | + border-radius: fox.card(radius-xlg); | |
| 57 | + background: fox.color(surface-neutral-default); | |
| 58 | + color: fox.color(font-neutral-default); | |
| 59 | + // 시안 neutral/lv3 — 그림자 둘이 겹친다. | |
| 60 | + box-shadow: | |
| 61 | + fox.shadow(location4) fox.shadow(location4) fox.shadow(blur4) 0 | |
| 62 | + fox.primitive(alpha-black10), | |
| 63 | + fox.shadow(location2) fox.shadow(location2) fox.shadow(blur3) 0 | |
| 64 | + fox.primitive(alpha-black10); | |
| 65 | + | |
| 66 | + &::backdrop { | |
| 67 | + background: fox.color(background-dim); | |
| 68 | + } | |
| 69 | + | |
| 70 | + // 닫기 버튼이 상자 모서리에 얹히므로 기준점이 필요하다. | |
| 71 | + position: relative; | |
| 72 | + | |
| 73 | + &[open] { | |
| 74 | + display: flex; | |
| 75 | + } | |
| 76 | + | |
| 77 | + // ── 크기 ────────────────────────────────────────────────────────────────── | |
| 78 | + &--sm { | |
| 79 | + inline-size: fox.modal(width-sm); | |
| 80 | + } | |
| 81 | + | |
| 82 | + &--md { | |
| 83 | + inline-size: fox.modal(width-md); | |
| 84 | + } | |
| 85 | + | |
| 86 | + &--lg { | |
| 87 | + inline-size: fox.modal(width-lg); | |
| 88 | + } | |
| 89 | + | |
| 90 | + &--full { | |
| 91 | + inline-size: fox.modal(width-full); | |
| 92 | + } | |
| 93 | + | |
| 94 | + // ── 본문 ────────────────────────────────────────────────────────────────── | |
| 95 | + &__body { | |
| 96 | + display: flex; | |
| 97 | + flex-direction: column; | |
| 98 | + align-items: flex-start; | |
| 99 | + gap: fox.gap(5); | |
| 100 | + inline-size: 100%; | |
| 101 | + } | |
| 102 | + | |
| 103 | + // 닫기 버튼이 오른쪽 위에 앉으므로 제목만 그만큼 비켜 준다. | |
| 104 | + // | |
| 105 | + // 제목이 없어도 한 줄 높이를 남긴다 — 시안의 tit 프레임이 제목 없이도 28px(제목 글자 | |
| 106 | + // 크기 × 줄높이)을 차지한다. 이 자리를 없애면 닫기 버튼이 내용 위로 내려앉는다. | |
| 107 | + &__tit { | |
| 108 | + display: flex; | |
| 109 | + flex-direction: column; | |
| 110 | + align-items: flex-start; | |
| 111 | + gap: fox.gap(3); | |
| 112 | + inline-size: 100%; | |
| 113 | + min-block-size: calc(#{fox.font-size(heading-md)} * 1.4); | |
| 114 | + padding-inline-end: fox.padding(8); | |
| 115 | + } | |
| 116 | + | |
| 117 | + &__title { | |
| 118 | + inline-size: 100%; | |
| 119 | + margin: 0; | |
| 120 | + color: fox.color(font-neutral-strong); | |
| 121 | + font-family: fox.font-family(title); | |
| 122 | + font-size: fox.font-size(heading-md); | |
| 123 | + font-weight: fox.font-weight(bold); | |
| 124 | + line-height: 1.4; | |
| 125 | + letter-spacing: -0.025em; | |
| 126 | + word-break: break-word; | |
| 127 | + } | |
| 128 | + | |
| 129 | + &__contents { | |
| 130 | + display: flex; | |
| 131 | + flex-direction: column; | |
| 132 | + align-items: flex-start; | |
| 133 | + gap: fox.gap(5); | |
| 134 | + inline-size: 100%; | |
| 135 | + font-family: fox.font-family(body); | |
| 136 | + font-size: fox.font-size(body-md); | |
| 137 | + font-weight: fox.font-weight(regular); | |
| 138 | + line-height: 1.5; | |
| 139 | + letter-spacing: -0.025em; | |
| 140 | + | |
| 141 | + // 그림이 들어오면 폭을 채운다 — 시안의 contents 슬롯에 든 것도 폭 100%다. | |
| 142 | + > img, | |
| 143 | + > svg, | |
| 144 | + > picture { | |
| 145 | + inline-size: 100%; | |
| 146 | + block-size: auto; | |
| 147 | + } | |
| 148 | + } | |
| 149 | + | |
| 150 | + // ── 아래쪽 ──────────────────────────────────────────────────────────────── | |
| 151 | + &__foot { | |
| 152 | + display: flex; | |
| 153 | + align-items: center; | |
| 154 | + justify-content: flex-end; | |
| 155 | + gap: fox.gap(3); | |
| 156 | + inline-size: 100%; | |
| 157 | + padding-block-start: fox.padding(8); | |
| 158 | + } | |
| 159 | + | |
| 160 | + // 왼편은 남는 폭을 먹어 버튼을 오른쪽 끝으로 민다. | |
| 161 | + &__utility { | |
| 162 | + display: flex; | |
| 163 | + flex: 1 0 0; | |
| 164 | + align-items: flex-start; | |
| 165 | + gap: fox.gap(3); | |
| 166 | + min-inline-size: 0; | |
| 167 | + } | |
| 168 | + | |
| 169 | + &__actions { | |
| 170 | + display: flex; | |
| 171 | + flex-shrink: 0; | |
| 172 | + align-items: center; | |
| 173 | + justify-content: flex-end; | |
| 174 | + gap: fox.gap(3); | |
| 175 | + margin-inline-start: auto; | |
| 176 | + } | |
| 177 | + | |
| 178 | + // 시안에서 모달 버튼은 최소 8rem이다 — 글자가 짧아도 두 버튼의 폭이 같아 보이게 한다. | |
| 179 | + // 버튼 자체의 성질이 아니라 이 자리의 규칙이라 여기서 준다(btn 컴포넌트 세트에는 | |
| 180 | + // 최소 폭 표기가 없다). | |
| 181 | + &__actions .fox-button { | |
| 182 | + min-inline-size: 8rem; | |
| 183 | + } | |
| 184 | + | |
| 185 | + // ── 닫기 ────────────────────────────────────────────────────────────────── | |
| 186 | + // 시안대로 아이콘을 칸 아래쪽에 붙여 제목 글줄과 눈높이를 맞춘다. | |
| 187 | + &__close { | |
| 188 | + position: absolute; | |
| 189 | + inset-block-start: calc(-1 * #{fox.border(1)}); | |
| 190 | + inset-inline-end: calc(-1 * #{fox.border(1)}); | |
| 191 | + display: flex; | |
| 192 | + align-items: flex-end; | |
| 193 | + inline-size: fox.modal(btn-close); | |
| 194 | + block-size: fox.modal(btn-close); | |
| 195 | + padding: fox.padding(3); | |
| 196 | + border: none; | |
| 197 | + background: none; | |
| 198 | + color: fox.color(icon-neutral-default); | |
| 199 | + cursor: pointer; | |
| 200 | + } | |
| 201 | + | |
| 202 | + &__close-icon { | |
| 203 | + display: inline-flex; | |
| 204 | + | |
| 205 | + > * { | |
| 206 | + inline-size: fox.icon(5); | |
| 207 | + block-size: fox.icon(5); | |
| 208 | + } | |
| 209 | + } | |
| 210 | +} |
+++ @fox/styles/_fox-page-header.scss
... | ... | @@ -0,0 +1,68 @@ |
| 1 | +// FoxPageHeader — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) page-tit-area (3002:6823) | |
| 2 | +// | |
| 3 | +// 마크업 계약 (React 밖 소비자용): | |
| 4 | +// <div class="fox-page-header"> | |
| 5 | +// <div class="fox-page-header__main"> | |
| 6 | +// …fox-breadcrumb… <!-- 선택 --> | |
| 7 | +// <h1 class="fox-page-header__title">제목</h1> | |
| 8 | +// </div> | |
| 9 | +// <div class="fox-page-header__actions">…</div> <!-- 선택 --> | |
| 10 | +// </div> | |
| 11 | +// | |
| 12 | +// 제목이 남는 폭을 먹고 오른쪽에 24px 간격이 남는다. 시안에는 그 자리에 들어간 것이 없지만 | |
| 13 | +// 배치가 자리를 비워 두고 있어 `__actions`로 열어 둔다. | |
| 14 | +// | |
| 15 | +// 본문과의 간격(아래 여백)을 머리말이 갖는다 — 시안이 그렇게 묶어 놓았다. 화면마다 따로 | |
| 16 | +// 띄우지 않아도 된다. | |
| 17 | +// | |
| 18 | +// 토큰이 아닌 값: | |
| 19 | +// - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로 | |
| 20 | +// export하지 않는다. | |
| 21 | +// - `<h1>`의 브라우저 기본 여백 0: UA 기본값 지우기. | |
| 22 | + | |
| 23 | +@use "abstracts" as fox; | |
| 24 | + | |
| 25 | +.fox-page-header { | |
| 26 | + display: flex; | |
| 27 | + align-items: flex-start; | |
| 28 | + gap: fox.gap(7); | |
| 29 | + inline-size: 100%; | |
| 30 | + padding-block-end: fox.spacing(bottom-xlg); | |
| 31 | + | |
| 32 | + // 현재 위치와 제목이 세로로 쌓이고 남는 폭을 먹는다. | |
| 33 | + &__main { | |
| 34 | + display: flex; | |
| 35 | + flex: 1 0 0; | |
| 36 | + flex-direction: column; | |
| 37 | + align-items: flex-start; | |
| 38 | + gap: fox.gap(3); | |
| 39 | + min-inline-size: 0; | |
| 40 | + } | |
| 41 | + | |
| 42 | + &__title { | |
| 43 | + inline-size: 100%; | |
| 44 | + margin: 0; | |
| 45 | + color: fox.color(font-neutral-strong); | |
| 46 | + font-family: fox.font-family(title); | |
| 47 | + font-size: fox.font-size(display-md); | |
| 48 | + font-weight: fox.font-weight(bold); | |
| 49 | + line-height: 1.5; | |
| 50 | + letter-spacing: -0.025em; | |
| 51 | + word-break: break-word; | |
| 52 | + } | |
| 53 | + | |
| 54 | + // 제목 옆에 서고 줄어들지 않는다. 오른쪽 끝에 붙는 것을 선언으로 못박는다 — 제목 칸이 | |
| 55 | + // 남는 폭을 먹어 밀려나는 것에 기대면, 제목 칸이 없거나 좁아질 때 자리가 흔들린다. | |
| 56 | + &__actions { | |
| 57 | + display: flex; | |
| 58 | + flex-shrink: 0; | |
| 59 | + align-items: center; | |
| 60 | + justify-content: flex-end; | |
| 61 | + margin-inline-start: auto; | |
| 62 | + } | |
| 63 | + | |
| 64 | + // 여러 개가 들어와 줄이 넘어가도 오른쪽에 맞춘다. | |
| 65 | + &__actions > * { | |
| 66 | + justify-content: flex-end; | |
| 67 | + } | |
| 68 | +} |
--- @fox/styles/_fox-tab-item.scss
+++ @fox/styles/_fox-tab-item.scss
... | ... | @@ -10,8 +10,9 @@ |
| 10 | 10 |
// - line 고름: 흰 바탕 + 4px 밑줄 + 파란 글자 |
| 11 | 11 |
// - fill 고름: 남색 바탕 + 흰 글자 |
| 12 | 12 |
// |
| 13 |
-// line 계열의 기본 상태도 밑줄 자리를 1px 비워 둔다 — 고름으로 바뀔 때 글자가 밀리지 |
|
| 14 |
-// 않게 하려면 자리를 미리 잡아야 한다(시안도 투명 1px을 둔다). |
|
| 13 |
+// line 계열의 밑줄은 `border`가 아니라 안쪽 그림자로 그린다. Figma는 stroke를 상자 **안쪽**에 |
|
| 14 |
+// 그려 4px 밑줄이 높이를 늘리지 않지만(시안의 tab-item은 고름 여부와 무관하게 60px), CSS |
|
| 15 |
+// `border`는 상자를 키운다 — border로 그리면 고른 탭만 4px 커지고 옆 탭과 3px 어긋난다. |
|
| 15 | 16 |
// |
| 16 | 17 |
// 토큰이 아닌 값: |
| 17 | 18 |
// - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로 |
... | ... | @@ -69,13 +70,9 @@ |
| 69 | 70 |
} |
| 70 | 71 |
|
| 71 | 72 |
// ── line 계열 ─────────────────────────────────────────────────────────────── |
| 72 |
-// 고름으로 바뀔 때 글자가 밀리지 않게 밑줄 자리를 미리 잡는다. |
|
| 73 |
-.fox-tab--line .fox-tab-item {
|
|
| 74 |
- border-block-end: fox.border(1) solid fox.color(action-secondary); |
|
| 75 |
-} |
|
| 76 |
- |
|
| 73 |
+// 밑줄은 상자 안쪽에 그린다(위 주석 참조) — 높이가 60px로 고정돼 고름 여부에 글자가 밀리지 않는다. |
|
| 77 | 74 |
.fox-tab--line .fox-tab-item--active {
|
| 78 |
- border-block-end: fox.border(3) solid fox.color(action-secondary-active); |
|
| 75 |
+ box-shadow: inset 0 calc(-1 * #{fox.border(3)}) 0 0 fox.color(action-secondary-active);
|
|
| 79 | 76 |
background: fox.color(surface-neutral-default); |
| 80 | 77 |
color: fox.color(font-theme-secondary); |
| 81 | 78 |
} |
--- @fox/styles/_fox-table-row.scss
+++ @fox/styles/_fox-table-row.scss
... | ... | @@ -18,4 +18,18 @@ |
| 18 | 18 |
background: fox.color(action-primary-hover); |
| 19 | 19 |
color: fox.color(font-theme-primary); |
| 20 | 20 |
} |
| 21 |
+ |
|
| 22 |
+ // 행 전체가 눌리는 경우 — 누를 수 있다는 것이 보이고, 키보드 초점도 드러나야 한다. |
|
| 23 |
+ &--clickable {
|
|
| 24 |
+ cursor: pointer; |
|
| 25 |
+ |
|
| 26 |
+ &:hover {
|
|
| 27 |
+ background: fox.color(action-default-hover); |
|
| 28 |
+ } |
|
| 29 |
+ |
|
| 30 |
+ &:focus-visible {
|
|
| 31 |
+ outline: fox.border(2) solid fox.color(border-theme-primary); |
|
| 32 |
+ outline-offset: calc(-1 * #{fox.border(2)});
|
|
| 33 |
+ } |
|
| 34 |
+ } |
|
| 21 | 35 |
} |
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
... | ... | @@ -62,4 +62,13 @@ |
| 62 | 62 |
@use "fox-toast"; |
| 63 | 63 |
@use "fox-tooltip"; |
| 64 | 64 |
@use "fox-tooltip-rich"; |
| 65 |
-@use "fox-toggle-quantity";(파일 끝에 줄바꿈 문자 없음) |
|
| 65 |
+@use "fox-toggle-quantity"; |
|
| 66 |
+@use "fox-page-header"; |
|
| 67 |
+@use "fox-modal"; |
|
| 68 |
+@use "fox-accordion"; |
|
| 69 |
+@use "fox-card"; |
|
| 70 |
+@use "fox-list-search"; |
|
| 71 |
+@use "fox-list-toolbar"; |
|
| 72 |
+@use "fox-list-container"; |
|
| 73 |
+@use "fox-disclosure"; |
|
| 74 |
+@use "fox-description-list"; |
--- app/(protected)/(basic)/admins/_actions.ts
+++ app/(protected)/(basic)/admins/_actions.ts
... | ... | @@ -11,7 +11,7 @@ |
| 11 | 11 |
} from '@/lib/data/repositories/admin-member-repository'; |
| 12 | 12 |
import { ADMIN_MENU_OPTIONS } from '@/lib/domain/admin-member';
|
| 13 | 13 |
import {
|
| 14 |
- joinPhoneNumber, |
|
| 14 |
+ formatPhoneNumber, |
|
| 15 | 15 |
validateAdminLoginId, |
| 16 | 16 |
validateAdminMemberCreate, |
| 17 | 17 |
validateAdminMemberUpdate, |
... | ... | @@ -41,6 +41,8 @@ |
| 41 | 41 |
const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.'; |
| 42 | 42 |
const SELF_DELETE_MESSAGE = '현재 로그인한 본인 계정은 삭제할 수 없습니다.'; |
| 43 | 43 |
const DUPLICATE_LOGIN_ID_MESSAGE = '이미 사용 중인 ID입니다.'; |
| 44 |
+const LOGIN_ID_CHECK_FAILED_MESSAGE = |
|
| 45 |
+ '중복 확인에 실패했습니다. 잠시 후 다시 시도해 주세요.'; |
|
| 44 | 46 |
|
| 45 | 47 |
function readString(formData: FormData, key: string): string {
|
| 46 | 48 |
const value = formData.get(key); |
... | ... | @@ -58,15 +60,11 @@ |
| 58 | 60 |
} |
| 59 | 61 |
|
| 60 | 62 |
/** |
| 61 |
- * 등록·수정이 공유하는 입력 항목을 읽는다. 휴대전화번호는 시안대로 3칸으로 나뉘어 오므로 여기서 |
|
| 62 |
- * 하나의 값으로 합친다 — 형식이 어긋나면 빈 문자열이 되어 검증에서 걸린다. |
|
| 63 |
+ * 등록·수정이 공유하는 입력 항목을 읽는다. 휴대전화번호는 숫자만 담겨 오므로 여기서 저장 형식으로 |
|
| 64 |
+ * 바꾼다 — 자릿수가 어긋나면 빈 문자열이 되어 검증에서 걸린다. |
|
| 63 | 65 |
*/ |
| 64 | 66 |
function readEditableValues(formData: FormData): AdminMemberEditableValues {
|
| 65 |
- const phoneNumber = joinPhoneNumber([ |
|
| 66 |
- readString(formData, 'phoneNumber1'), |
|
| 67 |
- readString(formData, 'phoneNumber2'), |
|
| 68 |
- readString(formData, 'phoneNumber3'), |
|
| 69 |
- ]); |
|
| 67 |
+ const phoneNumber = formatPhoneNumber(readString(formData, 'phoneNumber')); |
|
| 70 | 68 |
|
| 71 | 69 |
return {
|
| 72 | 70 |
password: readString(formData, 'password'), |
... | ... | @@ -205,7 +203,10 @@ |
| 205 | 203 |
export type LoginIdCheckResult = |
| 206 | 204 |
| { status: 'idle' }
|
| 207 | 205 |
| { status: 'available'; loginId: string }
|
| 208 |
- | { status: 'unavailable'; message: string };
|
|
| 206 |
+ /** 쓸 수 없는 ID — 형식 오류이거나 이미 선점됐다. */ |
|
| 207 |
+ | { status: 'unavailable'; message: string }
|
|
| 208 |
+ /** 확인 자체를 못 했다(통신·권한 등). 값의 판정이 아니라 **확인 실패**다. */ |
|
| 209 |
+ | { status: 'failed'; message: string };
|
|
| 209 | 210 |
|
| 210 | 211 |
/** |
| 211 | 212 |
* 시안 ADM_ADM_102_p ① — ID 중복 확인. |
... | ... | @@ -230,8 +231,17 @@ |
| 230 | 231 |
return { status: 'unavailable', message: formatError };
|
| 231 | 232 |
} |
| 232 | 233 |
|
| 233 |
- if (await isAdminLoginIdTaken(normalized)) {
|
|
| 234 |
- return { status: 'unavailable', message: DUPLICATE_LOGIN_ID_MESSAGE };
|
|
| 234 |
+ // 확인 호출이 실패하면 그 사유를 화면에 그대로 돌려준다 — 예외로 두면 클라이언트의 |
|
| 235 |
+ // transition에서 처리되지 않은 rejection이 되어 버튼만 원복되고 아무 말도 남지 않는다. |
|
| 236 |
+ try {
|
|
| 237 |
+ if (await isAdminLoginIdTaken(normalized)) {
|
|
| 238 |
+ return { status: 'unavailable', message: DUPLICATE_LOGIN_ID_MESSAGE };
|
|
| 239 |
+ } |
|
| 240 |
+ } catch (error) {
|
|
| 241 |
+ if (error instanceof BackendRequestError) {
|
|
| 242 |
+ return { status: 'failed', message: error.message };
|
|
| 243 |
+ } |
|
| 244 |
+ return { status: 'failed', message: LOGIN_ID_CHECK_FAILED_MESSAGE };
|
|
| 235 | 245 |
} |
| 236 | 246 |
|
| 237 | 247 |
return { status: 'available', loginId: normalized };
|
--- app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx
... | ... | @@ -1,14 +1,26 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 |
-import { useActionState, useEffect, useState, useTransition } 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';
|
|
| 3 |
+import {
|
|
| 4 |
+ useActionState, |
|
| 5 |
+ useEffect, |
|
| 6 |
+ useRef, |
|
| 7 |
+ useState, |
|
| 8 |
+ useTransition, |
|
| 9 |
+} from 'react'; |
|
| 10 |
+import { FoxButton } from '@fox/core/components/fox-button';
|
|
| 11 |
+import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
|
|
| 12 |
+import { FoxFormLabel } from '@fox/core/components/fox-form-label';
|
|
| 13 |
+import { FoxInput } from '@fox/core/components/fox-input';
|
|
| 14 |
+import { FoxSelect } from '@fox/core/components/fox-select';
|
|
| 15 |
+import { FoxModal } from '@fox/core/components/fox-modal';
|
|
| 16 |
+import { foxPasswordValidator } from '@fox/core/validation';
|
|
| 17 |
+import { FoxChatCenteredDotsIcon } from '@fox/core/icons';
|
|
| 8 | 18 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 19 |
+import { DEFAULT_ADMIN_ROLE_CODE } from '@/lib/domain/admin-member';
|
|
| 9 | 20 |
import {
|
| 10 | 21 |
ADMIN_LOGIN_ID_HELP_TEXT, |
| 11 | 22 |
ADMIN_PASSWORD_HELP_TEXT, |
| 23 |
+ ADMIN_PASSWORD_POLICY, |
|
| 12 | 24 |
INITIAL_ADMIN_MEMBER_FORM_STATE, |
| 13 | 25 |
} from '@/lib/domain/admin-member-form'; |
| 14 | 26 |
import {
|
... | ... | @@ -16,19 +28,27 @@ |
| 16 | 28 |
createAdminMemberAction, |
| 17 | 29 |
type LoginIdCheckResult, |
| 18 | 30 |
} from '../_actions'; |
| 19 |
-import {
|
|
| 20 |
- AdminMemberFormFields, |
|
| 21 |
- FieldError, |
|
| 22 |
-} from './admin-member-form-fields'; |
|
| 31 |
+import { AdminMemberFormFields } from './admin-member-form-fields';
|
|
| 32 |
+import styles from './admin-member-modal.module.scss'; |
|
| 23 | 33 |
|
| 24 | 34 |
interface AdminMemberCreateModalProps {
|
| 25 | 35 |
onClose: () => void; |
| 26 | 36 |
} |
| 27 | 37 |
|
| 28 |
-const FORM_ID = 'admin-member-create-form'; |
|
| 38 |
+/** 목록에 없는 주소를 위해 도메인 칸을 입력으로 바꾸는 값. 실제 도메인과 겹치지 않게 잡았다. */ |
|
| 39 |
+const CUSTOM_EMAIL_DOMAIN = '__custom__'; |
|
| 40 |
+ |
|
| 41 |
+/** 시안의 도메인 목록. 맨 아래 "직접입력"은 같은 칸을 입력으로 바꾼다(상자를 늘리지 않는다). */ |
|
| 42 |
+const EMAIL_DOMAIN_OPTIONS = [ |
|
| 43 |
+ 'naver.com', |
|
| 44 |
+ 'gmail.com', |
|
| 45 |
+ 'daum.net', |
|
| 46 |
+ 'hanmail.net', |
|
| 47 |
+ 'nate.com', |
|
| 48 |
+]; |
|
| 29 | 49 |
|
| 30 | 50 |
/** |
| 31 |
- * 관리자 등록 팝업(시안 ADM_ADM_102_p). |
|
| 51 |
+ * 관리자 등록 팝업 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) ADM_ADM_102_p (5227:2035) |
|
| 32 | 52 |
* |
| 33 | 53 |
* **ID는 중복 확인을 통과해야 저장할 수 있다**(시안 ①). 확인은 등록 폼과 별개의 서버 호출인데, |
| 34 | 54 |
* 폼 안에 폼을 중첩할 수 없어 `useActionState` 대신 값을 직접 넘기는 Server Action |
... | ... | @@ -36,12 +56,14 @@ |
| 36 | 56 |
* "확인에 성공한 ID"와 "지금 입력창의 값"이 같을 때만 통과로 본다 — 확인만 받아 두고 다른 ID로 |
| 37 | 57 |
* 바꿔 저장하는 경로를 막기 위해서다. 그래도 최종 방어선은 Server Action의 재확인이다. |
| 38 | 58 |
* |
| 39 |
- * 저장 버튼은 footer 슬롯에서 `form={FORM_ID}` 속성으로 폼과 연결한다 — 네이티브 HTML의 `form`
|
|
| 40 |
- * 속성은 버튼이 실제 DOM상 form의 자손이 아니어도 같은 문서 안에서 id만 일치하면 그 form을 |
|
| 41 |
- * 제출한다. |
|
| 59 |
+ * 저장 버튼은 `FoxModal`의 actions 슬롯에 있어 폼 **바깥**에 그려진다. `FoxButton`에는 네이티브 |
|
| 60 |
+ * `form` 속성이 없으므로 폼을 ref로 잡아 `requestSubmit()`을 부른다 — `useActionState`의 폼 |
|
| 61 |
+ * 액션이 그대로 타는 정식 제출이라 검증·상태 흐름이 동일하다. |
|
| 42 | 62 |
* |
| 43 |
- * 비밀번호는 형식만 검증하고 저장되지 않는다 — 백엔드에 등록 API가 없어 지금은 mock 저장소가 |
|
| 44 |
- * 받는데, 평문 비밀번호를 메모리에 들고 있지 않기로 했기 때문이다(Repository·mock 주석 참조). |
|
| 63 |
+ * @fox의 제어 위젯(전화번호·이메일·역할·메뉴)은 값이 FormData에 실리지 않아 이 컴포넌트가 값을 |
|
| 64 |
+ * 들고 hidden input으로 제출한다(`AdminMemberFormFields` 주석 참조). |
|
| 65 |
+ * |
|
| 66 |
+ * 비밀번호는 형식만 검증하고 백엔드가 SHA-256으로 암호화해 저장한다(`MngrAdminServiceImpl`). |
|
| 45 | 67 |
*/ |
| 46 | 68 |
export function AdminMemberCreateModal({
|
| 47 | 69 |
onClose, |
... | ... | @@ -51,12 +73,20 @@ |
| 51 | 73 |
createAdminMemberAction, |
| 52 | 74 |
INITIAL_ADMIN_MEMBER_FORM_STATE |
| 53 | 75 |
); |
| 76 |
+ const formRef = useRef<HTMLFormElement>(null); |
|
| 54 | 77 |
|
| 55 | 78 |
const [loginId, setLoginId] = useState('');
|
| 56 | 79 |
const [checkResult, setCheckResult] = useState<LoginIdCheckResult>({
|
| 57 | 80 |
status: 'idle', |
| 58 | 81 |
}); |
| 59 | 82 |
const [isChecking, startChecking] = useTransition(); |
| 83 |
+ |
|
| 84 |
+ const [phoneNumber, setPhoneNumber] = useState('');
|
|
| 85 |
+ const [emailLocal, setEmailLocal] = useState('');
|
|
| 86 |
+ const [emailDomain, setEmailDomain] = useState(EMAIL_DOMAIN_OPTIONS[0]); |
|
| 87 |
+ const [isCustomDomain, setIsCustomDomain] = useState(false); |
|
| 88 |
+ const [roleCode, setRoleCode] = useState<string>(DEFAULT_ADMIN_ROLE_CODE); |
|
| 89 |
+ const [menuCodes, setMenuCodes] = useState<string[]>([]); |
|
| 60 | 90 |
|
| 61 | 91 |
useEffect(() => {
|
| 62 | 92 |
if (state.status === 'success') {
|
... | ... | @@ -66,99 +96,180 @@ |
| 66 | 96 |
}, [state, showToast, onClose]); |
| 67 | 97 |
|
| 68 | 98 |
const isLoginIdVerified = |
| 69 |
- checkResult.status === 'available' && |
|
| 70 |
- checkResult.loginId === loginId.trim(); |
|
| 71 |
- |
|
| 72 |
- function handleCheckLoginId() {
|
|
| 73 |
- startChecking(async () => {
|
|
| 74 |
- setCheckResult(await checkAdminLoginId(loginId)); |
|
| 75 |
- }); |
|
| 76 |
- } |
|
| 99 |
+ checkResult.status === 'available' && checkResult.loginId === loginId.trim(); |
|
| 77 | 100 |
|
| 78 | 101 |
const errors = state.status === 'error' ? (state.errors ?? {}) : {};
|
| 79 | 102 |
|
| 103 |
+ const email = emailLocal || emailDomain ? `${emailLocal}@${emailDomain}` : '';
|
|
| 104 |
+ |
|
| 105 |
+ // 중복 확인 결과는 서버 검증 오류보다 먼저 보여준다 — 방금 누른 버튼의 답이기 때문이다. |
|
| 106 |
+ // 값이 나쁜 것(unavailable)과 확인을 못 한 것(failed)은 사유가 다르므로 둘 다 문구로 낸다. |
|
| 107 |
+ const loginIdFailure = |
|
| 108 |
+ checkResult.status === 'unavailable' || checkResult.status === 'failed' |
|
| 109 |
+ ? checkResult.message |
|
| 110 |
+ : undefined; |
|
| 111 |
+ const loginIdMessage = |
|
| 112 |
+ loginIdFailure ?? |
|
| 113 |
+ (isLoginIdVerified |
|
| 114 |
+ ? '사용할 수 있는 ID입니다.' |
|
| 115 |
+ : (errors.loginId ?? ADMIN_LOGIN_ID_HELP_TEXT)); |
|
| 116 |
+ |
|
| 80 | 117 |
return ( |
| 81 |
- <Modal |
|
| 82 |
- title="관리자 등록" |
|
| 118 |
+ <FoxModal |
|
| 119 |
+ open |
|
| 83 | 120 |
onClose={onClose}
|
| 84 |
- footer={
|
|
| 85 |
- <> |
|
| 86 |
- <Button type="button" variant="ghost" onClick={onClose}>
|
|
| 87 |
- 취소 |
|
| 88 |
- </Button> |
|
| 89 |
- <Button |
|
| 90 |
- type="submit" |
|
| 91 |
- form={FORM_ID}
|
|
| 92 |
- variant="primary" |
|
| 93 |
- disabled={isPending || !isLoginIdVerified}
|
|
| 94 |
- title={isLoginIdVerified ? undefined : 'ID 중복 확인이 필요합니다.'}
|
|
| 95 |
- > |
|
| 96 |
- {isPending ? '저장 중...' : '저장'}
|
|
| 97 |
- </Button> |
|
| 98 |
- </> |
|
| 121 |
+ size="sm" |
|
| 122 |
+ title="관리자 등록" |
|
| 123 |
+ actions={
|
|
| 124 |
+ <FoxButtonGroup size="md"> |
|
| 125 |
+ <FoxButton type="default" size="md" label="취소" onAction={onClose} />
|
|
| 126 |
+ <FoxButton |
|
| 127 |
+ type="primary" |
|
| 128 |
+ size="md" |
|
| 129 |
+ label="저장" |
|
| 130 |
+ loading={isPending}
|
|
| 131 |
+ disabled={!isLoginIdVerified}
|
|
| 132 |
+ onAction={() => formRef.current?.requestSubmit()}
|
|
| 133 |
+ /> |
|
| 134 |
+ </FoxButtonGroup> |
|
| 99 | 135 |
} |
| 100 | 136 |
> |
| 101 |
- <form id={FORM_ID} action={formAction} className="flex flex-col gap-4">
|
|
| 102 |
- <p className="text-right text-body-sm text-danger"> |
|
| 103 |
- * 는 필수 항목입니다. |
|
| 104 |
- </p> |
|
| 137 |
+ <form ref={formRef} action={formAction} className={styles.formList}>
|
|
| 138 |
+ <FoxInput |
|
| 139 |
+ size="md" |
|
| 140 |
+ name="name" |
|
| 141 |
+ label="이름" |
|
| 142 |
+ requirement="required" |
|
| 143 |
+ placeholder="이름을 입력하세요." |
|
| 144 |
+ message={errors.name}
|
|
| 145 |
+ invalid={Boolean(errors.name)}
|
|
| 146 |
+ /> |
|
| 105 | 147 |
|
| 106 |
- <Field label="이름 *"> |
|
| 107 |
- <Input type="text" name="name" placeholder="이름을 입력하세요." /> |
|
| 108 |
- </Field> |
|
| 109 |
- <FieldError message={errors.name} />
|
|
| 110 |
- |
|
| 111 |
- <Field label="ID * (중복확인 필수)"> |
|
| 112 |
- <div className="flex items-center gap-2"> |
|
| 113 |
- <Input |
|
| 114 |
- type="text" |
|
| 115 |
- name="loginId" |
|
| 116 |
- value={loginId}
|
|
| 117 |
- onChange={(event) => setLoginId(event.target.value)}
|
|
| 118 |
- placeholder="관리자 ID를 입력하세요." |
|
| 119 |
- autoComplete="off" |
|
| 120 |
- /> |
|
| 121 |
- <Button |
|
| 122 |
- type="button" |
|
| 123 |
- variant="secondary" |
|
| 124 |
- onClick={handleCheckLoginId}
|
|
| 125 |
- disabled={isChecking || loginId.trim().length === 0}
|
|
| 126 |
- className="shrink-0" |
|
| 127 |
- > |
|
| 128 |
- {isChecking ? '확인 중...' : '중복 확인'}
|
|
| 129 |
- </Button> |
|
| 130 |
- </div> |
|
| 131 |
- </Field> |
|
| 132 |
- <p className="text-body-sm text-foreground-muted"> |
|
| 133 |
- {ADMIN_LOGIN_ID_HELP_TEXT}
|
|
| 134 |
- </p> |
|
| 135 |
- {checkResult.status === 'unavailable' && (
|
|
| 136 |
- <FieldError message={checkResult.message} />
|
|
| 137 |
- )} |
|
| 138 |
- {isLoginIdVerified && (
|
|
| 139 |
- <p className="text-body-sm text-success">사용할 수 있는 ID입니다.</p> |
|
| 140 |
- )} |
|
| 141 |
- <FieldError message={errors.loginId} />
|
|
| 142 |
- |
|
| 143 |
- <Field label="비밀번호 *"> |
|
| 144 |
- <Input |
|
| 145 |
- type="password" |
|
| 146 |
- name="password" |
|
| 147 |
- placeholder="비밀번호를 입력하세요." |
|
| 148 |
- autoComplete="new-password" |
|
| 148 |
+ <div className={styles.fieldWithAction}>
|
|
| 149 |
+ <FoxInput |
|
| 150 |
+ size="md" |
|
| 151 |
+ name="loginId" |
|
| 152 |
+ label="ID" |
|
| 153 |
+ requirement="required" |
|
| 154 |
+ placeholder="관리자 ID를 입력하세요." |
|
| 155 |
+ autoComplete="off" |
|
| 156 |
+ value={loginId}
|
|
| 157 |
+ onChange={setLoginId}
|
|
| 158 |
+ message={loginIdMessage}
|
|
| 159 |
+ messageIcon={<FoxChatCenteredDotsIcon />}
|
|
| 160 |
+ invalid={Boolean(loginIdFailure) || Boolean(errors.loginId)}
|
|
| 149 | 161 |
/> |
| 150 |
- </Field> |
|
| 151 |
- <p className="text-body-sm text-foreground-muted"> |
|
| 152 |
- {ADMIN_PASSWORD_HELP_TEXT}
|
|
| 153 |
- </p> |
|
| 154 |
- <FieldError message={errors.password} />
|
|
| 162 |
+ <FoxButton |
|
| 163 |
+ type="secondary" |
|
| 164 |
+ size="md" |
|
| 165 |
+ fullWidth |
|
| 166 |
+ label="중복 확인" |
|
| 167 |
+ loading={isChecking}
|
|
| 168 |
+ disabled={loginId.trim().length === 0}
|
|
| 169 |
+ onAction={() =>
|
|
| 170 |
+ startChecking(async () => {
|
|
| 171 |
+ try {
|
|
| 172 |
+ setCheckResult(await checkAdminLoginId(loginId)); |
|
| 173 |
+ } catch {
|
|
| 174 |
+ // Server Action 호출 자체가 깨진 경우(네트워크 등). 조용히 끝나면 버튼만 |
|
| 175 |
+ // 원복되고 아무 말도 남지 않아, 사유를 알 수 없어도 실패는 알린다. |
|
| 176 |
+ setCheckResult({
|
|
| 177 |
+ status: 'failed', |
|
| 178 |
+ message: '중복 확인에 실패했습니다. 잠시 후 다시 시도해 주세요.', |
|
| 179 |
+ }); |
|
| 180 |
+ } |
|
| 181 |
+ }) |
|
| 182 |
+ } |
|
| 183 |
+ /> |
|
| 184 |
+ </div> |
|
| 155 | 185 |
|
| 156 |
- <AdminMemberFormFields errors={errors} />
|
|
| 186 |
+ <FoxInput |
|
| 187 |
+ size="md" |
|
| 188 |
+ type="password" |
|
| 189 |
+ name="password" |
|
| 190 |
+ label="비밀번호" |
|
| 191 |
+ requirement="required" |
|
| 192 |
+ placeholder="비밀번호를 입력하세요." |
|
| 193 |
+ autoComplete="new-password" |
|
| 194 |
+ message={errors.password ?? ADMIN_PASSWORD_HELP_TEXT}
|
|
| 195 |
+ messageIcon={<FoxChatCenteredDotsIcon />}
|
|
| 196 |
+ invalid={Boolean(errors.password)}
|
|
| 197 |
+ // 규칙은 도메인이 한 벌로 갖는다 — 저장 직전 서버 검증이 같은 값을 본다. |
|
| 198 |
+ validators={[foxPasswordValidator({ ...ADMIN_PASSWORD_POLICY, required: true })]}
|
|
| 199 |
+ validationMessages={{ minlength: ADMIN_PASSWORD_HELP_TEXT }}
|
|
| 200 |
+ /> |
|
| 201 |
+ |
|
| 202 |
+ <AdminMemberFormFields |
|
| 203 |
+ phoneNumber={phoneNumber}
|
|
| 204 |
+ onPhoneNumberChange={setPhoneNumber}
|
|
| 205 |
+ roleCode={roleCode}
|
|
| 206 |
+ onRoleCodeChange={setRoleCode}
|
|
| 207 |
+ menuCodes={menuCodes}
|
|
| 208 |
+ onMenuCodesChange={setMenuCodes}
|
|
| 209 |
+ errors={errors}
|
|
| 210 |
+ emailField={
|
|
| 211 |
+ <> |
|
| 212 |
+ <div className={styles.field}>
|
|
| 213 |
+ <FoxFormLabel as="span" requirement="required"> |
|
| 214 |
+ 이메일 |
|
| 215 |
+ </FoxFormLabel> |
|
| 216 |
+ <div className={styles.emailRow}>
|
|
| 217 |
+ <span className={styles.emailPart}>
|
|
| 218 |
+ <FoxInput |
|
| 219 |
+ size="md" |
|
| 220 |
+ aria-label="이메일 아이디" |
|
| 221 |
+ placeholder="1234-5678" |
|
| 222 |
+ value={emailLocal}
|
|
| 223 |
+ onChange={setEmailLocal}
|
|
| 224 |
+ invalid={Boolean(errors.email)}
|
|
| 225 |
+ /> |
|
| 226 |
+ </span> |
|
| 227 |
+ <span aria-hidden="true">@</span> |
|
| 228 |
+ <span className={styles.emailPart}>
|
|
| 229 |
+ {isCustomDomain ? (
|
|
| 230 |
+ <FoxInput |
|
| 231 |
+ size="md" |
|
| 232 |
+ aria-label="이메일 도메인" |
|
| 233 |
+ placeholder="직접입력" |
|
| 234 |
+ value={emailDomain}
|
|
| 235 |
+ onChange={setEmailDomain}
|
|
| 236 |
+ invalid={Boolean(errors.email)}
|
|
| 237 |
+ /> |
|
| 238 |
+ ) : ( |
|
| 239 |
+ <FoxSelect |
|
| 240 |
+ size="md" |
|
| 241 |
+ options={[
|
|
| 242 |
+ ...EMAIL_DOMAIN_OPTIONS.map((domain) => ({
|
|
| 243 |
+ value: domain, |
|
| 244 |
+ label: domain, |
|
| 245 |
+ })), |
|
| 246 |
+ { value: CUSTOM_EMAIL_DOMAIN, label: '직접입력' },
|
|
| 247 |
+ ]} |
|
| 248 |
+ value={emailDomain}
|
|
| 249 |
+ onValueChange={(next) => {
|
|
| 250 |
+ if (next === CUSTOM_EMAIL_DOMAIN) {
|
|
| 251 |
+ setIsCustomDomain(true); |
|
| 252 |
+ setEmailDomain('');
|
|
| 253 |
+ return; |
|
| 254 |
+ } |
|
| 255 |
+ setEmailDomain(next); |
|
| 256 |
+ }} |
|
| 257 |
+ error={Boolean(errors.email)}
|
|
| 258 |
+ /> |
|
| 259 |
+ )} |
|
| 260 |
+ </span> |
|
| 261 |
+ </div> |
|
| 262 |
+ {errors.email && <p role="alert">{errors.email}</p>}
|
|
| 263 |
+ </div> |
|
| 264 |
+ <input type="hidden" name="email" value={email} />
|
|
| 265 |
+ </> |
|
| 266 |
+ } |
|
| 267 |
+ /> |
|
| 157 | 268 |
|
| 158 | 269 |
{state.status === 'error' && state.message && (
|
| 159 |
- <p className="text-body-sm text-danger">{state.message}</p>
|
|
| 270 |
+ <p role="alert">{state.message}</p>
|
|
| 160 | 271 |
)} |
| 161 | 272 |
</form> |
| 162 |
- </Modal> |
|
| 273 |
+ </FoxModal> |
|
| 163 | 274 |
); |
| 164 | 275 |
} |
--- app/(protected)/(basic)/admins/_components/admin-member-edit-modal.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-edit-modal.tsx
... | ... | @@ -1,49 +1,51 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 |
-import { useActionState, useEffect, useState } 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 { Select } from '@/components/ui/select';
|
|
| 3 |
+import { useActionState, useEffect, useRef, useState } from 'react';
|
|
| 4 |
+import { FoxButton } from '@fox/core/components/fox-button';
|
|
| 5 |
+import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
|
|
| 6 |
+import { FoxFormLabel } from '@fox/core/components/fox-form-label';
|
|
| 7 |
+import { FoxInput } from '@fox/core/components/fox-input';
|
|
| 8 |
+import { FoxModal } from '@fox/core/components/fox-modal';
|
|
| 9 |
+import { FoxToggleSwitch } from '@fox/core/components/fox-toggle-switch';
|
|
| 10 |
+import { foxPasswordValidator } from '@fox/core/validation';
|
|
| 11 |
+import { FoxChatCenteredDotsIcon } from '@fox/core/icons';
|
|
| 9 | 12 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 10 |
-import {
|
|
| 11 |
- ADMIN_LOCK_STATUS_OPTIONS, |
|
| 12 |
- type AdminMember, |
|
| 13 |
-} from '@/lib/domain/admin-member'; |
|
| 13 |
+import { DEFAULT_ADMIN_ROLE_CODE, type AdminMember } from '@/lib/domain/admin-member';
|
|
| 14 | 14 |
import {
|
| 15 | 15 |
ADMIN_PASSWORD_HELP_TEXT, |
| 16 |
+ ADMIN_PASSWORD_POLICY, |
|
| 16 | 17 |
INITIAL_ADMIN_MEMBER_FORM_STATE, |
| 18 |
+ toPhoneDigits, |
|
| 17 | 19 |
} from '@/lib/domain/admin-member-form'; |
| 18 | 20 |
import { updateAdminMemberAction } from '../_actions';
|
| 19 |
-import {
|
|
| 20 |
- AdminMemberFormFields, |
|
| 21 |
- FieldError, |
|
| 22 |
-} from './admin-member-form-fields'; |
|
| 21 |
+import { AdminMemberFormFields } from './admin-member-form-fields';
|
|
| 22 |
+import styles from './admin-member-modal.module.scss'; |
|
| 23 | 23 |
|
| 24 | 24 |
interface AdminMemberEditModalProps {
|
| 25 | 25 |
member: AdminMember; |
| 26 | 26 |
onClose: () => void; |
| 27 | 27 |
} |
| 28 | 28 |
|
| 29 |
-const FORM_ID = 'admin-member-edit-form'; |
|
| 30 |
- |
|
| 31 | 29 |
/** |
| 32 |
- * 관리자 수정 팝업(시안 ADM_ADM_103_p). |
|
| 30 |
+ * 관리자 수정 팝업 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) ADM_ADM_103_p (5227:3351) |
|
| 33 | 31 |
* |
| 34 |
- * 이름·ID는 읽기 전용이다(시안 ①). 다만 화면의 readOnly는 표시일 뿐이라 Server Action은 두 값을 |
|
| 35 |
- * 아예 반영하지 않는다 — 직접 POST로 다른 값을 보내도 바뀌지 않는다. |
|
| 32 |
+ * 이름·ID는 읽기 전용이다(시안 ①). 화면의 readOnly는 표시일 뿐이지만 Server Action도 두 값을 아예 |
|
| 33 |
+ * 읽지 않고, 백엔드 수정 API 역시 받지 않는다(`MngrAdminUpdateRequestVo`에 필드가 없다) — 세 겹이 |
|
| 34 |
+ * 같은 말을 한다. |
|
| 36 | 35 |
* |
| 37 |
- * **비밀번호는 [비밀번호 변경]을 눌러야 입력할 수 있다**(시안 ②). 다만 비워 둔 채로는 수정이 |
|
| 38 |
- * 저장되지 않는다 — 백엔드 UPDATE가 비밀번호를 조건 없이 덮어써서 빈 값을 보내면 그 계정이 로그인 |
|
| 39 |
- * 불가가 되기 때문이다(`ADMIN_PASSWORD_REQUIRED_ON_UPDATE_TEXT` 주석). 비활성 input은 FormData에 |
|
| 40 |
- * 실리지 않으므로 버튼을 누르지 않고 제출하면 그 안내가 그대로 뜬다. |
|
| 36 |
+ * **비밀번호는 [비밀번호 변경]을 눌러야 입력할 수 있고, 비워 두면 바꾸지 않는다**(시안 ②). |
|
| 37 |
+ * 백엔드 UPDATE의 `LOGIN_PW`가 `<if test='loginPw != null and loginPw != ""'>`로 감싸여 있어 |
|
| 38 |
+ * 빈 값은 SET 절에서 빠진다. (예전에는 조건 없이 덮어써서 빈 값을 보내면 그 계정이 로그인 불가가 |
|
| 39 |
+ * 됐고, 그래서 수정 시 비밀번호를 필수로 막아 두었다 — 그 제약은 이제 없다.) |
|
| 41 | 40 |
* |
| 42 |
- * 휴대전화번호·이메일은 항상 빈 칸으로 시작한다 — 백엔드가 두 값을 되돌려주지 않아 기존 값을 |
|
| 43 |
- * 채울 수단이 없다(Repository 주석). 입력한 값으로 덮어쓰는 동작이다. |
|
| 41 |
+ * 이메일은 시안이 **단일 입력**이다(등록 팝업의 도메인 셀렉트가 없다). 두 시안의 차이를 그대로 |
|
| 42 |
+ * 따른다 — 수정은 이미 있는 주소를 고치는 자리라 도메인만 고르는 형태가 맞지 않는다. |
|
| 44 | 43 |
* |
| 45 |
- * "잠김여부"는 활성/비활성으로 표기되지만 백엔드 필드는 `acctLockYn`(잠김 여부)이라 의미가 |
|
| 46 |
- * 뒤집혀 있다. 그 변환은 도메인(`ADMIN_LOCK_STATUS_OPTIONS`)이 한 곳에서 책임진다. |
|
| 44 |
+ * 휴대전화번호·이메일·역할은 이제 목록 응답에 담겨 오므로 **기존 값으로 채운다**(백엔드가 |
|
| 45 |
+ * `ADM_TEL_NO`·`ADM_EML_ADDR`을 select 목록에 넣었다). |
|
| 46 |
+ * |
|
| 47 |
+ * "잠김여부"는 시안이 활성/비활성 토글이고 백엔드 필드는 `acctLockYn`(잠김 여부)이라 의미가 |
|
| 48 |
+ * 뒤집혀 있다 — 켜짐이 곧 "잠기지 않음"이다. |
|
| 47 | 49 |
*/ |
| 48 | 50 |
export function AdminMemberEditModal({
|
| 49 | 51 |
member, |
... | ... | @@ -54,8 +56,18 @@ |
| 54 | 56 |
updateAdminMemberAction, |
| 55 | 57 |
INITIAL_ADMIN_MEMBER_FORM_STATE |
| 56 | 58 |
); |
| 59 |
+ const formRef = useRef<HTMLFormElement>(null); |
|
| 57 | 60 |
|
| 58 | 61 |
const [isPasswordEditable, setIsPasswordEditable] = useState(false); |
| 62 |
+ const [phoneNumber, setPhoneNumber] = useState( |
|
| 63 |
+ toPhoneDigits(member.phoneNumber) |
|
| 64 |
+ ); |
|
| 65 |
+ const [email, setEmail] = useState(member.email ?? ''); |
|
| 66 |
+ const [roleCode, setRoleCode] = useState( |
|
| 67 |
+ member.roleCode || DEFAULT_ADMIN_ROLE_CODE |
|
| 68 |
+ ); |
|
| 69 |
+ const [menuCodes, setMenuCodes] = useState<string[]>(member.menuCodes); |
|
| 70 |
+ const [isActive, setIsActive] = useState(!(member.isLocked ?? false)); |
|
| 59 | 71 |
|
| 60 | 72 |
useEffect(() => {
|
| 61 | 73 |
if (state.status === 'success') {
|
... | ... | @@ -67,88 +79,115 @@ |
| 67 | 79 |
const errors = state.status === 'error' ? (state.errors ?? {}) : {};
|
| 68 | 80 |
|
| 69 | 81 |
return ( |
| 70 |
- <Modal |
|
| 71 |
- title="관리자 수정" |
|
| 82 |
+ <FoxModal |
|
| 83 |
+ open |
|
| 72 | 84 |
onClose={onClose}
|
| 73 |
- footer={
|
|
| 74 |
- <> |
|
| 75 |
- <Button type="button" variant="ghost" onClick={onClose}>
|
|
| 76 |
- 취소 |
|
| 77 |
- </Button> |
|
| 78 |
- <Button |
|
| 79 |
- type="submit" |
|
| 80 |
- form={FORM_ID}
|
|
| 81 |
- variant="primary" |
|
| 82 |
- disabled={isPending}
|
|
| 83 |
- > |
|
| 84 |
- {isPending ? '수정 중...' : '수정'}
|
|
| 85 |
- </Button> |
|
| 86 |
- </> |
|
| 85 |
+ size="sm" |
|
| 86 |
+ title="관리자 수정" |
|
| 87 |
+ actions={
|
|
| 88 |
+ <FoxButtonGroup size="md"> |
|
| 89 |
+ <FoxButton type="default" size="md" label="취소" onAction={onClose} />
|
|
| 90 |
+ <FoxButton |
|
| 91 |
+ type="primary" |
|
| 92 |
+ size="md" |
|
| 93 |
+ label="저장" |
|
| 94 |
+ loading={isPending}
|
|
| 95 |
+ onAction={() => formRef.current?.requestSubmit()}
|
|
| 96 |
+ /> |
|
| 97 |
+ </FoxButtonGroup> |
|
| 87 | 98 |
} |
| 88 | 99 |
> |
| 89 |
- <form id={FORM_ID} action={formAction} className="flex flex-col gap-4">
|
|
| 90 |
- {/* 수정 대상을 가리키는 유일한 입력. 이름·ID는 읽기 전용이라 아예 제출하지 않는다 —
|
|
| 91 |
- Server Action도 그 둘을 읽지 않으므로 위조해 보내도 반영되지 않는다. */} |
|
| 100 |
+ <form ref={formRef} action={formAction} className={styles.formList}>
|
|
| 101 |
+ {/* 수정 대상을 가리키는 유일한 입력. 이름·ID는 읽기 전용이라 아예 제출하지 않는다. */}
|
|
| 92 | 102 |
<input type="hidden" name="id" value={member.id} />
|
| 93 | 103 |
|
| 94 |
- <p className="text-right text-body-sm text-danger"> |
|
| 95 |
- * 는 필수 항목입니다. |
|
| 96 |
- </p> |
|
| 104 |
+ <FoxInput |
|
| 105 |
+ size="md" |
|
| 106 |
+ label="이름" |
|
| 107 |
+ requirement="required" |
|
| 108 |
+ value={member.name}
|
|
| 109 |
+ readOnly |
|
| 110 |
+ /> |
|
| 97 | 111 |
|
| 98 |
- <Field label="이름"> |
|
| 99 |
- <Input type="text" value={member.name} readOnly />
|
|
| 100 |
- </Field> |
|
| 112 |
+ <FoxInput |
|
| 113 |
+ size="md" |
|
| 114 |
+ label="ID" |
|
| 115 |
+ requirement="required" |
|
| 116 |
+ value={member.loginId}
|
|
| 117 |
+ readOnly |
|
| 118 |
+ /> |
|
| 101 | 119 |
|
| 102 |
- <Field label="ID"> |
|
| 103 |
- <Input type="text" value={member.loginId} readOnly />
|
|
| 104 |
- </Field> |
|
| 120 |
+ <div className={styles.fieldWithAction}>
|
|
| 121 |
+ <FoxInput |
|
| 122 |
+ size="md" |
|
| 123 |
+ type="password" |
|
| 124 |
+ name="password" |
|
| 125 |
+ label="비밀번호" |
|
| 126 |
+ requirement="required" |
|
| 127 |
+ placeholder="비밀번호를 입력하세요." |
|
| 128 |
+ autoComplete="new-password" |
|
| 129 |
+ disabled={!isPasswordEditable}
|
|
| 130 |
+ message={
|
|
| 131 |
+ errors.password ?? |
|
| 132 |
+ (isPasswordEditable ? ADMIN_PASSWORD_HELP_TEXT : undefined) |
|
| 133 |
+ } |
|
| 134 |
+ messageIcon={
|
|
| 135 |
+ isPasswordEditable ? <FoxChatCenteredDotsIcon /> : undefined |
|
| 136 |
+ } |
|
| 137 |
+ invalid={Boolean(errors.password)}
|
|
| 138 |
+ // 비우면 "바꾸지 않음"이라 required를 걸지 않는다(등록 팝업과 다른 점). |
|
| 139 |
+ validators={[foxPasswordValidator(ADMIN_PASSWORD_POLICY)]}
|
|
| 140 |
+ validationMessages={{ minlength: ADMIN_PASSWORD_HELP_TEXT }}
|
|
| 141 |
+ /> |
|
| 142 |
+ <FoxButton |
|
| 143 |
+ type="secondary" |
|
| 144 |
+ size="md" |
|
| 145 |
+ fullWidth |
|
| 146 |
+ label="비밀번호 변경" |
|
| 147 |
+ disabled={isPasswordEditable}
|
|
| 148 |
+ onAction={() => setIsPasswordEditable(true)}
|
|
| 149 |
+ /> |
|
| 150 |
+ </div> |
|
| 105 | 151 |
|
| 106 |
- <Field label="비밀번호"> |
|
| 107 |
- <div className="flex items-center gap-2"> |
|
| 108 |
- <Input |
|
| 109 |
- type="password" |
|
| 110 |
- name="password" |
|
| 111 |
- placeholder="비밀번호를 입력하세요." |
|
| 112 |
- autoComplete="new-password" |
|
| 113 |
- disabled={!isPasswordEditable}
|
|
| 152 |
+ <AdminMemberFormFields |
|
| 153 |
+ phoneNumber={phoneNumber}
|
|
| 154 |
+ onPhoneNumberChange={setPhoneNumber}
|
|
| 155 |
+ roleCode={roleCode}
|
|
| 156 |
+ onRoleCodeChange={setRoleCode}
|
|
| 157 |
+ menuCodes={menuCodes}
|
|
| 158 |
+ onMenuCodesChange={setMenuCodes}
|
|
| 159 |
+ errors={errors}
|
|
| 160 |
+ emailField={
|
|
| 161 |
+ <FoxInput |
|
| 162 |
+ size="md" |
|
| 163 |
+ type="email" |
|
| 164 |
+ name="email" |
|
| 165 |
+ label="이메일" |
|
| 166 |
+ placeholder="이메일을 입력하세요." |
|
| 167 |
+ value={email}
|
|
| 168 |
+ onChange={setEmail}
|
|
| 169 |
+ message={errors.email}
|
|
| 170 |
+ invalid={Boolean(errors.email)}
|
|
| 114 | 171 |
/> |
| 115 |
- <Button |
|
| 116 |
- type="button" |
|
| 117 |
- variant="secondary" |
|
| 118 |
- onClick={() => setIsPasswordEditable(true)}
|
|
| 119 |
- disabled={isPasswordEditable}
|
|
| 120 |
- className="shrink-0" |
|
| 121 |
- > |
|
| 122 |
- 비밀번호 변경 |
|
| 123 |
- </Button> |
|
| 124 |
- </div> |
|
| 125 |
- </Field> |
|
| 126 |
- {isPasswordEditable && (
|
|
| 127 |
- <p className="text-body-sm text-foreground-muted"> |
|
| 128 |
- {ADMIN_PASSWORD_HELP_TEXT}
|
|
| 129 |
- </p> |
|
| 130 |
- )} |
|
| 131 |
- <FieldError message={errors.password} />
|
|
| 172 |
+ } |
|
| 173 |
+ /> |
|
| 132 | 174 |
|
| 133 |
- <AdminMemberFormFields member={member} errors={errors} />
|
|
| 134 |
- |
|
| 135 |
- <Field label="잠김여부"> |
|
| 136 |
- <Select |
|
| 137 |
- name="isLocked" |
|
| 138 |
- defaultValue={String(member.isLocked ?? false)}
|
|
| 139 |
- > |
|
| 140 |
- {ADMIN_LOCK_STATUS_OPTIONS.map((option) => (
|
|
| 141 |
- <option key={option.value} value={option.value}>
|
|
| 142 |
- {option.label}
|
|
| 143 |
- </option> |
|
| 144 |
- ))} |
|
| 145 |
- </Select> |
|
| 146 |
- </Field> |
|
| 175 |
+ <div className={styles.field}>
|
|
| 176 |
+ <FoxFormLabel as="span">잠김여부</FoxFormLabel> |
|
| 177 |
+ <FoxToggleSwitch |
|
| 178 |
+ size="md" |
|
| 179 |
+ label={isActive ? '활성' : '비활성'}
|
|
| 180 |
+ checked={isActive}
|
|
| 181 |
+ onChange={setIsActive}
|
|
| 182 |
+ /> |
|
| 183 |
+ </div> |
|
| 184 |
+ {/* 화면은 활성(잠기지 않음)을 보여주고 서버는 잠김 여부를 받는다 — 여기서 뒤집는다. */}
|
|
| 185 |
+ <input type="hidden" name="isLocked" value={String(!isActive)} />
|
|
| 147 | 186 |
|
| 148 | 187 |
{state.status === 'error' && state.message && (
|
| 149 |
- <p className="text-body-sm text-danger">{state.message}</p>
|
|
| 188 |
+ <p role="alert">{state.message}</p>
|
|
| 150 | 189 |
)} |
| 151 | 190 |
</form> |
| 152 |
- </Modal> |
|
| 191 |
+ </FoxModal> |
|
| 153 | 192 |
); |
| 154 | 193 |
} |
--- app/(protected)/(basic)/admins/_components/admin-member-form-fields.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-form-fields.tsx
... | ... | @@ -1,115 +1,135 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 |
-import { Field } from '@/components/ui/field';
|
|
| 4 |
-import { Input } from '@/components/ui/input';
|
|
| 5 |
-import { Select } from '@/components/ui/select';
|
|
| 3 |
+import { useId, type ReactNode } from 'react';
|
|
| 4 |
+import { FoxChip } from '@fox/core/components/fox-chip';
|
|
| 5 |
+import { FoxChipArea } from '@fox/core/components/fox-chip-area';
|
|
| 6 |
+import { FoxFormLabel } from '@fox/core/components/fox-form-label';
|
|
| 7 |
+import { FoxPhoneNumber } from '@fox/core/components/fox-phone-number';
|
|
| 8 |
+import { FoxSelect } from '@fox/core/components/fox-select';
|
|
| 6 | 9 |
import {
|
| 10 |
+ ADMIN_MENU_OPTIONS, |
|
| 7 | 11 |
ADMIN_ROLE_OPTIONS, |
| 8 |
- DEFAULT_ADMIN_ROLE_CODE, |
|
| 9 |
- type AdminMember, |
|
| 10 | 12 |
} from '@/lib/domain/admin-member'; |
| 11 |
-import {
|
|
| 12 |
- splitPhoneNumber, |
|
| 13 |
- type AdminMemberFormErrors, |
|
| 14 |
-} from '@/lib/domain/admin-member-form'; |
|
| 15 |
-import { AdminMenuPicker } from './admin-menu-picker';
|
|
| 13 |
+import type { AdminMemberFormErrors } from '@/lib/domain/admin-member-form';
|
|
| 14 |
+import styles from './admin-member-modal.module.scss'; |
|
| 16 | 15 |
|
| 17 | 16 |
interface AdminMemberFormFieldsProps {
|
| 18 |
- /** 수정 팝업의 기존 값. 등록 팝업은 넘기지 않는다(빈 폼). */ |
|
| 19 |
- member?: AdminMember; |
|
| 17 |
+ /** 숫자만 담긴 휴대전화번호. 하이픈은 화면이 그리고 값에는 넣지 않는다. */ |
|
| 18 |
+ phoneNumber: string; |
|
| 19 |
+ onPhoneNumberChange: (phoneNumber: string) => void; |
|
| 20 |
+ roleCode: string; |
|
| 21 |
+ onRoleCodeChange: (roleCode: string) => void; |
|
| 22 |
+ menuCodes: string[]; |
|
| 23 |
+ onMenuCodesChange: (menuCodes: string[]) => void; |
|
| 24 |
+ /** |
|
| 25 |
+ * 휴대전화 번호와 역할 선택 **사이**에 들어가는 이메일 칸. 두 시안이 이 자리에 서로 다른 것을 |
|
| 26 |
+ * 놓아(등록은 도메인 셀렉트가 붙은 form-email, 수정은 단일 input) 슬롯으로 받는다 — 자리 순서는 |
|
| 27 |
+ * 두 시안이 같으므로 여기가 갖고, 무엇을 놓을지는 각 팝업이 정한다. |
|
| 28 |
+ */ |
|
| 29 |
+ emailField: ReactNode; |
|
| 20 | 30 |
errors: AdminMemberFormErrors; |
| 21 | 31 |
} |
| 22 | 32 |
|
| 23 | 33 |
/** |
| 24 |
- * 등록·수정 팝업이 공유하는 입력 항목 — 휴대전화 번호 / 이메일 / 역할 선택 / 메뉴 선택. |
|
| 25 |
- * 두 시안(ADM_ADM_102_p ②③ / 103_p ②)에서 규칙이 같은 부분만 모았다. 서로 다른 부분 |
|
| 26 |
- * (이름·ID의 편집 가능 여부, 비밀번호 입력 방식, 잠김여부)은 각 팝업이 직접 그린다. |
|
| 34 |
+ * 등록·수정 팝업이 공유하는 입력 항목 — 휴대전화 번호 / (이메일 슬롯) / 역할 선택 / 메뉴 선택. |
|
| 35 |
+ * 두 시안(ADM_ADM_102_p 5227:2035 / ADM_ADM_103_p 5227:3351)에서 자리와 순서가 같은 부분이다. |
|
| 36 |
+ * 이름·ID·비밀번호·잠김여부는 두 시안이 서로 달라 각 팝업이 직접 그린다. |
|
| 27 | 37 |
* |
| 28 |
- * 휴대전화 번호는 시안대로 3칸으로 나눠 받고 Server Action이 하나로 합친다 |
|
| 29 |
- * (`joinPhoneNumber`) — 칸을 나누는 것은 화면 표현이고, 저장 형식은 도메인이 정한다. |
|
| 38 |
+ * 세 칸 모두 @fox의 제어 위젯이라 값이 FormData에 실리지 않는다(`FoxSelect`는 네이티브 select가 |
|
| 39 |
+ * 아니라 버튼+리스트박스, `FoxPhoneNumber`·칩도 마찬가지다). 그래서 값은 호출부가 들고, 제출용 |
|
| 40 |
+ * hidden input을 여기서 함께 낸다 — 폼 제출 규약(Server Action + FormData)을 그대로 두기 위해서다. |
|
| 30 | 41 |
* |
| 31 |
- * ⚠️ **메뉴 선택은 아직 저장되지 않는다.** 백엔드에 관리자별 메뉴 권한 개념이 없어 보낼 곳이 |
|
| 32 |
- * 없다(등록·수정 VO에 필드가 없고 `/api/v1/common/menu`는 개인 북마크용이다). 시안대로 자리와 |
|
| 33 |
- * 필수 검증은 유지하되 값은 Repository까지 가지 않는다 — 권한 API가 생기면 여기는 그대로 두고 |
|
| 34 |
- * Server Action이 값을 넘기기만 하면 된다. |
|
| 42 |
+ * ⚠️ **메뉴 선택은 저장되지 않는다.** 백엔드에 관리자별 메뉴 권한이 테이블·VO·SQL 어디에도 없다 |
|
| 43 |
+ * (`/api/v1/common/menu`는 개인 북마크용이다). 시안대로 자리는 유지하되 **필수 검증은 걸지 않는다** |
|
| 44 |
+ * (사용자 확정) — 저장되지도 않는 값 때문에 등록이 막히면 안 되기 때문이다. 권한 API가 생기면 |
|
| 45 |
+ * 여기는 그대로 두고 Server Action이 값을 넘기기만 하면 된다. |
|
| 35 | 46 |
*/ |
| 36 | 47 |
export function AdminMemberFormFields({
|
| 37 |
- member, |
|
| 48 |
+ phoneNumber, |
|
| 49 |
+ onPhoneNumberChange, |
|
| 50 |
+ roleCode, |
|
| 51 |
+ onRoleCodeChange, |
|
| 52 |
+ menuCodes, |
|
| 53 |
+ onMenuCodesChange, |
|
| 54 |
+ emailField, |
|
| 38 | 55 |
errors, |
| 39 | 56 |
}: AdminMemberFormFieldsProps) {
|
| 40 |
- const phoneParts = splitPhoneNumber(member?.phoneNumber ?? null); |
|
| 57 |
+ const phoneLabelId = useId(); |
|
| 58 |
+ const menuLabelId = useId(); |
|
| 59 |
+ |
|
| 60 |
+ const allMenuValues = ADMIN_MENU_OPTIONS.map((option) => option.value); |
|
| 61 |
+ const isAllSelected = menuCodes.length === allMenuValues.length; |
|
| 62 |
+ |
|
| 63 |
+ function toggleMenu(value: string) {
|
|
| 64 |
+ onMenuCodesChange( |
|
| 65 |
+ menuCodes.includes(value) |
|
| 66 |
+ ? menuCodes.filter((code) => code !== value) |
|
| 67 |
+ : [...menuCodes, value] |
|
| 68 |
+ ); |
|
| 69 |
+ } |
|
| 41 | 70 |
|
| 42 | 71 |
return ( |
| 43 | 72 |
<> |
| 44 |
- <Field label="휴대전화 번호 *"> |
|
| 45 |
- <div className="flex items-center gap-2"> |
|
| 46 |
- <Input |
|
| 47 |
- type="tel" |
|
| 48 |
- name="phoneNumber1" |
|
| 49 |
- defaultValue={phoneParts[0]}
|
|
| 50 |
- maxLength={3}
|
|
| 51 |
- inputMode="numeric" |
|
| 52 |
- aria-label="휴대전화 번호 앞자리" |
|
| 53 |
- placeholder="010" |
|
| 54 |
- /> |
|
| 55 |
- <Input |
|
| 56 |
- type="tel" |
|
| 57 |
- name="phoneNumber2" |
|
| 58 |
- defaultValue={phoneParts[1]}
|
|
| 59 |
- maxLength={4}
|
|
| 60 |
- inputMode="numeric" |
|
| 61 |
- aria-label="휴대전화 번호 가운데자리" |
|
| 62 |
- placeholder="0000" |
|
| 63 |
- /> |
|
| 64 |
- <Input |
|
| 65 |
- type="tel" |
|
| 66 |
- name="phoneNumber3" |
|
| 67 |
- defaultValue={phoneParts[2]}
|
|
| 68 |
- maxLength={4}
|
|
| 69 |
- inputMode="numeric" |
|
| 70 |
- aria-label="휴대전화 번호 끝자리" |
|
| 71 |
- placeholder="0000" |
|
| 72 |
- /> |
|
| 73 |
- </div> |
|
| 74 |
- </Field> |
|
| 75 |
- <FieldError message={errors.phoneNumber} />
|
|
| 76 |
- |
|
| 77 |
- <Field label="이메일"> |
|
| 78 |
- <Input |
|
| 79 |
- type="email" |
|
| 80 |
- name="email" |
|
| 81 |
- defaultValue={member?.email ?? ''}
|
|
| 82 |
- placeholder="이메일을 입력하세요." |
|
| 73 |
+ {/* 칸이 셋으로 나뉘어 있어 `<label htmlFor>`가 가리킬 대상이 하나가 아니다 — 이름 연결은
|
|
| 74 |
+ 감싼 group이 aria-labelledby로 한다(FoxFormLabel의 `as="span"`이 그 용도다). */} |
|
| 75 |
+ <div className={styles.field} role="group" aria-labelledby={phoneLabelId}>
|
|
| 76 |
+ <FoxFormLabel as="span" id={phoneLabelId} requirement="required">
|
|
| 77 |
+ 휴대전화 번호 |
|
| 78 |
+ </FoxFormLabel> |
|
| 79 |
+ <FoxPhoneNumber |
|
| 80 |
+ type="unit" |
|
| 81 |
+ value={phoneNumber}
|
|
| 82 |
+ onChange={onPhoneNumberChange}
|
|
| 83 |
+ message={errors.phoneNumber}
|
|
| 84 |
+ invalid={Boolean(errors.phoneNumber)}
|
|
| 83 | 85 |
/> |
| 84 |
- </Field> |
|
| 85 |
- <FieldError message={errors.email} />
|
|
| 86 |
+ </div> |
|
| 87 |
+ <input type="hidden" name="phoneNumber" value={phoneNumber} />
|
|
| 86 | 88 |
|
| 87 |
- <Field label="역할 선택 *"> |
|
| 88 |
- <Select |
|
| 89 |
- name="roleCode" |
|
| 90 |
- defaultValue={member?.roleCode ?? DEFAULT_ADMIN_ROLE_CODE}
|
|
| 91 |
- > |
|
| 92 |
- {ADMIN_ROLE_OPTIONS.map((option) => (
|
|
| 93 |
- <option key={option.value} value={option.value}>
|
|
| 94 |
- {option.label}
|
|
| 95 |
- </option> |
|
| 89 |
+ {emailField}
|
|
| 90 |
+ |
|
| 91 |
+ <FoxSelect |
|
| 92 |
+ size="md" |
|
| 93 |
+ label="역할 선택" |
|
| 94 |
+ options={ADMIN_ROLE_OPTIONS.map((option) => ({
|
|
| 95 |
+ value: option.value, |
|
| 96 |
+ label: option.label, |
|
| 97 |
+ }))} |
|
| 98 |
+ value={roleCode}
|
|
| 99 |
+ onValueChange={onRoleCodeChange}
|
|
| 100 |
+ error={Boolean(errors.roleCode)}
|
|
| 101 |
+ hint={errors.roleCode}
|
|
| 102 |
+ /> |
|
| 103 |
+ <input type="hidden" name="roleCode" value={roleCode} />
|
|
| 104 |
+ |
|
| 105 |
+ <div className={styles.field} role="group" aria-labelledby={menuLabelId}>
|
|
| 106 |
+ <FoxFormLabel as="span" id={menuLabelId} requirement="required">
|
|
| 107 |
+ 메뉴 선택 |
|
| 108 |
+ </FoxFormLabel> |
|
| 109 |
+ <FoxChipArea size="md" wrap labelledBy={menuLabelId}>
|
|
| 110 |
+ {/* 시안의 첫 칩. 개별 메뉴가 아니라 나머지를 한 번에 켜고 끄는 조각이다. */}
|
|
| 111 |
+ <FoxChip |
|
| 112 |
+ type="check" |
|
| 113 |
+ label="전체" |
|
| 114 |
+ checked={isAllSelected}
|
|
| 115 |
+ onCheckedChange={(checked) =>
|
|
| 116 |
+ onMenuCodesChange(checked ? allMenuValues : []) |
|
| 117 |
+ } |
|
| 118 |
+ /> |
|
| 119 |
+ {ADMIN_MENU_OPTIONS.map((option) => (
|
|
| 120 |
+ <FoxChip |
|
| 121 |
+ key={option.value}
|
|
| 122 |
+ type="check" |
|
| 123 |
+ label={option.label}
|
|
| 124 |
+ checked={menuCodes.includes(option.value)}
|
|
| 125 |
+ onCheckedChange={() => toggleMenu(option.value)}
|
|
| 126 |
+ /> |
|
| 96 | 127 |
))} |
| 97 |
- </Select> |
|
| 98 |
- </Field> |
|
| 99 |
- <FieldError message={errors.roleCode} />
|
|
| 100 |
- |
|
| 101 |
- <Field label="메뉴 선택 *"> |
|
| 102 |
- <AdminMenuPicker defaultValue={member?.menuCodes ?? []} />
|
|
| 103 |
- </Field> |
|
| 104 |
- <FieldError message={errors.menuCodes} />
|
|
| 128 |
+ </FoxChipArea> |
|
| 129 |
+ </div> |
|
| 130 |
+ {menuCodes.map((code) => (
|
|
| 131 |
+ <input key={code} type="hidden" name="menuCodes" value={code} />
|
|
| 132 |
+ ))} |
|
| 105 | 133 |
</> |
| 106 | 134 |
); |
| 107 |
-} |
|
| 108 |
- |
|
| 109 |
-/** 필드 하단 오류 문구. 값이 없으면 아무것도 그리지 않아 레이아웃을 차지하지 않는다. */ |
|
| 110 |
-export function FieldError({ message }: { message?: string }) {
|
|
| 111 |
- if (!message) {
|
|
| 112 |
- return null; |
|
| 113 |
- } |
|
| 114 |
- return <p className="text-body-sm text-danger">{message}</p>;
|
|
| 115 | 135 |
} |
--- app/(protected)/(basic)/admins/_components/admin-member-list-toolbar.tsx
... | ... | @@ -1,99 +0,0 @@ |
| 1 | -'use client'; | |
| 2 | - | |
| 3 | -import { useRouter } from 'next/navigation'; | |
| 4 | -import { useState, type ChangeEvent } from 'react'; | |
| 5 | -import { Button } from '@/components/ui/button'; | |
| 6 | -import { Select } from '@/components/ui/select'; | |
| 7 | -import { | |
| 8 | - ADMIN_MEMBER_PAGE_SIZE_OPTIONS, | |
| 9 | - ADMIN_MEMBER_SORT_OPTIONS, | |
| 10 | - buildAdminMemberHref, | |
| 11 | - type AdminMemberPageSize, | |
| 12 | - type AdminMemberQuery, | |
| 13 | - type AdminMemberSortOption, | |
| 14 | -} from '@/lib/domain/admin-member-query'; | |
| 15 | -import { AdminMemberCreateModal } from './admin-member-create-modal'; | |
| 16 | - | |
| 17 | -interface AdminMemberListToolbarProps { | |
| 18 | - query: AdminMemberQuery; | |
| 19 | -} | |
| 20 | - | |
| 21 | -const EXCEL_UNSUPPORTED_NOTE = '엑셀 다운로드는 추후 제공됩니다.'; | |
| 22 | - | |
| 23 | -/** | |
| 24 | - * 목록 툴바(시안 ADM_ADM_101 ②③) — 정렬/페이지 크기 select는 변경 즉시 URL을 갱신해 서버 | |
| 25 | - * 데이터를 다시 조회하게 한다. 두 값 모두 바뀌면 1페이지로 되돌린다(기존 페이지 번호가 새 정렬· | |
| 26 | - * 크기 기준으로는 의미가 달라지므로). | |
| 27 | - * | |
| 28 | - * **엑셀다운로드는 버튼만 배치하고 동작은 구현하지 않는다**(사용자 지시 — 추후 진행). | |
| 29 | - * disabled로 두어 클릭해도 아무 일도 일어나지 않게 한다. 학생 회원 목록처럼 백엔드에 엑셀 API가 | |
| 30 | - * 있으면 라우트 핸들러로 중계하면 되지만, 관리자 쪽에는 그 API가 아직 없다 | |
| 31 | - * (`/api/v1/mngr/admin/**`에 목록 조회 하나뿐). | |
| 32 | - * | |
| 33 | - * [신규 등록]은 시안대로 등록 팝업을 연다 — 팝업 열림 상태를 이 컴포넌트가 소유한다. | |
| 34 | - */ | |
| 35 | -export function AdminMemberListToolbar({ query }: AdminMemberListToolbarProps) { | |
| 36 | - const router = useRouter(); | |
| 37 | - const [isCreateOpen, setIsCreateOpen] = useState(false); | |
| 38 | - | |
| 39 | - function handleSortChange(event: ChangeEvent<HTMLSelectElement>) { | |
| 40 | - const sort = event.target.value as AdminMemberSortOption; | |
| 41 | - router.replace(buildAdminMemberHref(query, { sort, page: 1 })); | |
| 42 | - } | |
| 43 | - | |
| 44 | - function handlePageSizeChange(event: ChangeEvent<HTMLSelectElement>) { | |
| 45 | - const pageSize = Number(event.target.value) as AdminMemberPageSize; | |
| 46 | - router.replace(buildAdminMemberHref(query, { pageSize, page: 1 })); | |
| 47 | - } | |
| 48 | - | |
| 49 | - return ( | |
| 50 | - <div className="flex flex-wrap items-center justify-end gap-3"> | |
| 51 | - <Button | |
| 52 | - type="button" | |
| 53 | - variant="success" | |
| 54 | - disabled | |
| 55 | - title={EXCEL_UNSUPPORTED_NOTE} | |
| 56 | - > | |
| 57 | - 엑셀다운로드 | |
| 58 | - </Button> | |
| 59 | - | |
| 60 | - <Button | |
| 61 | - type="button" | |
| 62 | - variant="primary" | |
| 63 | - onClick={() => setIsCreateOpen(true)} | |
| 64 | - > | |
| 65 | - 신규 등록 | |
| 66 | - </Button> | |
| 67 | - | |
| 68 | - <Select | |
| 69 | - name="sort" | |
| 70 | - aria-label="정렬" | |
| 71 | - defaultValue={query.sort} | |
| 72 | - onChange={handleSortChange} | |
| 73 | - > | |
| 74 | - {ADMIN_MEMBER_SORT_OPTIONS.map((option) => ( | |
| 75 | - <option key={option.value} value={option.value}> | |
| 76 | - {option.label} | |
| 77 | - </option> | |
| 78 | - ))} | |
| 79 | - </Select> | |
| 80 | - | |
| 81 | - <Select | |
| 82 | - name="pageSize" | |
| 83 | - aria-label="페이지 크기" | |
| 84 | - defaultValue={query.pageSize} | |
| 85 | - onChange={handlePageSizeChange} | |
| 86 | - > | |
| 87 | - {ADMIN_MEMBER_PAGE_SIZE_OPTIONS.map((size) => ( | |
| 88 | - <option key={size} value={size}> | |
| 89 | - {size}개씩 | |
| 90 | - </option> | |
| 91 | - ))} | |
| 92 | - </Select> | |
| 93 | - | |
| 94 | - {isCreateOpen && ( | |
| 95 | - <AdminMemberCreateModal onClose={() => setIsCreateOpen(false)} /> | |
| 96 | - )} | |
| 97 | - </div> | |
| 98 | - ); | |
| 99 | -} |
+++ app/(protected)/(basic)/admins/_components/admin-member-list.tsx
... | ... | @@ -0,0 +1,240 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useRouter } from 'next/navigation'; | |
| 4 | +import { useState } from 'react'; | |
| 5 | +import { FoxBadge, type FoxBadgeColor } from '@fox/core/components/fox-badge'; | |
| 6 | +import { FoxButton } from '@fox/core/components/fox-button'; | |
| 7 | +import { FoxButtonGroup } from '@fox/core/components/fox-button-group'; | |
| 8 | +import { | |
| 9 | + FoxListContainer, | |
| 10 | + type FoxListColumn, | |
| 11 | +} from '@fox/core/components/fox-list-container'; | |
| 12 | +import { FoxSelectText } from '@fox/core/components/fox-select-text'; | |
| 13 | +import { | |
| 14 | + FoxArrowsDownUpIcon, | |
| 15 | + FoxDownloadSimpleIcon, | |
| 16 | + FoxPlusIcon, | |
| 17 | +} from '@fox/core/icons'; | |
| 18 | +import { | |
| 19 | + formatAdminRoleLabel, | |
| 20 | + formatOptionalValue, | |
| 21 | + type AdminMember, | |
| 22 | +} from '@/lib/domain/admin-member'; | |
| 23 | +import { | |
| 24 | + ADMIN_MEMBERS_EXCEL_PATH, | |
| 25 | + ADMIN_MEMBER_PAGE_SIZE_OPTIONS, | |
| 26 | + ADMIN_MEMBER_SEARCH_FIELD_OPTIONS, | |
| 27 | + ADMIN_MEMBER_SORT_OPTIONS, | |
| 28 | + buildAdminMemberHref, | |
| 29 | + type AdminMemberPageSize, | |
| 30 | + type AdminMemberQuery, | |
| 31 | + type AdminMemberSearchField, | |
| 32 | + type AdminMemberSortOption, | |
| 33 | +} from '@/lib/domain/admin-member-query'; | |
| 34 | +import { AdminMemberCreateModal } from './admin-member-create-modal'; | |
| 35 | +import { AdminMemberRowActions } from './admin-member-row-actions'; | |
| 36 | + | |
| 37 | +interface AdminMemberListProps { | |
| 38 | + items: AdminMember[]; | |
| 39 | + query: AdminMemberQuery; | |
| 40 | + /** 화면이 실제로 보여주는 페이지 — 순번 계산의 기준이다. */ | |
| 41 | + currentPage: number; | |
| 42 | + totalPages: number; | |
| 43 | + totalCount: number; | |
| 44 | + /** 현재 로그인한 관리자의 id — 본인 행의 삭제 버튼을 막는 데 쓴다. */ | |
| 45 | + currentAdminId: string; | |
| 46 | +} | |
| 47 | + | |
| 48 | +/** 폭이 고정되지 않은 세 열이 나눠 갖는 몫 — 시안 1552px 기준 (1552 - 고정열 720) / 3. */ | |
| 49 | +const FILL_COLUMN_WIDTH = 277; | |
| 50 | + | |
| 51 | +/** | |
| 52 | + * 역할 배지의 색. 시안이 최고관리자는 accent(붉은 계열), 그 외는 primary(파란 계열)로 | |
| 53 | + * 구분한다 — 색은 시각 결정이라 도메인(`admin-member.ts`)이 아니라 여기가 갖는다. | |
| 54 | + * 모르는 코드는 이름을 지어내지 않듯 색도 입히지 않는다(neutral). | |
| 55 | + */ | |
| 56 | +const BADGE_COLOR_BY_ROLE: Record<string, FoxBadgeColor> = { | |
| 57 | + ROLE_SYSTEM: 'accent', | |
| 58 | + ROLE_ADMIN: 'primary', | |
| 59 | +}; | |
| 60 | + | |
| 61 | +/** | |
| 62 | + * 관리자 회원 목록 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) ADM_ADM_101 (5227:1545) | |
| 63 | + * | |
| 64 | + * 뼈대는 `FoxListContainer`가 전부 그린다(학생 회원 목록과 같은 구성). 이 파일이 갖는 것은 | |
| 65 | + * 이 화면 고유의 것뿐이다 — 8개 열의 정의, 정렬·페이지 크기 셀렉트, 툴바 버튼 두 개, | |
| 66 | + * 그리고 URL을 갱신하는 방법. | |
| 67 | + * | |
| 68 | + * 이동은 두 갈래로 나뉜다. 페이지네이션은 `buildHref`로 **링크**가 되고(주소가 곧 상태라 | |
| 69 | + * 새로고침·뒤로가기·공유가 그대로 동작한다), 검색·정렬·페이지 크기는 컨트롤이 눌린 순간 | |
| 70 | + * `router.replace`로 같은 주소 규칙을 태운다. 어느 쪽이든 목록을 다시 그리는 것은 서버다. | |
| 71 | + * | |
| 72 | + * [엑셀 다운로드]는 네이티브 GET 폼이다 — 응답이 첨부파일(Content-Disposition)이라 라우터 | |
| 73 | + * 내비게이션으로는 처리할 수 없고, 브라우저가 화면을 둔 채 파일만 내려받는다. 검색·페이징 값을 | |
| 74 | + * 싣지 않는 것이 사양이다(파일은 항상 전체 데이터). | |
| 75 | + */ | |
| 76 | +export function AdminMemberList({ | |
| 77 | + items, | |
| 78 | + query, | |
| 79 | + currentPage, | |
| 80 | + totalPages, | |
| 81 | + totalCount, | |
| 82 | + currentAdminId, | |
| 83 | +}: AdminMemberListProps) { | |
| 84 | + const router = useRouter(); | |
| 85 | + const [isCreateOpen, setIsCreateOpen] = useState(false); | |
| 86 | + | |
| 87 | + /** 목록 조건이 바뀌면 늘 1페이지로 되돌린다 — 이전 페이지 번호는 새 조건에서 의미가 다르다. */ | |
| 88 | + function go(patch: Partial<AdminMemberQuery>) { | |
| 89 | + router.replace(buildAdminMemberHref(query, { ...patch, page: 1 })); | |
| 90 | + } | |
| 91 | + | |
| 92 | + const offset = (currentPage - 1) * query.pageSize; | |
| 93 | + | |
| 94 | + const columns: FoxListColumn<AdminMember>[] = [ | |
| 95 | + { | |
| 96 | + key: 'no', | |
| 97 | + header: '번호', | |
| 98 | + width: 80, | |
| 99 | + // 저장된 값이 아니라 전체 건수에서 거꾸로 세는 표시 순번이다 — 기본 정렬이 생성일 | |
| 100 | + // 최신순이라 "가장 최근에 만든 계정이 가장 큰 번호"가 된다. | |
| 101 | + render: (_row, index) => totalCount - offset - index, | |
| 102 | + }, | |
| 103 | + // 이름·아이디·이메일은 시안에서 남는 폭을 똑같이 나눠 갖는다(1552 기준 각 277). 폭을 | |
| 104 | + // 비워 두면 표가 글자 길이대로 나눠 이메일 열만 넓어져 시안과 어긋난다. | |
| 105 | + { key: 'name', header: '이름', width: FILL_COLUMN_WIDTH }, | |
| 106 | + { key: 'loginId', header: '아이디', width: FILL_COLUMN_WIDTH }, | |
| 107 | + { | |
| 108 | + key: 'phoneNumber', | |
| 109 | + header: '휴대전화번호', | |
| 110 | + width: 200, | |
| 111 | + render: (row) => formatOptionalValue(row.phoneNumber), | |
| 112 | + }, | |
| 113 | + { | |
| 114 | + key: 'email', | |
| 115 | + header: '이메일', | |
| 116 | + width: FILL_COLUMN_WIDTH, | |
| 117 | + render: (row) => formatOptionalValue(row.email), | |
| 118 | + }, | |
| 119 | + { | |
| 120 | + key: 'role', | |
| 121 | + header: '역할', | |
| 122 | + width: 120, | |
| 123 | + render: (row) => ( | |
| 124 | + <FoxBadge | |
| 125 | + type="pastel" | |
| 126 | + color={BADGE_COLOR_BY_ROLE[row.roleCode] ?? 'neutral'} | |
| 127 | + size="md" | |
| 128 | + > | |
| 129 | + {formatAdminRoleLabel(row.roleCode)} | |
| 130 | + </FoxBadge> | |
| 131 | + ), | |
| 132 | + }, | |
| 133 | + { | |
| 134 | + key: 'createdAt', | |
| 135 | + header: '생성일', | |
| 136 | + width: 160, | |
| 137 | + render: (row) => formatOptionalValue(row.createdAt), | |
| 138 | + }, | |
| 139 | + { | |
| 140 | + key: 'actions', | |
| 141 | + header: '관리', | |
| 142 | + width: 160, | |
| 143 | + render: (row) => ( | |
| 144 | + <AdminMemberRowActions | |
| 145 | + member={row} | |
| 146 | + isSelf={row.id === currentAdminId} | |
| 147 | + /> | |
| 148 | + ), | |
| 149 | + }, | |
| 150 | + ]; | |
| 151 | + | |
| 152 | + return ( | |
| 153 | + <> | |
| 154 | + <FoxListContainer<AdminMember> | |
| 155 | + title="관리자 회원 목록" | |
| 156 | + breadcrumb={[ | |
| 157 | + { label: '홈', href: '/' }, | |
| 158 | + { label: '관리자정보관리' }, | |
| 159 | + { label: '관리자 회원' }, | |
| 160 | + ]} | |
| 161 | + caption="관리자 회원 목록" | |
| 162 | + columns={columns} | |
| 163 | + rows={items} | |
| 164 | + rowKey={(row) => row.id} | |
| 165 | + totalCount={totalCount} | |
| 166 | + emptyMessage="검색 결과가 없습니다. 다른 검색어로 다시 시도해 주세요." | |
| 167 | + sorts={ | |
| 168 | + <> | |
| 169 | + <FoxSelectText | |
| 170 | + size="sm" | |
| 171 | + ariaLabel="정렬" | |
| 172 | + icon={<FoxArrowsDownUpIcon />} | |
| 173 | + options={ADMIN_MEMBER_SORT_OPTIONS.map((option) => ({ | |
| 174 | + value: option.value, | |
| 175 | + label: option.label, | |
| 176 | + }))} | |
| 177 | + value={query.sort} | |
| 178 | + onValueChange={(value) => go({ sort: value as AdminMemberSortOption })} | |
| 179 | + /> | |
| 180 | + <FoxSelectText | |
| 181 | + size="sm" | |
| 182 | + ariaLabel="페이지 크기" | |
| 183 | + options={ADMIN_MEMBER_PAGE_SIZE_OPTIONS.map((size) => ({ | |
| 184 | + value: String(size), | |
| 185 | + label: `${size}개씩 보기`, | |
| 186 | + }))} | |
| 187 | + value={String(query.pageSize)} | |
| 188 | + onValueChange={(value) => | |
| 189 | + go({ pageSize: Number(value) as AdminMemberPageSize }) | |
| 190 | + } | |
| 191 | + /> | |
| 192 | + </> | |
| 193 | + } | |
| 194 | + searchFields={ADMIN_MEMBER_SEARCH_FIELD_OPTIONS.map((option) => ({ | |
| 195 | + value: option.value, | |
| 196 | + label: option.label, | |
| 197 | + }))} | |
| 198 | + searchField={query.searchField} | |
| 199 | + keyword={query.keyword} | |
| 200 | + toolbarActions={ | |
| 201 | + <FoxButtonGroup size="md"> | |
| 202 | + <form action={ADMIN_MEMBERS_EXCEL_PATH} method="get"> | |
| 203 | + <FoxButton | |
| 204 | + type="secondary" | |
| 205 | + size="md" | |
| 206 | + htmlType="submit" | |
| 207 | + leadingIcon={<FoxDownloadSimpleIcon />} | |
| 208 | + label="엑셀 다운로드" | |
| 209 | + /> | |
| 210 | + </form> | |
| 211 | + <FoxButton | |
| 212 | + type="primary" | |
| 213 | + size="md" | |
| 214 | + leadingIcon={<FoxPlusIcon />} | |
| 215 | + label="신규 등록" | |
| 216 | + onAction={() => setIsCreateOpen(true)} | |
| 217 | + /> | |
| 218 | + </FoxButtonGroup> | |
| 219 | + } | |
| 220 | + page={currentPage} | |
| 221 | + totalPages={totalPages} | |
| 222 | + buildHref={(patch) => | |
| 223 | + buildAdminMemberHref(query, { page: Number(patch.page ?? 1) }) | |
| 224 | + } | |
| 225 | + onQueryChange={(patch) => { | |
| 226 | + if (patch.keyword !== undefined || patch.searchField !== undefined) { | |
| 227 | + go({ | |
| 228 | + keyword: String(patch.keyword ?? ''), | |
| 229 | + searchField: (patch.searchField ?? | |
| 230 | + query.searchField) as AdminMemberSearchField, | |
| 231 | + }); | |
| 232 | + } | |
| 233 | + }} | |
| 234 | + /> | |
| 235 | + {isCreateOpen && ( | |
| 236 | + <AdminMemberCreateModal onClose={() => setIsCreateOpen(false)} /> | |
| 237 | + )} | |
| 238 | + </> | |
| 239 | + ); | |
| 240 | +} |
+++ app/(protected)/(basic)/admins/_components/admin-member-modal.module.scss
... | ... | @@ -0,0 +1,49 @@ |
| 1 | +// 관리자 등록·수정 팝업의 배치 — 시안(ADM_ADM_102_p / 103_p)의 form-list. | |
| 2 | +// 값은 전부 @fox 토큰을 거치므로 없는 이름을 쓰면 빌드가 실패한다. | |
| 3 | + | |
| 4 | +@use "@fox/styles/abstracts" as fox; | |
| 5 | + | |
| 6 | +/// 필드 한 벌씩 세로로 쌓는다. 라벨·상자·헬퍼 사이 간격은 각 @fox 컴포넌트가 이미 갖는다. | |
| 7 | +/// 모달 contents는 `align-items: flex-start`라 자식이 늘어나지 않는다 — 폭을 주지 않으면 폼이 | |
| 8 | +/// 내용만큼 넓어져(전화번호 칸이 가장 넓다) 모달 밖으로 터진다. | |
| 9 | +.formList { | |
| 10 | + display: flex; | |
| 11 | + flex-direction: column; | |
| 12 | + gap: fox.gap(8); | |
| 13 | + inline-size: 100%; | |
| 14 | + min-inline-size: 0; | |
| 15 | +} | |
| 16 | + | |
| 17 | +/// 입력칸 바로 아래 전체폭 버튼이 붙는 칸([중복 확인]·[비밀번호 변경]). | |
| 18 | +.fieldWithAction { | |
| 19 | + display: flex; | |
| 20 | + flex-direction: column; | |
| 21 | + gap: fox.gap(3); | |
| 22 | +} | |
| 23 | + | |
| 24 | +/// 시안(102_p)의 이메일 한 줄 — [아이디] @ [도메인]. @fox의 `FoxEmail`을 쓰지 않는다: | |
| 25 | +/// 그쪽은 도메인 셀렉트 옆에 잠긴 '직접입력' 상자를 하나 더 그리고(시안은 상자 둘뿐), | |
| 26 | +/// 칸마다 240px 최소폭이 박혀 있어 320px 안에서 세 줄로 쌓인다(사용자 확정). | |
| 27 | +.emailRow { | |
| 28 | + display: flex; | |
| 29 | + align-items: center; | |
| 30 | + gap: fox.gap(2); | |
| 31 | + inline-size: 100%; | |
| 32 | +} | |
| 33 | + | |
| 34 | +/// 두 칸이 남는 폭을 나눠 갖는다. `min-inline-size: 0`이 없으면 내용만큼 넓어져 줄이 넘어간다. | |
| 35 | +.emailPart { | |
| 36 | + flex: 1 1 0; | |
| 37 | + min-inline-size: 0; | |
| 38 | +} | |
| 39 | + | |
| 40 | +/// 라벨 + 컨트롤 한 벌. @fox 입력 컴포넌트는 라벨을 스스로 갖지만, 여러 칸을 묶은 필드 | |
| 41 | +/// (전화번호·이메일·메뉴·잠김여부)는 라벨을 밖에서 얹으므로 그 간격을 여기서 준다. | |
| 42 | +/// `FoxFormLabel`은 인라인이라 이 틀이 없으면 토글 같은 인라인 컨트롤과 한 줄에 붙는다. | |
| 43 | +.field { | |
| 44 | + display: flex; | |
| 45 | + flex-direction: column; | |
| 46 | + align-items: flex-start; | |
| 47 | + gap: fox.gap(3); | |
| 48 | + inline-size: 100%; | |
| 49 | +} |
+++ app/(protected)/(basic)/admins/_components/admin-member-row-actions.module.scss
... | ... | @@ -0,0 +1,6 @@ |
| 1 | +// 비활성 삭제 버튼을 감싸 네이티브 툴팁을 다는 자리(그 이유는 컴포넌트 주석 참조). | |
| 2 | +// 감싸기만 하면 안쪽 버튼이 글자 기준선에 앉아 2px가 더 붙고, 그만큼 행이 시안(44px)보다 | |
| 3 | +// 높아진다. 래퍼를 flex로 두어 버튼 높이가 그대로 래퍼 높이가 되게 한다. | |
| 4 | +.hint { | |
| 5 | + display: flex; | |
| 6 | +} |
--- app/(protected)/(basic)/admins/_components/admin-member-row-actions.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-row-actions.tsx
... | ... | @@ -1,11 +1,13 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 | 3 |
import { useState, useTransition } from 'react';
|
| 4 |
-import { Button } from '@/components/ui/button';
|
|
| 4 |
+import { FoxButton } from '@fox/core/components/fox-button';
|
|
| 5 |
+import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
|
|
| 5 | 6 |
import { FoxIconButton } from '@fox/core/components/fox-icon-button';
|
| 6 | 7 |
import { FoxPencilSimpleIcon, FoxTrashIcon } from '@fox/core/icons';
|
| 7 | 8 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 8 | 9 |
import type { AdminMember } from '@/lib/domain/admin-member';
|
| 10 |
+import styles from './admin-member-row-actions.module.scss'; |
|
| 9 | 11 |
import { deleteAdminMemberAction } from '../_actions';
|
| 10 | 12 |
import { AdminMemberEditModal } from './admin-member-edit-modal';
|
| 11 | 13 |
|
... | ... | @@ -27,9 +29,9 @@ |
| 27 | 29 |
* **본인 계정 삭제는 막는다**(시안 ⑤). 화면에서 버튼을 비활성으로 두되, 실제 차단은 Server |
| 28 | 30 |
* Action이 세션의 adminId와 대조해 수행한다 — 화면 비활성은 안내일 뿐 신뢰 경계가 아니다. |
| 29 | 31 |
* |
| 30 |
- * 시안의 연필·휴지통 아이콘 버튼이다. 버튼은 `components/ui/button`이 아니라 `FoxIconButton`을 |
|
| 31 |
- * 쓴다 — 아이콘은 스스로 크기를 갖지 않고 감싼 슬롯이 정하는데, 그 슬롯 크기를 실제로 정의하는 |
|
| 32 |
- * 건 @fox 쪽뿐이다(components/ui는 Tailwind 제거 이후 무스타일이다). |
|
| 32 |
+ * 시안의 연필·휴지통 아이콘 버튼이다. `FoxIconButton`의 default·sm이 시안 값과 그대로 맞는다 |
|
| 33 |
+ * (form/height/sm=28, form/radius/sm=4, 테두리 #b1b8be). 두 버튼의 4px 간격도 시안이 btn-group |
|
| 34 |
+ * 인스턴스라 `FoxButtonGroup`(gap/2=4)이 갖는다 — 여기서 직접 여백을 주지 않는다. |
|
| 33 | 35 |
* |
| 34 | 36 |
* 본인 계정 안내는 `<span title>`로 감싸 남긴다 — FoxIconButton은 `label`을 `aria-label`로만 |
| 35 | 37 |
* 쓰고 네이티브 툴팁을 렌더하지 않으며, 비활성 버튼은 스스로 hover 이벤트를 받지 못한다. |
... | ... | @@ -63,30 +65,26 @@ |
| 63 | 65 |
title: '관리자를 삭제하시겠습니까?', |
| 64 | 66 |
message: `${member.name}(${member.loginId}) 계정을 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
|
| 65 | 67 |
actions: ( |
| 66 |
- <> |
|
| 67 |
- <Button type="button" variant="ghost" onClick={hideAlert}>
|
|
| 68 |
- 취소 |
|
| 69 |
- </Button> |
|
| 70 |
- <Button type="button" variant="primary" onClick={runDelete}>
|
|
| 71 |
- 삭제 |
|
| 72 |
- </Button> |
|
| 73 |
- </> |
|
| 68 |
+ <FoxButtonGroup size="md"> |
|
| 69 |
+ <FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
|
|
| 70 |
+ <FoxButton type="error" size="md" label="삭제" onAction={runDelete} />
|
|
| 71 |
+ </FoxButtonGroup> |
|
| 74 | 72 |
), |
| 75 | 73 |
}); |
| 76 | 74 |
} |
| 77 | 75 |
|
| 78 | 76 |
return ( |
| 79 |
- <div className="flex items-center gap-1"> |
|
| 77 |
+ <FoxButtonGroup size="sm" align="center"> |
|
| 80 | 78 |
<FoxIconButton |
| 81 |
- type="ghost" |
|
| 79 |
+ type="default" |
|
| 82 | 80 |
size="sm" |
| 83 | 81 |
icon={<FoxPencilSimpleIcon />}
|
| 84 | 82 |
label="수정" |
| 85 | 83 |
onAction={() => setIsEditOpen(true)}
|
| 86 | 84 |
/> |
| 87 |
- <span title={isSelf ? SELF_DELETE_HINT : undefined}>
|
|
| 85 |
+ <span className={styles.hint} title={isSelf ? SELF_DELETE_HINT : undefined}>
|
|
| 88 | 86 |
<FoxIconButton |
| 89 |
- type="ghost" |
|
| 87 |
+ type="default" |
|
| 90 | 88 |
size="sm" |
| 91 | 89 |
icon={<FoxTrashIcon />}
|
| 92 | 90 |
label={isDeleting ? '삭제 중' : '삭제'}
|
... | ... | @@ -101,6 +99,6 @@ |
| 101 | 99 |
onClose={() => setIsEditOpen(false)}
|
| 102 | 100 |
/> |
| 103 | 101 |
)} |
| 104 |
- </div> |
|
| 102 |
+ </FoxButtonGroup> |
|
| 105 | 103 |
); |
| 106 | 104 |
} |
--- app/(protected)/(basic)/admins/_components/admin-member-search-bar.tsx
... | ... | @@ -1,74 +0,0 @@ |
| 1 | -import Link from 'next/link'; | |
| 2 | -import { Button } from '@/components/ui/button'; | |
| 3 | -import { Field } from '@/components/ui/field'; | |
| 4 | -import { Input } from '@/components/ui/input'; | |
| 5 | -import { Select } from '@/components/ui/select'; | |
| 6 | -import { | |
| 7 | - ADMIN_MEMBERS_PATH, | |
| 8 | - ADMIN_MEMBER_SEARCH_FIELD_OPTIONS, | |
| 9 | - DEFAULT_ADMIN_MEMBER_SEARCH_FIELD, | |
| 10 | - buildAdminMemberHref, | |
| 11 | - type AdminMemberQuery, | |
| 12 | -} from '@/lib/domain/admin-member-query'; | |
| 13 | - | |
| 14 | -interface AdminMemberSearchBarProps { | |
| 15 | - query: AdminMemberQuery; | |
| 16 | -} | |
| 17 | - | |
| 18 | -/** | |
| 19 | - * 검색 영역(시안 ADM_ADM_101 ①) — 순수 `<form method="get">` GET 제출로 URL을 갱신한다 | |
| 20 | - * (자바스크립트 없이도 동작). 현재 `sort`·`pageSize`는 hidden input으로 함께 제출해 검색 후에도 | |
| 21 | - * 유지되게 하고, 검색 자체는 항상 1페이지로 돌아간다(hidden page 필드를 두지 않아 | |
| 22 | - * `parseAdminMemberQuery`의 기본값 1로 자연스럽게 떨어진다). "초기화"는 폼 제출이 아니라 | |
| 23 | - * 기본값으로 되돌린 링크라 현재 입력값과 무관하게 동작한다. 상호작용이 전부 네이티브 폼 | |
| 24 | - * 제출/링크 내비게이션이라 클라이언트 자바스크립트가 필요 없다 — Server Component로 유지한다. | |
| 25 | - * | |
| 26 | - * 검색 대상이 시안의 셋이 아니라 둘인 이유는 `admin-member-query.ts` 주석에 적어 두었다. | |
| 27 | - */ | |
| 28 | -export function AdminMemberSearchBar({ query }: AdminMemberSearchBarProps) { | |
| 29 | - const resetHref = buildAdminMemberHref(query, { | |
| 30 | - searchField: DEFAULT_ADMIN_MEMBER_SEARCH_FIELD, | |
| 31 | - keyword: '', | |
| 32 | - page: 1, | |
| 33 | - }); | |
| 34 | - | |
| 35 | - return ( | |
| 36 | - <form | |
| 37 | - method="get" | |
| 38 | - action={ADMIN_MEMBERS_PATH} | |
| 39 | - className="flex flex-wrap items-end gap-3" | |
| 40 | - > | |
| 41 | - <input type="hidden" name="sort" value={query.sort} /> | |
| 42 | - <input type="hidden" name="pageSize" value={query.pageSize} /> | |
| 43 | - | |
| 44 | - <Field label="검색 대상"> | |
| 45 | - <Select name="searchField" defaultValue={query.searchField}> | |
| 46 | - {ADMIN_MEMBER_SEARCH_FIELD_OPTIONS.map((option) => ( | |
| 47 | - <option key={option.value} value={option.value}> | |
| 48 | - {option.label} | |
| 49 | - </option> | |
| 50 | - ))} | |
| 51 | - </Select> | |
| 52 | - </Field> | |
| 53 | - | |
| 54 | - <Field label="검색어"> | |
| 55 | - <Input | |
| 56 | - type="text" | |
| 57 | - name="keyword" | |
| 58 | - defaultValue={query.keyword} | |
| 59 | - placeholder="검색어를 입력하세요." | |
| 60 | - /> | |
| 61 | - </Field> | |
| 62 | - | |
| 63 | - <Button type="submit" variant="secondary"> | |
| 64 | - 검색 | |
| 65 | - </Button> | |
| 66 | - <Link | |
| 67 | - href={resetHref} | |
| 68 | - className="text-body-md text-foreground-muted underline-offset-2 hover:underline" | |
| 69 | - > | |
| 70 | - 초기화 | |
| 71 | - </Link> | |
| 72 | - </form> | |
| 73 | - ); | |
| 74 | -} |
--- app/(protected)/(basic)/admins/_components/admin-member-table.tsx
... | ... | @@ -1,90 +0,0 @@ |
| 1 | -import { | |
| 2 | - Table, | |
| 3 | - TableBody, | |
| 4 | - TableCell, | |
| 5 | - TableHead, | |
| 6 | - TableHeaderCell, | |
| 7 | - TableRow, | |
| 8 | -} from '@/components/ui/table'; | |
| 9 | -import { | |
| 10 | - formatAdminRoleLabel, | |
| 11 | - formatOptionalValue, | |
| 12 | - type AdminMember, | |
| 13 | -} from '@/lib/domain/admin-member'; | |
| 14 | -import { AdminMemberRowActions } from './admin-member-row-actions'; | |
| 15 | - | |
| 16 | -interface AdminMemberTableProps { | |
| 17 | - items: AdminMember[]; | |
| 18 | - page: number; | |
| 19 | - pageSize: number; | |
| 20 | - totalCount: number; | |
| 21 | - /** 현재 로그인한 관리자의 id — 본인 행의 삭제 버튼을 막는 데 쓴다. */ | |
| 22 | - currentAdminId: string; | |
| 23 | -} | |
| 24 | - | |
| 25 | -const COLUMN_HEADERS = [ | |
| 26 | - '번호', | |
| 27 | - '이름', | |
| 28 | - '아이디', | |
| 29 | - '휴대전화번호', | |
| 30 | - '이메일', | |
| 31 | - '역할', | |
| 32 | - '생성일', | |
| 33 | - '관리', | |
| 34 | -]; | |
| 35 | - | |
| 36 | -/** | |
| 37 | - * 관리자 회원 목록 테이블(시안 ADM_ADM_101 ④). 개인정보 마스킹은 시안에 "마스킹 처리 없음"으로 | |
| 38 | - * 명시돼 있어 원본 값을 그대로 노출한다. | |
| 39 | - * | |
| 40 | - * **"번호"는 저장된 값이 아니라 전체 건수에서 거꾸로 세는 표시 순번이다** — 시안이 6·5·4처럼 | |
| 41 | - * 내림차순으로 표기하고, 목록의 기본 정렬이 생성일 최신순이라 "가장 최근에 만들어진 계정이 가장 | |
| 42 | - * 큰 번호"가 된다. 이 계산은 정확한 전체 건수를 전제하는데, Repository가 전체를 받아 세기 때문에 | |
| 43 | - * 그 전제가 성립한다(백엔드 `totalCount`는 신뢰할 수 없다 — Repository 주석 참조). | |
| 44 | - * | |
| 45 | - * 백엔드가 아직 주지 않는 항목(휴대전화번호·이메일·생성일)은 열을 그대로 유지한 채 `-`로 표시한다 | |
| 46 | - * — 백엔드가 필드를 추가하면 Repository 매핑만 늘리면 이 파일은 그대로 값이 채워진다. | |
| 47 | - * | |
| 48 | - * "관리" 열은 행별 수정/삭제 트리거(AdminMemberRowActions)에 위임한다 — 상호작용이 필요한 것은 | |
| 49 | - * 그 셀뿐이라 이 테이블 자체는 Server Component로 유지하고 최말단만 클라이언트 경계로 뗀다. | |
| 50 | - */ | |
| 51 | -export function AdminMemberTable({ | |
| 52 | - items, | |
| 53 | - page, | |
| 54 | - pageSize, | |
| 55 | - totalCount, | |
| 56 | - currentAdminId, | |
| 57 | -}: AdminMemberTableProps) { | |
| 58 | - const offset = (page - 1) * pageSize; | |
| 59 | - | |
| 60 | - return ( | |
| 61 | - <Table> | |
| 62 | - <TableHead> | |
| 63 | - <TableRow> | |
| 64 | - {COLUMN_HEADERS.map((header) => ( | |
| 65 | - <TableHeaderCell key={header}>{header}</TableHeaderCell> | |
| 66 | - ))} | |
| 67 | - </TableRow> | |
| 68 | - </TableHead> | |
| 69 | - <TableBody> | |
| 70 | - {items.map((member, index) => ( | |
| 71 | - <TableRow key={member.id}> | |
| 72 | - <TableCell>{totalCount - offset - index}</TableCell> | |
| 73 | - <TableCell>{member.name}</TableCell> | |
| 74 | - <TableCell>{member.loginId}</TableCell> | |
| 75 | - <TableCell>{formatOptionalValue(member.phoneNumber)}</TableCell> | |
| 76 | - <TableCell>{formatOptionalValue(member.email)}</TableCell> | |
| 77 | - <TableCell>{formatAdminRoleLabel(member.roleCode)}</TableCell> | |
| 78 | - <TableCell>{formatOptionalValue(member.createdAt)}</TableCell> | |
| 79 | - <TableCell> | |
| 80 | - <AdminMemberRowActions | |
| 81 | - member={member} | |
| 82 | - isSelf={member.id === currentAdminId} | |
| 83 | - /> | |
| 84 | - </TableCell> | |
| 85 | - </TableRow> | |
| 86 | - ))} | |
| 87 | - </TableBody> | |
| 88 | - </Table> | |
| 89 | - ); | |
| 90 | -} |
+++ app/(protected)/(basic)/admins/excel/route.ts
... | ... | @@ -0,0 +1,67 @@ |
| 1 | +import { getSessionAccessToken, verifySession } from '@/lib/auth/dal'; | |
| 2 | +import { backendFetchStream } from '@/lib/http/backend-fetch'; | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * 관리자 회원 목록 엑셀 다운로드 — 백엔드가 만든 xlsx를 브라우저로 중계한다. | |
| 6 | + * | |
| 7 | + * 라우트 핸들러인 이유는 학생 목록(`students/excel/route.ts`)과 같다 — 브라우저는 백엔드를 직접 | |
| 8 | + * 호출하지 않고, 백엔드 엑셀 API가 요구하는 ROLE_ADMIN 토큰은 httpOnly 세션 안에만 있다. 본문은 | |
| 9 | + * 파싱하지 않고 업스트림 스트림을 그대로 흘려보낸다. | |
| 10 | + * | |
| 11 | + * 파일에 담기는 열은 백엔드가 정한다 — 번호·이름·아이디·휴대폰번호·이메일·생성일 | |
| 12 | + * (`MngrAdminApiController.downloadExcel`). 화면의 "역할" 열은 파일에 없다. | |
| 13 | + */ | |
| 14 | + | |
| 15 | +const ADMIN_MEMBER_EXCEL_PATH = '/api/v1/mngr/admin/excel/download'; | |
| 16 | + | |
| 17 | +/** | |
| 18 | + * 파일은 화면의 검색·페이징과 무관하게 **항상 전체**를 담는다(학생 목록과 같은 사양). | |
| 19 | + * | |
| 20 | + * 백엔드 엑셀 API가 목록과 같은 `selectPagination`을 그대로 쓰므로 페이징이 걸린다 — 파라미터를 | |
| 21 | + * 생략하면 기본값(`recordCountPerPage=10`)이 적용돼 10건만 담긴 파일이 나온다. "전체" 모드가 따로 | |
| 22 | + * 없어 충분히 큰 상한을 명시해 1페이지로 전부 받는다. 이 상한을 넘으면 파일이 조용히 잘린다. | |
| 23 | + */ | |
| 24 | +const EXCEL_ROW_LIMIT = 100_000; | |
| 25 | + | |
| 26 | +/** 전체 행을 모아 워크북을 만드는 시간이 있어 일반 조회보다 넉넉히 잡는다. */ | |
| 27 | +const EXCEL_TIMEOUT_MS = 60_000; | |
| 28 | + | |
| 29 | +const DOWNLOAD_FAILED_MESSAGE = | |
| 30 | + '엑셀 파일을 내려받지 못했습니다. 잠시 후 다시 시도해 주세요.'; | |
| 31 | + | |
| 32 | +const XLSX_CONTENT_TYPE = | |
| 33 | + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; | |
| 34 | + | |
| 35 | +export async function GET() { | |
| 36 | + // 라우트 핸들러는 UI를 거치지 않고 직접 호출될 수 있으므로 여기서 직접 인증을 확인한다 | |
| 37 | + // (proxy의 쿠키 존재 확인은 낙관적 필터일 뿐이다 — 설계서 §8). | |
| 38 | + await verifySession(); | |
| 39 | + | |
| 40 | + const accessToken = await getSessionAccessToken(); | |
| 41 | + | |
| 42 | + const result = await backendFetchStream(ADMIN_MEMBER_EXCEL_PATH, { | |
| 43 | + query: { pageIndex: 1, recordCountPerPage: EXCEL_ROW_LIMIT }, | |
| 44 | + accessToken: accessToken ?? undefined, | |
| 45 | + timeoutMs: EXCEL_TIMEOUT_MS, | |
| 46 | + }); | |
| 47 | + | |
| 48 | + if (!result.ok) { | |
| 49 | + // 실패 사유(코드·백엔드 메시지)는 backendFetchStream이 서버 콘솔에 남긴다. | |
| 50 | + return new Response(DOWNLOAD_FAILED_MESSAGE, { | |
| 51 | + status: 502, | |
| 52 | + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, | |
| 53 | + }); | |
| 54 | + } | |
| 55 | + | |
| 56 | + const upstream = result.data; | |
| 57 | + | |
| 58 | + return new Response(upstream.body, { | |
| 59 | + status: 200, | |
| 60 | + headers: { | |
| 61 | + 'Content-Type': upstream.headers.get('content-type') ?? XLSX_CONTENT_TYPE, | |
| 62 | + 'Content-Disposition': | |
| 63 | + upstream.headers.get('content-disposition') ?? | |
| 64 | + 'attachment; filename="admins.xlsx"', | |
| 65 | + }, | |
| 66 | + }); | |
| 67 | +} |
--- app/(protected)/(basic)/admins/page.tsx
+++ app/(protected)/(basic)/admins/page.tsx
... | ... | @@ -1,16 +1,8 @@ |
| 1 | 1 |
import type { Metadata } from 'next';
|
| 2 |
-import Link from 'next/link'; |
|
| 3 |
-import { Alert } from '@/components/ui/alert';
|
|
| 4 |
-import { Pagination } from '@/components/ui/pagination';
|
|
| 5 | 2 |
import { verifySession } from '@/lib/auth/dal';
|
| 6 | 3 |
import { fetchAdminMembers } from '@/lib/data/repositories/admin-member-repository';
|
| 7 |
-import {
|
|
| 8 |
- buildAdminMemberHref, |
|
| 9 |
- parseAdminMemberQuery, |
|
| 10 |
-} from '@/lib/domain/admin-member-query'; |
|
| 11 |
-import { AdminMemberListToolbar } from './_components/admin-member-list-toolbar';
|
|
| 12 |
-import { AdminMemberSearchBar } from './_components/admin-member-search-bar';
|
|
| 13 |
-import { AdminMemberTable } from './_components/admin-member-table';
|
|
| 4 |
+import { parseAdminMemberQuery } from '@/lib/domain/admin-member-query';
|
|
| 5 |
+import { AdminMemberList } from './_components/admin-member-list';
|
|
| 14 | 6 |
|
| 15 | 7 |
export const metadata: Metadata = {
|
| 16 | 8 |
title: '관리자 회원', |
... | ... | @@ -37,51 +29,17 @@ |
| 37 | 29 |
|
| 38 | 30 |
const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize)); |
| 39 | 31 |
// 요청 페이지가 범위를 벗어나면(예: 삭제로 마지막 페이지가 사라짐) 마지막 페이지로 맞춘다 — |
| 40 |
- // 표의 순번 계산도 이 값을 기준으로 해야 헤더의 "현재페이지"와 어긋나지 않는다. |
|
| 32 |
+ // 표의 순번 계산도 이 값을 기준으로 해야 페이지네이션의 현재 위치와 어긋나지 않는다. |
|
| 41 | 33 |
const currentPage = Math.min(query.page, totalPages); |
| 42 | 34 |
|
| 43 | 35 |
return ( |
| 44 |
- <section className="flex flex-1 flex-col gap-6 p-8"> |
|
| 45 |
- <h1 className="text-headline-sm text-foreground">관리자 회원 목록</h1> |
|
| 46 |
- |
|
| 47 |
- <AdminMemberSearchBar query={query} />
|
|
| 48 |
- |
|
| 49 |
- <AdminMemberListToolbar query={query} />
|
|
| 50 |
- |
|
| 51 |
- <p className="text-body-md text-foreground-muted"> |
|
| 52 |
- 총 {totalCount}명 | 현재페이지 {currentPage}/{totalPages}
|
|
| 53 |
- </p> |
|
| 54 |
- |
|
| 55 |
- {items.length === 0 ? (
|
|
| 56 |
- <Alert |
|
| 57 |
- title="검색 결과가 없습니다." |
|
| 58 |
- actions={
|
|
| 59 |
- <Link |
|
| 60 |
- href={buildAdminMemberHref(query, { keyword: '', page: 1 })}
|
|
| 61 |
- className="bg-foreground px-4 py-2 text-background" |
|
| 62 |
- > |
|
| 63 |
- 확인 |
|
| 64 |
- </Link> |
|
| 65 |
- } |
|
| 66 |
- > |
|
| 67 |
- 다른 검색어로 다시 시도해 주세요. |
|
| 68 |
- </Alert> |
|
| 69 |
- ) : ( |
|
| 70 |
- <> |
|
| 71 |
- <AdminMemberTable |
|
| 72 |
- items={items}
|
|
| 73 |
- page={currentPage}
|
|
| 74 |
- pageSize={query.pageSize}
|
|
| 75 |
- totalCount={totalCount}
|
|
| 76 |
- currentAdminId={admin.id}
|
|
| 77 |
- /> |
|
| 78 |
- <Pagination |
|
| 79 |
- currentPage={currentPage}
|
|
| 80 |
- totalPages={totalPages}
|
|
| 81 |
- buildHref={(page: number) => buildAdminMemberHref(query, { page })}
|
|
| 82 |
- /> |
|
| 83 |
- </> |
|
| 84 |
- )} |
|
| 85 |
- </section> |
|
| 36 |
+ <AdminMemberList |
|
| 37 |
+ items={items}
|
|
| 38 |
+ query={query}
|
|
| 39 |
+ currentPage={currentPage}
|
|
| 40 |
+ totalPages={totalPages}
|
|
| 41 |
+ totalCount={totalCount}
|
|
| 42 |
+ currentAdminId={admin.id}
|
|
| 43 |
+ /> |
|
| 86 | 44 |
); |
| 87 | 45 |
} |
--- app/(protected)/(basic)/decoration-items/_actions.ts
+++ app/(protected)/(basic)/decoration-items/_actions.ts
... | ... | @@ -2,12 +2,14 @@ |
| 2 | 2 |
|
| 3 | 3 |
import { revalidatePath } from 'next/cache';
|
| 4 | 4 |
import { verifySession } from '@/lib/auth/dal';
|
| 5 |
+import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository';
|
|
| 5 | 6 |
import {
|
| 6 | 7 |
createDecorationItem, |
| 7 | 8 |
deleteDecorationItem, |
| 8 | 9 |
updateDecorationItem, |
| 9 | 10 |
uploadDecorationItemImage, |
| 10 | 11 |
} from '@/lib/data/repositories/decoration-item-repository'; |
| 12 |
+import { CODE_GROUP } from '@/lib/domain/common-code';
|
|
| 11 | 13 |
import type { DecorationItemType } from '@/lib/domain/decoration-item';
|
| 12 | 14 |
import {
|
| 13 | 15 |
validateDecorationItemCreate, |
... | ... | @@ -24,7 +26,8 @@ |
| 24 | 26 |
* POST될 수 있어 이 확인이 유일한 최종 방어선이다(설계서 §3 SRP 체크). |
| 25 | 27 |
* |
| 26 | 28 |
* 검증은 화면이 아니라 여기서 확정한다(`lib/domain/decoration-item-form.ts`의 규칙을 호출) — |
| 27 |
- * 화면의 required 속성·기본값 채움은 편의일 뿐 신뢰 경계가 아니다. |
|
| 29 |
+ * 화면의 required 속성·기본값 채움은 편의일 뿐 신뢰 경계가 아니다. **허용 카테고리도 여기서 |
|
| 30 |
+ * 다시 조회해 넘긴다** — 화면이 보낸 코드를 그대로 믿으면 코드테이블에 없는 값이 저장된다. |
|
| 28 | 31 |
* |
| 29 | 32 |
* **이미지 업로드도 이 계층을 지난다** — 브라우저는 백엔드를 직접 호출하지 않으므로(토큰이 |
| 30 | 33 |
* httpOnly 세션 안에만 있다) 폼이 실어 보낸 File을 여기서 받아 Repository를 통해 백엔드에 |
... | ... | @@ -119,7 +122,8 @@ |
| 119 | 122 |
readEditableValues( |
| 120 | 123 |
formData, |
| 121 | 124 |
newFile ? PENDING_UPLOAD_PLACEHOLDER : currentImageFileId |
| 122 |
- ) |
|
| 125 |
+ ), |
|
| 126 |
+ await fetchCommonCodes(CODE_GROUP.decorationItemCategory) |
|
| 123 | 127 |
); |
| 124 | 128 |
if (!validation.ok) {
|
| 125 | 129 |
return { status: 'error', errors: validation.errors };
|
... | ... | @@ -162,7 +166,8 @@ |
| 162 | 166 |
readEditableValues( |
| 163 | 167 |
formData, |
| 164 | 168 |
newFile ? PENDING_UPLOAD_PLACEHOLDER : currentImageFileId |
| 165 |
- ) |
|
| 169 |
+ ), |
|
| 170 |
+ await fetchCommonCodes(CODE_GROUP.decorationItemCategory) |
|
| 166 | 171 |
); |
| 167 | 172 |
if (!validation.ok) {
|
| 168 | 173 |
return { status: 'error', errors: validation.errors };
|
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-create-modal.tsx
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-create-modal.tsx
... | ... | @@ -1,11 +1,12 @@ |
| 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';
|
|
| 3 |
+import { useActionState, useEffect, useRef } from 'react';
|
|
| 4 |
+import { FoxButton } from '@fox/core/components/fox-button';
|
|
| 5 |
+import { FoxHelperText } from '@fox/core/components/fox-helper-text';
|
|
| 6 |
+import { FoxInput } from '@fox/core/components/fox-input';
|
|
| 7 |
+import { FoxModal } from '@fox/core/components/fox-modal';
|
|
| 8 | 8 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 9 |
+import type { CommonCode } from '@/lib/domain/common-code';
|
|
| 9 | 10 |
import type { DecorationItemType } from '@/lib/domain/decoration-item';
|
| 10 | 11 |
import { INITIAL_DECORATION_ITEM_FORM_STATE } from '@/lib/domain/decoration-item-form';
|
| 11 | 12 |
import { createDecorationItemAction } from '../_actions';
|
... | ... | @@ -15,27 +16,30 @@ |
| 15 | 16 |
/** 등록 버튼을 누른 시점의 활성 탭(유형) — 폼의 유형 기본값으로 쓴다. */ |
| 16 | 17 |
defaultItemType: DecorationItemType; |
| 17 | 18 |
typeTotalCount: number; |
| 19 |
+ /** 공통코드 `ITEM_CATE_CD` — 카테고리 선택지. */ |
|
| 20 |
+ categories: CommonCode[]; |
|
| 18 | 21 |
onClose: () => void; |
| 19 | 22 |
} |
| 20 | 23 |
|
| 21 |
-const FORM_ID = 'decoration-item-create-form'; |
|
| 22 |
- |
|
| 23 | 24 |
/** |
| 24 |
- * 꾸미기 아이템 등록 팝업(시안 ADM_ITM_102_p). |
|
| 25 |
+ * 꾸미기 아이템 등록 팝업 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) ADM_ITM_102_p (5227:4629) |
|
| 25 | 26 |
* |
| 26 |
- * 아이템ID는 입력 항목이 아니다 — 시안대로 "등록 후 자동발급됩니다."를 읽기 전용으로 보여주고, |
|
| 27 |
- * 실제 값은 백엔드가 `itemSn`(자동증가)으로 채운다. |
|
| 27 |
+ * **아이템ID는 입력받지 않는다.** 시안은 직접 입력하는 칸으로 그리지만 백엔드 `TB_COM_ITEM`에 |
|
| 28 |
+ * 그런 컬럼이 없고 쓰기 API도 그 값을 받지 않는다 — 자동증가 PK인 `itemSn`이 목록의 아이템ID로 |
|
| 29 |
+ * 그대로 노출되는 값이라, 등록 시점에는 발급 전이므로 읽기 전용 안내로 둔다(백엔드에 컬럼이 |
|
| 30 |
+ * 생기면 이 칸만 입력으로 바꾸면 된다). |
|
| 28 | 31 |
* |
| 29 |
- * 저장 버튼은 footer 슬롯에서 `form={FORM_ID}` 속성으로 폼과 연결한다(admins의 등록 팝업과
|
|
| 30 |
- * 동일한 패턴) — 버튼이 실제 DOM상 form의 자손이 아니어도 같은 문서 안에서 id만 일치하면 그 |
|
| 31 |
- * form을 제출한다. |
|
| 32 |
+ * 등록 버튼은 foot 슬롯에 그려져 폼의 자손이 아니다 — `FoxButton`에 폼을 가리키는 prop이 없어 |
|
| 33 |
+ * ref로 직접 제출한다(학생 회원 조회 팝업과 같은 방식). |
|
| 32 | 34 |
*/ |
| 33 | 35 |
export function DecorationItemCreateModal({
|
| 34 | 36 |
defaultItemType, |
| 35 | 37 |
typeTotalCount, |
| 38 |
+ categories, |
|
| 36 | 39 |
onClose, |
| 37 | 40 |
}: DecorationItemCreateModalProps) {
|
| 38 | 41 |
const { showToast } = useFeedback();
|
| 42 |
+ const formRef = useRef<HTMLFormElement>(null); |
|
| 39 | 43 |
const [state, formAction, isPending] = useActionState( |
| 40 | 44 |
createDecorationItemAction, |
| 41 | 45 |
INITIAL_DECORATION_ITEM_FORM_STATE |
... | ... | @@ -51,44 +55,45 @@ |
| 51 | 55 |
const errors = state.status === 'error' ? (state.errors ?? {}) : {};
|
| 52 | 56 |
|
| 53 | 57 |
return ( |
| 54 |
- <Modal |
|
| 58 |
+ <FoxModal |
|
| 59 |
+ open |
|
| 60 |
+ size="sm" |
|
| 55 | 61 |
title="꾸미기 아이템 등록" |
| 56 | 62 |
onClose={onClose}
|
| 57 |
- footer={
|
|
| 63 |
+ actions={
|
|
| 58 | 64 |
<> |
| 59 |
- <Button type="button" variant="ghost" onClick={onClose}>
|
|
| 60 |
- 취소 |
|
| 61 |
- </Button> |
|
| 62 |
- <Button |
|
| 63 |
- type="submit" |
|
| 64 |
- form={FORM_ID}
|
|
| 65 |
- variant="primary" |
|
| 65 |
+ <FoxButton type="default" size="md" label="취소" onAction={onClose} />
|
|
| 66 |
+ <FoxButton |
|
| 67 |
+ type="primary" |
|
| 68 |
+ size="md" |
|
| 69 |
+ label={isPending ? '등록 중...' : '등록'}
|
|
| 66 | 70 |
disabled={isPending}
|
| 67 |
- > |
|
| 68 |
- {isPending ? '등록 중...' : '등록'}
|
|
| 69 |
- </Button> |
|
| 71 |
+ onAction={() => formRef.current?.requestSubmit()}
|
|
| 72 |
+ /> |
|
| 70 | 73 |
</> |
| 71 | 74 |
} |
| 72 | 75 |
> |
| 73 |
- <form id={FORM_ID} action={formAction} className="flex flex-col gap-4">
|
|
| 74 |
- <p className="text-right text-body-sm text-danger"> |
|
| 75 |
- * 는 필수 항목입니다. |
|
| 76 |
- </p> |
|
| 77 |
- |
|
| 78 |
- <Field label="아이템 ID"> |
|
| 79 |
- <Input type="text" value="등록 후 자동발급됩니다." readOnly /> |
|
| 80 |
- </Field> |
|
| 81 |
- |
|
| 76 |
+ <form ref={formRef} action={formAction}>
|
|
| 82 | 77 |
<DecorationItemFormFields |
| 83 | 78 |
defaultItemType={defaultItemType}
|
| 84 | 79 |
typeTotalCount={typeTotalCount}
|
| 80 |
+ categories={categories}
|
|
| 85 | 81 |
errors={errors}
|
| 82 |
+ itemIdField={
|
|
| 83 |
+ <FoxInput |
|
| 84 |
+ size="md" |
|
| 85 |
+ label="아이템 ID" |
|
| 86 |
+ requirement="required" |
|
| 87 |
+ value="등록 후 자동발급됩니다." |
|
| 88 |
+ readOnly |
|
| 89 |
+ /> |
|
| 90 |
+ } |
|
| 86 | 91 |
/> |
| 87 | 92 |
|
| 88 | 93 |
{state.status === 'error' && state.message && (
|
| 89 |
- <p className="text-body-sm text-danger">{state.message}</p>
|
|
| 94 |
+ <FoxHelperText type="danger" message={state.message} />
|
|
| 90 | 95 |
)} |
| 91 | 96 |
</form> |
| 92 |
- </Modal> |
|
| 97 |
+ </FoxModal> |
|
| 93 | 98 |
); |
| 94 | 99 |
} |
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-edit-modal.tsx
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-edit-modal.tsx
... | ... | @@ -1,11 +1,12 @@ |
| 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';
|
|
| 3 |
+import { useActionState, useEffect, useRef } from 'react';
|
|
| 4 |
+import { FoxButton } from '@fox/core/components/fox-button';
|
|
| 5 |
+import { FoxHelperText } from '@fox/core/components/fox-helper-text';
|
|
| 6 |
+import { FoxInput } from '@fox/core/components/fox-input';
|
|
| 7 |
+import { FoxModal } from '@fox/core/components/fox-modal';
|
|
| 8 | 8 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 9 |
+import type { CommonCode } from '@/lib/domain/common-code';
|
|
| 9 | 10 |
import type { DecorationItem } from '@/lib/domain/decoration-item';
|
| 10 | 11 |
import { INITIAL_DECORATION_ITEM_FORM_STATE } from '@/lib/domain/decoration-item-form';
|
| 11 | 12 |
import { updateDecorationItemAction } from '../_actions';
|
... | ... | @@ -14,26 +15,27 @@ |
| 14 | 15 |
interface DecorationItemEditModalProps {
|
| 15 | 16 |
item: DecorationItem; |
| 16 | 17 |
typeTotalCount: number; |
| 18 |
+ /** 공통코드 `ITEM_CATE_CD` — 카테고리 선택지. */ |
|
| 19 |
+ categories: CommonCode[]; |
|
| 17 | 20 |
onClose: () => void; |
| 18 | 21 |
} |
| 19 | 22 |
|
| 20 |
-const FORM_ID = 'decoration-item-edit-form'; |
|
| 21 |
- |
|
| 22 | 23 |
/** |
| 23 |
- * 꾸미기 아이템 수정 팝업(시안 ADM_ITM_103_p) — 등록과 동일 항목이되 **아이템ID는 읽기 |
|
| 24 |
- * 전용**이다. |
|
| 24 |
+ * 꾸미기 아이템 수정 팝업(시안 ADM_ITM_103_p) — 등록 팝업(5227:4629)과 같은 구성이되 |
|
| 25 |
+ * **아이템ID는 읽기 전용**이다. |
|
| 25 | 26 |
* |
| 26 |
- * readOnly로 보여주는 아이템ID `Input`에는 `name`을 주지 않아 제출 대상에서 아예 빠지게 하고, |
|
| 27 |
- * 실제 수정 대상은 별도 hidden input(`itemSn`)으로 넘긴다 — Server Action도 hidden 값만 |
|
| 28 |
- * 읽으므로 readOnly 필드를 위조해서 보내도 수정 대상이 바뀌지 않는다(admins의 이름/ID readOnly |
|
| 29 |
- * 처리와 동일한 방어 방식). |
|
| 27 |
+ * 읽기 전용 아이템ID 칸에는 `name`을 주지 않아 제출 대상에서 아예 빠지게 하고, 실제 수정 대상은 |
|
| 28 |
+ * 별도 hidden input(`itemSn`)으로 넘긴다 — Server Action도 hidden 값만 읽으므로 읽기 전용 칸을 |
|
| 29 |
+ * 위조해 보내도 수정 대상이 바뀌지 않는다. |
|
| 30 | 30 |
*/ |
| 31 | 31 |
export function DecorationItemEditModal({
|
| 32 | 32 |
item, |
| 33 | 33 |
typeTotalCount, |
| 34 |
+ categories, |
|
| 34 | 35 |
onClose, |
| 35 | 36 |
}: DecorationItemEditModalProps) {
|
| 36 | 37 |
const { showToast } = useFeedback();
|
| 38 |
+ const formRef = useRef<HTMLFormElement>(null); |
|
| 37 | 39 |
const [state, formAction, isPending] = useActionState( |
| 38 | 40 |
updateDecorationItemAction, |
| 39 | 41 |
INITIAL_DECORATION_ITEM_FORM_STATE |
... | ... | @@ -49,47 +51,48 @@ |
| 49 | 51 |
const errors = state.status === 'error' ? (state.errors ?? {}) : {};
|
| 50 | 52 |
|
| 51 | 53 |
return ( |
| 52 |
- <Modal |
|
| 54 |
+ <FoxModal |
|
| 55 |
+ open |
|
| 56 |
+ size="sm" |
|
| 53 | 57 |
title="꾸미기 아이템 수정" |
| 54 | 58 |
onClose={onClose}
|
| 55 |
- footer={
|
|
| 59 |
+ actions={
|
|
| 56 | 60 |
<> |
| 57 |
- <Button type="button" variant="ghost" onClick={onClose}>
|
|
| 58 |
- 취소 |
|
| 59 |
- </Button> |
|
| 60 |
- <Button |
|
| 61 |
- type="submit" |
|
| 62 |
- form={FORM_ID}
|
|
| 63 |
- variant="primary" |
|
| 61 |
+ <FoxButton type="default" size="md" label="취소" onAction={onClose} />
|
|
| 62 |
+ <FoxButton |
|
| 63 |
+ type="primary" |
|
| 64 |
+ size="md" |
|
| 65 |
+ label={isPending ? '수정 중...' : '수정'}
|
|
| 64 | 66 |
disabled={isPending}
|
| 65 |
- > |
|
| 66 |
- {isPending ? '수정 중...' : '수정'}
|
|
| 67 |
- </Button> |
|
| 67 |
+ onAction={() => formRef.current?.requestSubmit()}
|
|
| 68 |
+ /> |
|
| 68 | 69 |
</> |
| 69 | 70 |
} |
| 70 | 71 |
> |
| 71 |
- <form id={FORM_ID} action={formAction} className="flex flex-col gap-4">
|
|
| 72 |
+ <form ref={formRef} action={formAction}>
|
|
| 72 | 73 |
<input type="hidden" name="itemSn" value={item.itemSn} />
|
| 73 |
- |
|
| 74 |
- <p className="text-right text-body-sm text-danger"> |
|
| 75 |
- * 는 필수 항목입니다. |
|
| 76 |
- </p> |
|
| 77 |
- |
|
| 78 |
- <Field label="아이템 ID"> |
|
| 79 |
- <Input type="text" value={item.itemSn} readOnly />
|
|
| 80 |
- </Field> |
|
| 81 | 74 |
|
| 82 | 75 |
<DecorationItemFormFields |
| 83 | 76 |
item={item}
|
| 84 | 77 |
defaultItemType={item.itemType}
|
| 85 | 78 |
typeTotalCount={typeTotalCount}
|
| 79 |
+ categories={categories}
|
|
| 86 | 80 |
errors={errors}
|
| 81 |
+ itemIdField={
|
|
| 82 |
+ <FoxInput |
|
| 83 |
+ size="md" |
|
| 84 |
+ label="아이템 ID" |
|
| 85 |
+ requirement="required" |
|
| 86 |
+ value={String(item.itemSn)}
|
|
| 87 |
+ readOnly |
|
| 88 |
+ /> |
|
| 89 |
+ } |
|
| 87 | 90 |
/> |
| 88 | 91 |
|
| 89 | 92 |
{state.status === 'error' && state.message && (
|
| 90 |
- <p className="text-body-sm text-danger">{state.message}</p>
|
|
| 93 |
+ <FoxHelperText type="danger" message={state.message} />
|
|
| 91 | 94 |
)} |
| 92 | 95 |
</form> |
| 93 |
- </Modal> |
|
| 96 |
+ </FoxModal> |
|
| 94 | 97 |
); |
| 95 | 98 |
} |
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-form-fields.module.scss
... | ... | @@ -0,0 +1,20 @@ |
| 1 | +// 등록·수정 팝업의 form-list — 시안 ADM_ITM_102_p(5227:4629)의 `item` 한 줄 배치. | |
| 2 | +// 항목마다 위아래 16px 여백과 아래 실선 하나를 갖고, 라벨과 입력 사이는 8px이다. | |
| 3 | +// @fox에 폼 줄 배치 조각이 없어 이 화면 전용으로 둔다(값은 전부 @fox 토큰). | |
| 4 | + | |
| 5 | +@use "@fox/styles/abstracts" as fox; | |
| 6 | + | |
| 7 | +.fields { | |
| 8 | + display: flex; | |
| 9 | + inline-size: 100%; | |
| 10 | + flex-direction: column; | |
| 11 | +} | |
| 12 | + | |
| 13 | +.field { | |
| 14 | + display: flex; | |
| 15 | + inline-size: 100%; | |
| 16 | + flex-direction: column; | |
| 17 | + gap: fox.gap(3); | |
| 18 | + padding-block: fox.padding(6); | |
| 19 | + border-block-end: fox.border(1) solid fox.color(border-neutral-subtler); | |
| 20 | +} |
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-form-fields.tsx
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-form-fields.tsx
... | ... | @@ -1,159 +1,219 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 |
-import { Field } from '@/components/ui/field';
|
|
| 4 |
-import { Input } from '@/components/ui/input';
|
|
| 5 |
-import { RadioGroup } from '@/components/ui/radio-group';
|
|
| 6 |
-import { Select } from '@/components/ui/select';
|
|
| 3 |
+import { useState, type ReactNode } from 'react';
|
|
| 4 |
+import { FoxFileUpload } from '@fox/core/components/fox-file-upload';
|
|
| 5 |
+import { FoxFormLabel } from '@fox/core/components/fox-form-label';
|
|
| 6 |
+import { FoxHelperText } from '@fox/core/components/fox-helper-text';
|
|
| 7 |
+import { FoxInput } from '@fox/core/components/fox-input';
|
|
| 8 |
+import { FoxSelect } from '@fox/core/components/fox-select';
|
|
| 9 |
+import { FoxTab } from '@fox/core/components/fox-tab';
|
|
| 10 |
+import { FoxTabItem } from '@fox/core/components/fox-tab-item';
|
|
| 11 |
+import { FoxToggleSwitch } from '@fox/core/components/fox-toggle-switch';
|
|
| 12 |
+import type { CommonCode } from '@/lib/domain/common-code';
|
|
| 7 | 13 |
import {
|
| 8 |
- DECORATION_ITEM_CATEGORIES, |
|
| 9 | 14 |
DECORATION_ITEM_TYPE_OPTIONS, |
| 10 |
- DEFAULT_DECORATION_ITEM_CATEGORY_CODE, |
|
| 15 |
+ formatDecorationItemActiveLabel, |
|
| 11 | 16 |
type DecorationItem, |
| 12 | 17 |
type DecorationItemType, |
| 13 | 18 |
} from '@/lib/domain/decoration-item'; |
| 14 | 19 |
import type { DecorationItemFormErrors } from '@/lib/domain/decoration-item-form';
|
| 15 |
- |
|
| 16 |
-const ACTIVE_STATUS_OPTIONS = [ |
|
| 17 |
- { value: 'true', label: '사용' },
|
|
| 18 |
- { value: 'false', label: '미사용' },
|
|
| 19 |
-]; |
|
| 20 |
+import styles from './decoration-item-form-fields.module.scss'; |
|
| 20 | 21 |
|
| 21 | 22 |
interface DecorationItemFormFieldsProps {
|
| 22 | 23 |
/** 수정 팝업의 기존 값. 등록 팝업은 넘기지 않는다(빈 폼 + 기본값). */ |
| 23 | 24 |
item?: DecorationItem; |
| 24 | 25 |
/** 등록 팝업을 연 시점의 활성 탭(유형) — 수정 팝업에서는 `item.itemType`이 대신 쓰인다. */ |
| 25 | 26 |
defaultItemType: DecorationItemType; |
| 26 |
- /** "정렬순서" 라벨의 "(총 등록 N개)" 힌트와 등록 시 기본값 계산에 쓰는, 현재 유형의 전체 |
|
| 27 |
- * 등록 건수(검색어 무관). */ |
|
| 27 |
+ /** 현재 유형의 전체 등록 건수(검색어 무관) — 정렬순서의 "/ 총 N개"와 기본값에 쓴다. */ |
|
| 28 | 28 |
typeTotalCount: number; |
| 29 |
+ /** 공통코드 `ITEM_CATE_CD` — 카테고리 선택지. 서버에서 조회해 내려온다. */ |
|
| 30 |
+ categories: CommonCode[]; |
|
| 29 | 31 |
errors: DecorationItemFormErrors; |
| 32 |
+ /** 아이템ID 줄 — 등록은 자동발급 안내, 수정은 발급된 값이라 팝업마다 달라 밖에서 받는다. */ |
|
| 33 |
+ itemIdField: ReactNode; |
|
| 30 | 34 |
} |
| 31 | 35 |
|
| 36 |
+/** 시안의 안내 문구 그대로. */ |
|
| 37 |
+const IMAGE_DESCRIPTION = |
|
| 38 |
+ 'JPG, PNG 파일만 가능 (권장 사이즈: 1280x720px, 최대 5MB)'; |
|
| 39 |
+ |
|
| 32 | 40 |
/** |
| 33 |
- * 등록·수정 팝업이 공유하는 입력 항목 — 유형/아이템명/카테고리/포인트/썸네일 이미지/설명/ |
|
| 34 |
- * 사용여부/정렬순서(시안 ADM_ITM_102_p / 103_p). 아이템ID 표시만 각 팝업이 따로 그린다. |
|
| 41 |
+ * 등록·수정 팝업이 공유하는 입력 항목 — 시안 ADM_ITM_102_p(5227:4629). |
|
| 35 | 42 |
* |
| 36 |
- * **유형(개별/셋트) 전환 시 폼 구성이 바뀔 수 있다는 시안 설명이 있지만, 두 유형의 입력 항목이 |
|
| 37 |
- * 완전히 같아 조건부 렌더링을 두지 않았다** — 유형 값 자체만 폼에 실어 저장한다. |
|
| 43 |
+ * 유형은 라디오가 아니라 **탭**이다(시안). 목록의 유형 탭과 달리 이건 화면 이동이 아니라 폼의 |
|
| 44 |
+ * 값이라 상태를 여기서 들고, 저장에는 hidden 필드로 실어 보낸다 — `FoxTab`은 폼 요소가 아니다. |
|
| 45 |
+ * 카테고리(`FoxSelect`)도 네이티브 `<select>`가 아니라 버튼+리스트박스라 같은 방식이다. |
|
| 38 | 46 |
* |
| 39 |
- * **이미지는 파일 하나만 받는다** — 시안의 "썸네일, 아이템 이미지 각각 등록? → NO"에 따른 것이고, |
|
| 40 |
- * 저장은 백엔드의 `atchFileId`에만 한다(썸네일은 추후 백엔드가 자동생성할 예정, 사용자 확정 사항). |
|
| 41 |
- * 수정 팝업에서 파일을 새로 고르지 않으면 hidden 필드로 유지한 기존 파일 ID가 그대로 저장된다. |
|
| 47 |
+ * **두 유형의 입력 항목은 완전히 같다** — 시안이 "셋트아이템도 동일한 항목으로 처리"라고 |
|
| 48 |
+ * 명시해 탭은 저장될 유형 값만 바꾼다. |
|
| 42 | 49 |
* |
| 43 |
- * 설명은 여러 줄 입력이 자연스럽지만 공용 textarea 컴포넌트가 없어 로직 우선 단계에서는 단일행 |
|
| 44 |
- * `Input`으로 둔다(신설은 design 레인 소관). |
|
| 50 |
+ * 썸네일은 파일 하나만 받는다. `FoxFileUpload`의 네이티브 입력에 `name`을 주면 고른 파일이 |
|
| 51 |
+ * 폼 제출에 그대로 실리므로 파일을 따로 들고 있지 않는다. 수정 팝업에서 새로 고르지 않으면 |
|
| 52 |
+ * hidden 필드의 기존 파일 ID가 그대로 저장된다. |
|
| 45 | 53 |
*/ |
| 46 | 54 |
export function DecorationItemFormFields({
|
| 47 | 55 |
item, |
| 48 | 56 |
defaultItemType, |
| 49 | 57 |
typeTotalCount, |
| 58 |
+ categories, |
|
| 50 | 59 |
errors, |
| 60 |
+ itemIdField, |
|
| 51 | 61 |
}: DecorationItemFormFieldsProps) {
|
| 62 |
+ const [itemType, setItemType] = useState<DecorationItemType>( |
|
| 63 |
+ item?.itemType ?? defaultItemType |
|
| 64 |
+ ); |
|
| 65 |
+ // 수정 팝업은 기존 값, 등록 팝업은 첫 코드가 기본이다. 코드가 하나도 없으면 빈 값이고, |
|
| 66 |
+ // 그대로 저장하면 Server Action의 검증이 "카테고리를 선택해 주세요."로 잡는다. |
|
| 67 |
+ const [categoryCode, setCategoryCode] = useState( |
|
| 68 |
+ item?.categoryCode || categories[0]?.code || '' |
|
| 69 |
+ ); |
|
| 70 |
+ const [isActive, setIsActive] = useState(item?.isActive ?? true); |
|
| 71 |
+ // 수정 팝업에서 파일을 새로 고르지 않았을 때 유지할 기존 이미지. 썸네일을 지우면 함께 비워 |
|
| 72 |
+ // 검증이 "썸네일 이미지를 등록해 주세요."로 걸리게 한다 — 비우지 않으면 지운 것처럼 보이는데 |
|
| 73 |
+ // 저장은 예전 이미지를 그대로 남긴다. |
|
| 74 |
+ const [retainedImageFileId, setRetainedImageFileId] = useState( |
|
| 75 |
+ item?.imageFileId ?? '' |
|
| 76 |
+ ); |
|
| 77 |
+ |
|
| 52 | 78 |
return ( |
| 53 | 79 |
<> |
| 54 |
- <Field label="유형 *"> |
|
| 55 |
- <RadioGroup |
|
| 56 |
- name="itemType" |
|
| 57 |
- // RadioGroup의 options는 mutable 배열을 요구해 도메인의 ReadonlyArray를 그대로 넘길 |
|
| 58 |
- // 수 없다(components/ui는 design 레인 소관이라 시그니처를 바꾸지 않는다) — 얕은 |
|
| 59 |
- // 복사로 새 mutable 배열을 만들어 넘긴다. |
|
| 60 |
- options={[...DECORATION_ITEM_TYPE_OPTIONS]}
|
|
| 61 |
- defaultValue={item?.itemType ?? defaultItemType}
|
|
| 62 |
- /> |
|
| 63 |
- </Field> |
|
| 64 |
- <FieldError message={errors.itemType} />
|
|
| 80 |
+ <FoxTab |
|
| 81 |
+ type="line" |
|
| 82 |
+ size="lg" |
|
| 83 |
+ label="아이템 유형" |
|
| 84 |
+ value={itemType}
|
|
| 85 |
+ onValueChange={(value) => setItemType(value as DecorationItemType)}
|
|
| 86 |
+ > |
|
| 87 |
+ {DECORATION_ITEM_TYPE_OPTIONS.map((option) => (
|
|
| 88 |
+ <FoxTabItem key={option.value} value={option.value}>
|
|
| 89 |
+ {option.label}
|
|
| 90 |
+ </FoxTabItem> |
|
| 91 |
+ ))} |
|
| 92 |
+ </FoxTab> |
|
| 93 |
+ <input type="hidden" name="itemType" value={itemType} />
|
|
| 65 | 94 |
|
| 66 |
- <Field label="아이템명 *"> |
|
| 67 |
- <Input |
|
| 68 |
- type="text" |
|
| 69 |
- name="name" |
|
| 70 |
- defaultValue={item?.name ?? ''}
|
|
| 71 |
- placeholder="아이템명을 입력하세요." |
|
| 72 |
- /> |
|
| 73 |
- </Field> |
|
| 74 |
- <FieldError message={errors.name} />
|
|
| 95 |
+ <div className={styles.fields}>
|
|
| 96 |
+ <div className={styles.field}>{itemIdField}</div>
|
|
| 75 | 97 |
|
| 76 |
- <Field label="카테고리 *"> |
|
| 77 |
- <Select |
|
| 78 |
- name="categoryCode" |
|
| 79 |
- defaultValue={
|
|
| 80 |
- item?.categoryCode ?? DEFAULT_DECORATION_ITEM_CATEGORY_CODE |
|
| 81 |
- } |
|
| 82 |
- > |
|
| 83 |
- {DECORATION_ITEM_CATEGORIES.map((category) => (
|
|
| 84 |
- <option key={category.code} value={category.code}>
|
|
| 85 |
- {category.label}
|
|
| 86 |
- </option> |
|
| 87 |
- ))} |
|
| 88 |
- </Select> |
|
| 89 |
- </Field> |
|
| 90 |
- <FieldError message={errors.categoryCode} />
|
|
| 98 |
+ <div className={styles.field}>
|
|
| 99 |
+ <FoxInput |
|
| 100 |
+ size="md" |
|
| 101 |
+ name="name" |
|
| 102 |
+ label="아이템명" |
|
| 103 |
+ requirement="required" |
|
| 104 |
+ defaultValue={item?.name ?? ''}
|
|
| 105 |
+ placeholder="아이템명을 입력하세요." |
|
| 106 |
+ invalid={Boolean(errors.name)}
|
|
| 107 |
+ message={errors.name}
|
|
| 108 |
+ maxLength={100}
|
|
| 109 |
+ /> |
|
| 110 |
+ </div> |
|
| 91 | 111 |
|
| 92 |
- <Field label="포인트(오픈 가능한 포인트) *"> |
|
| 93 |
- <Input |
|
| 94 |
- type="number" |
|
| 95 |
- name="points" |
|
| 96 |
- min={0}
|
|
| 97 |
- step={1}
|
|
| 98 |
- defaultValue={item?.points}
|
|
| 99 |
- placeholder="오픈 가능한 포인트" |
|
| 100 |
- /> |
|
| 101 |
- </Field> |
|
| 102 |
- <FieldError message={errors.points} />
|
|
| 112 |
+ <div className={styles.field}>
|
|
| 113 |
+ <FoxSelect |
|
| 114 |
+ size="md" |
|
| 115 |
+ label="카테고리" |
|
| 116 |
+ options={categories.map((category) => ({
|
|
| 117 |
+ value: category.code, |
|
| 118 |
+ label: category.label, |
|
| 119 |
+ }))} |
|
| 120 |
+ placeholder="카테고리를 선택하세요." |
|
| 121 |
+ value={categoryCode}
|
|
| 122 |
+ onValueChange={setCategoryCode}
|
|
| 123 |
+ error={Boolean(errors.categoryCode)}
|
|
| 124 |
+ hint={errors.categoryCode}
|
|
| 125 |
+ /> |
|
| 126 |
+ <input type="hidden" name="categoryCode" value={categoryCode} />
|
|
| 127 |
+ </div> |
|
| 103 | 128 |
|
| 104 |
- <Field label="썸네일 이미지 *"> |
|
| 105 |
- <Input type="file" name="imageFile" accept="image/*" /> |
|
| 106 |
- </Field> |
|
| 107 |
- {/* 수정 팝업에서 파일을 새로 고르지 않았을 때 기존 이미지를 유지하는 값. 등록 팝업에서는
|
|
| 108 |
- 빈 문자열이라 파일을 고르지 않으면 검증에서 걸린다. */} |
|
| 109 |
- <input |
|
| 110 |
- type="hidden" |
|
| 111 |
- name="imageFileId" |
|
| 112 |
- defaultValue={item?.imageFileId ?? ''}
|
|
| 113 |
- /> |
|
| 114 |
- {item?.imageFileId && (
|
|
| 115 |
- <p className="text-body-sm text-foreground-muted"> |
|
| 116 |
- 현재 등록된 이미지가 있습니다. 새로 선택하면 교체됩니다. |
|
| 117 |
- </p> |
|
| 118 |
- )} |
|
| 119 |
- <FieldError message={errors.imageFileId} />
|
|
| 129 |
+ <div className={styles.field}>
|
|
| 130 |
+ <FoxInput |
|
| 131 |
+ size="md" |
|
| 132 |
+ type="number" |
|
| 133 |
+ name="points" |
|
| 134 |
+ label="포인트(오픈 가능한 포인트)" |
|
| 135 |
+ requirement="required" |
|
| 136 |
+ min={0}
|
|
| 137 |
+ step={1}
|
|
| 138 |
+ defaultValue={item?.points}
|
|
| 139 |
+ placeholder="숫자를 입력하세요." |
|
| 140 |
+ invalid={Boolean(errors.points)}
|
|
| 141 |
+ message={errors.points}
|
|
| 142 |
+ /> |
|
| 143 |
+ </div> |
|
| 120 | 144 |
|
| 121 |
- <Field label="설명"> |
|
| 122 |
- <Input |
|
| 123 |
- type="text" |
|
| 124 |
- name="description" |
|
| 125 |
- defaultValue={item?.description ?? ''}
|
|
| 126 |
- placeholder="설명을 입력하세요." |
|
| 127 |
- /> |
|
| 128 |
- </Field> |
|
| 129 |
- <FieldError message={errors.description} />
|
|
| 145 |
+ <div className={styles.field}>
|
|
| 146 |
+ <FoxFileUpload |
|
| 147 |
+ mode="image" |
|
| 148 |
+ name="imageFile" |
|
| 149 |
+ label="썸네일 이미지" |
|
| 150 |
+ requirement="required" |
|
| 151 |
+ accept="image/jpeg,image/png" |
|
| 152 |
+ selectLabel="파일선택" |
|
| 153 |
+ description={IMAGE_DESCRIPTION}
|
|
| 154 |
+ thumbnailWidth={120}
|
|
| 155 |
+ thumbnailHeight={120}
|
|
| 156 |
+ defaultFiles={
|
|
| 157 |
+ // 수정 팝업은 이미 등록된 이미지를 썸네일 자리에 먼저 보여준다. |
|
| 158 |
+ item?.imageUrl |
|
| 159 |
+ ? [ |
|
| 160 |
+ {
|
|
| 161 |
+ id: item.imageFileId ?? 'current', |
|
| 162 |
+ name: item.name, |
|
| 163 |
+ previewUrl: item.imageUrl, |
|
| 164 |
+ }, |
|
| 165 |
+ ] |
|
| 166 |
+ : undefined |
|
| 167 |
+ } |
|
| 168 |
+ onRemove={() => setRetainedImageFileId('')}
|
|
| 169 |
+ /> |
|
| 170 |
+ <input type="hidden" name="imageFileId" value={retainedImageFileId} />
|
|
| 171 |
+ {errors.imageFileId && (
|
|
| 172 |
+ <FoxHelperText type="danger" message={errors.imageFileId} />
|
|
| 173 |
+ )} |
|
| 174 |
+ </div> |
|
| 130 | 175 |
|
| 131 |
- <Field label="사용여부"> |
|
| 132 |
- <RadioGroup |
|
| 133 |
- name="isActive" |
|
| 134 |
- options={ACTIVE_STATUS_OPTIONS}
|
|
| 135 |
- defaultValue={String(item?.isActive ?? true)}
|
|
| 136 |
- /> |
|
| 137 |
- </Field> |
|
| 176 |
+ <div className={styles.field}>
|
|
| 177 |
+ <FoxInput |
|
| 178 |
+ size="md" |
|
| 179 |
+ name="description" |
|
| 180 |
+ label="설명" |
|
| 181 |
+ defaultValue={item?.description ?? ''}
|
|
| 182 |
+ placeholder="설명을 입력하세요." |
|
| 183 |
+ invalid={Boolean(errors.description)}
|
|
| 184 |
+ message={errors.description}
|
|
| 185 |
+ maxLength={500}
|
|
| 186 |
+ /> |
|
| 187 |
+ </div> |
|
| 138 | 188 |
|
| 139 |
- <Field label={`정렬순서 (총 등록 ${typeTotalCount}개)`}>
|
|
| 140 |
- <Input |
|
| 141 |
- type="number" |
|
| 142 |
- name="sortOrder" |
|
| 143 |
- min={1}
|
|
| 144 |
- step={1}
|
|
| 145 |
- defaultValue={item?.sortOrder ?? typeTotalCount + 1}
|
|
| 146 |
- /> |
|
| 147 |
- </Field> |
|
| 148 |
- <FieldError message={errors.sortOrder} />
|
|
| 189 |
+ <div className={styles.field}>
|
|
| 190 |
+ <FoxFormLabel as="span">사용여부</FoxFormLabel> |
|
| 191 |
+ <FoxToggleSwitch |
|
| 192 |
+ size="md" |
|
| 193 |
+ name="isActive" |
|
| 194 |
+ value="true" |
|
| 195 |
+ label={formatDecorationItemActiveLabel(isActive)}
|
|
| 196 |
+ checked={isActive}
|
|
| 197 |
+ onChange={setIsActive}
|
|
| 198 |
+ /> |
|
| 199 |
+ </div> |
|
| 200 |
+ |
|
| 201 |
+ <div className={styles.field}>
|
|
| 202 |
+ <FoxInput |
|
| 203 |
+ size="md" |
|
| 204 |
+ type="number" |
|
| 205 |
+ name="sortOrder" |
|
| 206 |
+ label="정렬순서" |
|
| 207 |
+ min={1}
|
|
| 208 |
+ step={1}
|
|
| 209 |
+ defaultValue={item?.sortOrder ?? typeTotalCount + 1}
|
|
| 210 |
+ placeholder="0번" |
|
| 211 |
+ suffix={`/ 총 ${typeTotalCount}개`}
|
|
| 212 |
+ invalid={Boolean(errors.sortOrder)}
|
|
| 213 |
+ message={errors.sortOrder}
|
|
| 214 |
+ /> |
|
| 215 |
+ </div> |
|
| 216 |
+ </div> |
|
| 149 | 217 |
</> |
| 150 | 218 |
); |
| 151 |
-} |
|
| 152 |
- |
|
| 153 |
-/** 필드 하단 오류 문구. 값이 없으면 아무것도 그리지 않아 레이아웃을 차지하지 않는다. */ |
|
| 154 |
-export function FieldError({ message }: { message?: string }) {
|
|
| 155 |
- if (!message) {
|
|
| 156 |
- return null; |
|
| 157 |
- } |
|
| 158 |
- return <p className="text-body-sm text-danger">{message}</p>;
|
|
| 159 | 219 |
} |
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-list-toolbar.tsx
... | ... | @@ -1,73 +0,0 @@ |
| 1 | -'use client'; | |
| 2 | - | |
| 3 | -import { useRouter } from 'next/navigation'; | |
| 4 | -import { useState, type ChangeEvent } from 'react'; | |
| 5 | -import { Button } from '@/components/ui/button'; | |
| 6 | -import { Select } from '@/components/ui/select'; | |
| 7 | -import { | |
| 8 | - DECORATION_ITEM_PAGE_SIZE_OPTIONS, | |
| 9 | - buildDecorationItemHref, | |
| 10 | - type DecorationItemPageSize, | |
| 11 | - type DecorationItemQuery, | |
| 12 | -} from '@/lib/domain/decoration-item-query'; | |
| 13 | -import { DecorationItemCreateModal } from './decoration-item-create-modal'; | |
| 14 | - | |
| 15 | -interface DecorationItemListToolbarProps { | |
| 16 | - query: DecorationItemQuery; | |
| 17 | - /** 현재 유형(개별/셋트)의 검색어 무관 전체 건수 — 등록 팝업의 "(총 등록 N개)" 힌트에 쓴다. */ | |
| 18 | - typeTotalCount: number; | |
| 19 | -} | |
| 20 | - | |
| 21 | -/** | |
| 22 | - * 목록 툴바(시안 ADM_ITM_101) — 페이지 크기 select는 변경 즉시 URL을 갱신해 서버 데이터를 | |
| 23 | - * 다시 조회하게 하고 1페이지로 되돌린다. 시안에 admins의 "정렬" select·엑셀다운로드에 | |
| 24 | - * 대응하는 항목이 없어 페이지 크기와 등록 버튼만 둔다. | |
| 25 | - * | |
| 26 | - * [+ 신규 등록]은 현재 유형 탭을 기본값으로 들고 등록 팝업을 연다 — 팝업 열림 상태를 이 | |
| 27 | - * 컴포넌트가 소유한다(admin-member-list-toolbar.tsx와 동일한 패턴). | |
| 28 | - */ | |
| 29 | -export function DecorationItemListToolbar({ | |
| 30 | - query, | |
| 31 | - typeTotalCount, | |
| 32 | -}: DecorationItemListToolbarProps) { | |
| 33 | - const router = useRouter(); | |
| 34 | - const [isCreateOpen, setIsCreateOpen] = useState(false); | |
| 35 | - | |
| 36 | - function handlePageSizeChange(event: ChangeEvent<HTMLSelectElement>) { | |
| 37 | - const pageSize = Number(event.target.value) as DecorationItemPageSize; | |
| 38 | - router.replace(buildDecorationItemHref(query, { pageSize, page: 1 })); | |
| 39 | - } | |
| 40 | - | |
| 41 | - return ( | |
| 42 | - <div className="flex flex-wrap items-center justify-end gap-3"> | |
| 43 | - <Button | |
| 44 | - type="button" | |
| 45 | - variant="primary" | |
| 46 | - onClick={() => setIsCreateOpen(true)} | |
| 47 | - > | |
| 48 | - + 신규 등록 | |
| 49 | - </Button> | |
| 50 | - | |
| 51 | - <Select | |
| 52 | - name="pageSize" | |
| 53 | - aria-label="페이지 크기" | |
| 54 | - defaultValue={query.pageSize} | |
| 55 | - onChange={handlePageSizeChange} | |
| 56 | - > | |
| 57 | - {DECORATION_ITEM_PAGE_SIZE_OPTIONS.map((size) => ( | |
| 58 | - <option key={size} value={size}> | |
| 59 | - {size}개씩 | |
| 60 | - </option> | |
| 61 | - ))} | |
| 62 | - </Select> | |
| 63 | - | |
| 64 | - {isCreateOpen && ( | |
| 65 | - <DecorationItemCreateModal | |
| 66 | - defaultItemType={query.itemType} | |
| 67 | - typeTotalCount={typeTotalCount} | |
| 68 | - onClose={() => setIsCreateOpen(false)} | |
| 69 | - /> | |
| 70 | - )} | |
| 71 | - </div> | |
| 72 | - ); | |
| 73 | -} |
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-list.module.scss
... | ... | @@ -0,0 +1,13 @@ |
| 1 | +// 목록 표의 썸네일 — 시안 ADM_ITM_101의 `img-thumb`(80×80 · 1px 테두리 · radius 4). | |
| 2 | +// 그림은 잘라내지 않고 상자 안에 다 보이게 담는다(contain) — 아이템 생김새가 정보다. | |
| 3 | +// @fox에 이미지 조각이 없어 이 화면 전용으로 둔다(값은 전부 @fox 토큰). | |
| 4 | + | |
| 5 | +@use "@fox/styles/abstracts" as fox; | |
| 6 | + | |
| 7 | +.thumbnail { | |
| 8 | + inline-size: 8rem; | |
| 9 | + block-size: 8rem; | |
| 10 | + border: fox.border(1) solid fox.color(border-neutral-subtler); | |
| 11 | + border-radius: fox.radius(2); | |
| 12 | + object-fit: contain; | |
| 13 | +} |
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-list.tsx
... | ... | @@ -0,0 +1,243 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useRouter } from 'next/navigation'; | |
| 4 | +import { useState } from 'react'; | |
| 5 | +import { FoxButton } from '@fox/core/components/fox-button'; | |
| 6 | +import { | |
| 7 | + FoxListContainer, | |
| 8 | + type FoxListColumn, | |
| 9 | +} from '@fox/core/components/fox-list-container'; | |
| 10 | +import { FoxSelectText } from '@fox/core/components/fox-select-text'; | |
| 11 | +import { FoxStatusIndicator } from '@fox/core/components/fox-status-indicator'; | |
| 12 | +import { FoxTab } from '@fox/core/components/fox-tab'; | |
| 13 | +import { FoxTabItem } from '@fox/core/components/fox-tab-item'; | |
| 14 | +import { FoxPlusIcon } from '@fox/core/icons'; | |
| 15 | +import type { CommonCode } from '@/lib/domain/common-code'; | |
| 16 | +import { | |
| 17 | + DECORATION_ITEM_TYPE_OPTIONS, | |
| 18 | + EMPTY_FIELD_PLACEHOLDER, | |
| 19 | + formatDecorationItemActiveLabel, | |
| 20 | + formatDecorationItemCategory, | |
| 21 | + formatDecorationItemPoints, | |
| 22 | + formatDecorationItemUpdatedAt, | |
| 23 | + type DecorationItem, | |
| 24 | + type DecorationItemType, | |
| 25 | +} from '@/lib/domain/decoration-item'; | |
| 26 | +import { | |
| 27 | + DECORATION_ITEM_PAGE_SIZE_OPTIONS, | |
| 28 | + DECORATION_ITEM_SEARCH_FIELD_OPTIONS, | |
| 29 | + buildDecorationItemHref, | |
| 30 | + type DecorationItemPageSize, | |
| 31 | + type DecorationItemQuery, | |
| 32 | + type DecorationItemSearchField, | |
| 33 | +} from '@/lib/domain/decoration-item-query'; | |
| 34 | +import { DecorationItemCreateModal } from './decoration-item-create-modal'; | |
| 35 | +import { DecorationItemRowActions } from './decoration-item-row-actions'; | |
| 36 | +import styles from './decoration-item-list.module.scss'; | |
| 37 | + | |
| 38 | +interface DecorationItemListProps { | |
| 39 | + items: DecorationItem[]; | |
| 40 | + query: DecorationItemQuery; | |
| 41 | + /** 화면이 실제로 보여주는 페이지 — 순번 계산의 기준이다. */ | |
| 42 | + currentPage: number; | |
| 43 | + totalPages: number; | |
| 44 | + totalCount: number; | |
| 45 | + /** 현재 유형(개별/셋트)의 검색어 무관 전체 건수 — 등록·수정 팝업의 정렬순서 힌트에 쓴다. */ | |
| 46 | + typeTotalCount: number; | |
| 47 | + /** 공통코드 `ITEM_CATE_CD` — 등록·수정 팝업의 카테고리 선택지. */ | |
| 48 | + categories: CommonCode[]; | |
| 49 | +} | |
| 50 | + | |
| 51 | +/** | |
| 52 | + * 꾸미기 아이템 목록 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) ADM_ITM_101 (5227:3549) | |
| 53 | + * | |
| 54 | + * 뼈대는 `FoxListContainer`가 그리고, 이 파일은 이 화면 고유의 것만 갖는다 — 유형 탭, 10개 열의 | |
| 55 | + * 정의, 페이지 크기 셀렉트, 신규 등록 버튼. | |
| 56 | + * | |
| 57 | + * **유형 탭이 이 화면의 중심이다.** 셋트아이템은 별도 화면이 없어 탭이 목록의 조회 조건 | |
| 58 | + * (`itemType`)만 바꾼다 — 그래서 검색어·페이지 크기와 같은 층위의 상태이고, 다른 조건과 | |
| 59 | + * 마찬가지로 URL이 소유한다. 링크(`href`)가 아니라 `router.replace`를 쓰는 이유는 두 가지다. | |
| 60 | + * 같은 화면 안의 전환이라 `role="tablist"` 의미가 맞고(FoxTabItem은 `href`를 주면 탭 역할을 | |
| 61 | + * 뗀다), 평범한 `<a>`는 App Router에서 문서 전체를 다시 받는다. | |
| 62 | + * | |
| 63 | + * 탭을 바꾸면 검색어까지 지운다 — 유형이 다르면 아이템 집합 자체가 달라 이전 검색어를 이어 | |
| 64 | + * 붙이면 "결과 없음"만 보게 된다. | |
| 65 | + */ | |
| 66 | +export function DecorationItemList({ | |
| 67 | + items, | |
| 68 | + query, | |
| 69 | + currentPage, | |
| 70 | + totalPages, | |
| 71 | + totalCount, | |
| 72 | + typeTotalCount, | |
| 73 | + categories, | |
| 74 | +}: DecorationItemListProps) { | |
| 75 | + const router = useRouter(); | |
| 76 | + const [isCreateOpen, setIsCreateOpen] = useState(false); | |
| 77 | + | |
| 78 | + /** 목록 조건이 바뀌면 늘 1페이지로 되돌린다 — 이전 페이지 번호는 새 조건에서 의미가 다르다. */ | |
| 79 | + function go(patch: Partial<DecorationItemQuery>) { | |
| 80 | + router.replace(buildDecorationItemHref(query, { ...patch, page: 1 })); | |
| 81 | + } | |
| 82 | + | |
| 83 | + const columns: FoxListColumn<DecorationItem>[] = [ | |
| 84 | + { | |
| 85 | + key: 'no', | |
| 86 | + header: '번호', | |
| 87 | + width: 80, | |
| 88 | + // 저장된 값이 아니라 현재 페이지 기준 표시 순번이다. 목록이 정렬순서 오름차순이라 | |
| 89 | + // "가장 작은 정렬순서가 1번"이 되도록 앞에서부터 센다. | |
| 90 | + render: (_row, index) => (currentPage - 1) * query.pageSize + index + 1, | |
| 91 | + }, | |
| 92 | + { key: 'itemSn', header: '아이템ID', width: 236 }, | |
| 93 | + { | |
| 94 | + key: 'thumbnail', | |
| 95 | + header: '썸네일', | |
| 96 | + width: 160, | |
| 97 | + render: (row) => | |
| 98 | + row.imageUrl ? ( | |
| 99 | + // 백엔드의 공개 이미지 GET이라 브라우저가 직접 부른다. `next/image`를 쓰려면 백엔드 | |
| 100 | + // 호스트를 next.config에 등록해야 해 여기서는 <img>로 둔다. | |
| 101 | + // eslint-disable-next-line @next/next/no-img-element | |
| 102 | + <img | |
| 103 | + className={styles.thumbnail} | |
| 104 | + src={row.imageUrl} | |
| 105 | + alt={`${row.name} 썸네일`} | |
| 106 | + /> | |
| 107 | + ) : ( | |
| 108 | + EMPTY_FIELD_PLACEHOLDER | |
| 109 | + ), | |
| 110 | + }, | |
| 111 | + { key: 'name', header: '아이템명', width: 236 }, | |
| 112 | + { | |
| 113 | + key: 'category', | |
| 114 | + header: '카테고리', | |
| 115 | + width: 160, | |
| 116 | + render: formatDecorationItemCategory, | |
| 117 | + }, | |
| 118 | + { | |
| 119 | + key: 'points', | |
| 120 | + header: '포인트', | |
| 121 | + width: 160, | |
| 122 | + render: (row) => formatDecorationItemPoints(row.points), | |
| 123 | + }, | |
| 124 | + { | |
| 125 | + key: 'isActive', | |
| 126 | + header: '사용여부', | |
| 127 | + width: 120, | |
| 128 | + render: (row) => ( | |
| 129 | + <FoxStatusIndicator | |
| 130 | + type={row.isActive ? 'success' : 'danger'} | |
| 131 | + label={formatDecorationItemActiveLabel(row.isActive)} | |
| 132 | + /> | |
| 133 | + ), | |
| 134 | + }, | |
| 135 | + { key: 'sortOrder', header: '정렬순서', width: 80 }, | |
| 136 | + { | |
| 137 | + key: 'updatedAt', | |
| 138 | + header: '수정일시', | |
| 139 | + width: 160, | |
| 140 | + render: (row) => formatDecorationItemUpdatedAt(row.updatedAt), | |
| 141 | + }, | |
| 142 | + { | |
| 143 | + key: 'actions', | |
| 144 | + header: '관리', | |
| 145 | + width: 160, | |
| 146 | + render: (row) => ( | |
| 147 | + <DecorationItemRowActions | |
| 148 | + item={row} | |
| 149 | + typeTotalCount={typeTotalCount} | |
| 150 | + categories={categories} | |
| 151 | + /> | |
| 152 | + ), | |
| 153 | + }, | |
| 154 | + ]; | |
| 155 | + | |
| 156 | + return ( | |
| 157 | + <> | |
| 158 | + <FoxListContainer<DecorationItem> | |
| 159 | + title="꾸미기 아이템 목록" | |
| 160 | + breadcrumb={[ | |
| 161 | + { label: '홈', href: '/' }, | |
| 162 | + { label: '꾸미기아이템관리' }, | |
| 163 | + { label: '아이템 관리' }, | |
| 164 | + ]} | |
| 165 | + caption="꾸미기 아이템 목록" | |
| 166 | + tabs={ | |
| 167 | + <FoxTab | |
| 168 | + type="line" | |
| 169 | + size="lg" | |
| 170 | + label="아이템 유형" | |
| 171 | + value={query.itemType} | |
| 172 | + onValueChange={(value) => | |
| 173 | + go({ itemType: value as DecorationItemType, keyword: '' }) | |
| 174 | + } | |
| 175 | + > | |
| 176 | + {DECORATION_ITEM_TYPE_OPTIONS.map((option) => ( | |
| 177 | + <FoxTabItem key={option.value} value={option.value}> | |
| 178 | + {option.label} | |
| 179 | + </FoxTabItem> | |
| 180 | + ))} | |
| 181 | + </FoxTab> | |
| 182 | + } | |
| 183 | + columns={columns} | |
| 184 | + rows={items} | |
| 185 | + rowKey={(row) => String(row.itemSn)} | |
| 186 | + totalCount={totalCount} | |
| 187 | + emptyMessage="등록된 아이템이 없습니다. 다른 검색어로 다시 시도해 주세요." | |
| 188 | + sorts={ | |
| 189 | + <FoxSelectText | |
| 190 | + size="sm" | |
| 191 | + ariaLabel="페이지 크기" | |
| 192 | + options={DECORATION_ITEM_PAGE_SIZE_OPTIONS.map((size) => ({ | |
| 193 | + value: String(size), | |
| 194 | + label: `${size}개씩 보기`, | |
| 195 | + }))} | |
| 196 | + value={String(query.pageSize)} | |
| 197 | + onValueChange={(value) => | |
| 198 | + go({ pageSize: Number(value) as DecorationItemPageSize }) | |
| 199 | + } | |
| 200 | + /> | |
| 201 | + } | |
| 202 | + searchFields={DECORATION_ITEM_SEARCH_FIELD_OPTIONS.map((option) => ({ | |
| 203 | + value: option.value, | |
| 204 | + label: option.label, | |
| 205 | + }))} | |
| 206 | + searchField={query.searchField} | |
| 207 | + keyword={query.keyword} | |
| 208 | + toolbarActions={ | |
| 209 | + <FoxButton | |
| 210 | + type="primary" | |
| 211 | + size="md" | |
| 212 | + leadingIcon={<FoxPlusIcon />} | |
| 213 | + label="신규 등록" | |
| 214 | + onAction={() => setIsCreateOpen(true)} | |
| 215 | + /> | |
| 216 | + } | |
| 217 | + page={currentPage} | |
| 218 | + totalPages={totalPages} | |
| 219 | + buildHref={(patch) => | |
| 220 | + buildDecorationItemHref(query, { page: Number(patch.page ?? 1) }) | |
| 221 | + } | |
| 222 | + onQueryChange={(patch) => { | |
| 223 | + if (patch.keyword !== undefined || patch.searchField !== undefined) { | |
| 224 | + go({ | |
| 225 | + keyword: String(patch.keyword ?? ''), | |
| 226 | + searchField: (patch.searchField ?? | |
| 227 | + query.searchField) as DecorationItemSearchField, | |
| 228 | + }); | |
| 229 | + } | |
| 230 | + }} | |
| 231 | + /> | |
| 232 | + | |
| 233 | + {isCreateOpen && ( | |
| 234 | + <DecorationItemCreateModal | |
| 235 | + defaultItemType={query.itemType} | |
| 236 | + typeTotalCount={typeTotalCount} | |
| 237 | + categories={categories} | |
| 238 | + onClose={() => setIsCreateOpen(false)} | |
| 239 | + /> | |
| 240 | + )} | |
| 241 | + </> | |
| 242 | + ); | |
| 243 | +} |
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-row-actions.tsx
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-row-actions.tsx
... | ... | @@ -1,35 +1,34 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 | 3 |
import { useState, useTransition } from 'react';
|
| 4 |
-import { Button } from '@/components/ui/button';
|
|
| 4 |
+import { FoxButton } from '@fox/core/components/fox-button';
|
|
| 5 |
+import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
|
|
| 5 | 6 |
import { FoxIconButton } from '@fox/core/components/fox-icon-button';
|
| 6 | 7 |
import { FoxPencilSimpleIcon, FoxTrashIcon } from '@fox/core/icons';
|
| 7 | 8 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 9 |
+import type { CommonCode } from '@/lib/domain/common-code';
|
|
| 8 | 10 |
import type { DecorationItem } from '@/lib/domain/decoration-item';
|
| 9 | 11 |
import { deleteDecorationItemAction } from '../_actions';
|
| 10 | 12 |
import { DecorationItemEditModal } from './decoration-item-edit-modal';
|
| 11 | 13 |
|
| 12 | 14 |
interface DecorationItemRowActionsProps {
|
| 13 | 15 |
item: DecorationItem; |
| 14 |
- /** 수정 팝업의 "(총 등록 N개)" 힌트로 그대로 흘려보낸다(현재 유형 전체 등록 건수). */ |
|
| 16 |
+ /** 수정 팝업의 "/ 총 N개" 힌트로 그대로 흘려보낸다(현재 유형 전체 등록 건수). */ |
|
| 15 | 17 |
typeTotalCount: number; |
| 18 |
+ categories: CommonCode[]; |
|
| 16 | 19 |
} |
| 17 | 20 |
|
| 18 | 21 |
/** |
| 19 |
- * 목록 행의 "관리" 셀(시안 ADM_ITM_101) — 수정 팝업 열림 상태만 소유하는 최말단 상호작용 |
|
| 20 |
- * 경계다(테이블 전체를 클라이언트로 내리지 않기 위해 이 셀만 분리했다, admins와 동일 패턴). |
|
| 22 |
+ * 목록 행의 "관리" 셀(시안 ADM_ITM_101) — 시안의 btn-ico 두 개(28×28 · 테두리 있는 default |
|
| 23 |
+ * 계열)를 4px 간격으로 세운다. |
|
| 21 | 24 |
* |
| 22 |
- * **삭제는 시안대로 확인 얼럿을 거친 뒤 실행한다** — 전역 얼럿(FeedbackProvider)의 actions |
|
| 23 |
- * 슬롯에 직접 [취소]/[삭제] 버튼을 넣어 구성한다. 삭제는 되돌릴 수 없는 동작이라 얼럿 문구에 |
|
| 24 |
- * 명시한다. |
|
| 25 |
- * |
|
| 26 |
- * 시안의 연필·휴지통 아이콘 버튼이다. 버튼은 `components/ui/button`이 아니라 `FoxIconButton`을 |
|
| 27 |
- * 쓴다 — 아이콘은 스스로 크기를 갖지 않고 감싼 슬롯이 정하는데, 그 슬롯 크기를 실제로 정의하는 |
|
| 28 |
- * 건 @fox 쪽뿐이다(components/ui는 Tailwind 제거 이후 무스타일이다). |
|
| 25 |
+ * **삭제는 확인 얼럿을 거친다** — 전역 얼럿(FeedbackProvider)의 actions 슬롯에 [취소]/[삭제]를 |
|
| 26 |
+ * 넣어 구성한다. 되돌릴 수 없는 동작이라 얼럿 문구에 명시한다. |
|
| 29 | 27 |
*/ |
| 30 | 28 |
export function DecorationItemRowActions({
|
| 31 | 29 |
item, |
| 32 | 30 |
typeTotalCount, |
| 31 |
+ categories, |
|
| 33 | 32 |
}: DecorationItemRowActionsProps) {
|
| 34 | 33 |
const { showAlert, hideAlert, showToast } = useFeedback();
|
| 35 | 34 |
const [isEditOpen, setIsEditOpen] = useState(false); |
... | ... | @@ -57,42 +56,41 @@ |
| 57 | 56 |
message: `${item.name}(아이템ID ${item.itemSn})을(를) 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
|
| 58 | 57 |
actions: ( |
| 59 | 58 |
<> |
| 60 |
- <Button type="button" variant="ghost" onClick={hideAlert}>
|
|
| 61 |
- 취소 |
|
| 62 |
- </Button> |
|
| 63 |
- <Button type="button" variant="primary" onClick={runDelete}>
|
|
| 64 |
- 삭제 |
|
| 65 |
- </Button> |
|
| 59 |
+ <FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
|
|
| 60 |
+ <FoxButton type="error" size="md" label="삭제" onAction={runDelete} />
|
|
| 66 | 61 |
</> |
| 67 | 62 |
), |
| 68 | 63 |
}); |
| 69 | 64 |
} |
| 70 | 65 |
|
| 71 | 66 |
return ( |
| 72 |
- <div className="flex items-center gap-1"> |
|
| 73 |
- <FoxIconButton |
|
| 74 |
- type="ghost" |
|
| 75 |
- size="sm" |
|
| 76 |
- icon={<FoxPencilSimpleIcon />}
|
|
| 77 |
- label="수정" |
|
| 78 |
- onAction={() => setIsEditOpen(true)}
|
|
| 79 |
- /> |
|
| 80 |
- <FoxIconButton |
|
| 81 |
- type="ghost" |
|
| 82 |
- size="sm" |
|
| 83 |
- icon={<FoxTrashIcon />}
|
|
| 84 |
- label={isDeleting ? '삭제 중' : '삭제'}
|
|
| 85 |
- disabled={isDeleting}
|
|
| 86 |
- onAction={confirmDelete}
|
|
| 87 |
- /> |
|
| 67 |
+ <> |
|
| 68 |
+ <FoxButtonGroup size="sm"> |
|
| 69 |
+ <FoxIconButton |
|
| 70 |
+ type="default" |
|
| 71 |
+ size="sm" |
|
| 72 |
+ icon={<FoxPencilSimpleIcon />}
|
|
| 73 |
+ label="수정" |
|
| 74 |
+ onAction={() => setIsEditOpen(true)}
|
|
| 75 |
+ /> |
|
| 76 |
+ <FoxIconButton |
|
| 77 |
+ type="default" |
|
| 78 |
+ size="sm" |
|
| 79 |
+ icon={<FoxTrashIcon />}
|
|
| 80 |
+ label={isDeleting ? '삭제 중' : '삭제'}
|
|
| 81 |
+ disabled={isDeleting}
|
|
| 82 |
+ onAction={confirmDelete}
|
|
| 83 |
+ /> |
|
| 84 |
+ </FoxButtonGroup> |
|
| 88 | 85 |
|
| 89 | 86 |
{isEditOpen && (
|
| 90 | 87 |
<DecorationItemEditModal |
| 91 | 88 |
item={item}
|
| 92 | 89 |
typeTotalCount={typeTotalCount}
|
| 90 |
+ categories={categories}
|
|
| 93 | 91 |
onClose={() => setIsEditOpen(false)}
|
| 94 | 92 |
/> |
| 95 | 93 |
)} |
| 96 |
- </div> |
|
| 94 |
+ </> |
|
| 97 | 95 |
); |
| 98 | 96 |
} |
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-search-bar.tsx
... | ... | @@ -1,74 +0,0 @@ |
| 1 | -import Link from 'next/link'; | |
| 2 | -import { Button } from '@/components/ui/button'; | |
| 3 | -import { Field } from '@/components/ui/field'; | |
| 4 | -import { Input } from '@/components/ui/input'; | |
| 5 | -import { Select } from '@/components/ui/select'; | |
| 6 | -import { | |
| 7 | - DECORATION_ITEMS_PATH, | |
| 8 | - DECORATION_ITEM_SEARCH_FIELD_OPTIONS, | |
| 9 | - DEFAULT_DECORATION_ITEM_SEARCH_FIELD, | |
| 10 | - buildDecorationItemHref, | |
| 11 | - type DecorationItemQuery, | |
| 12 | -} from '@/lib/domain/decoration-item-query'; | |
| 13 | - | |
| 14 | -interface DecorationItemSearchBarProps { | |
| 15 | - query: DecorationItemQuery; | |
| 16 | -} | |
| 17 | - | |
| 18 | -/** | |
| 19 | - * 검색 영역(시안 ADM_ITM_101) — 순수 `<form method="get">` GET 제출로 URL을 갱신한다 | |
| 20 | - * (자바스크립트 없이도 동작). 현재 `itemType`(유형 탭)·`pageSize`는 hidden input으로 함께 | |
| 21 | - * 제출해 검색 후에도 유지되게 하고, 검색 자체는 항상 1페이지로 돌아간다(hidden page 필드를 | |
| 22 | - * 두지 않아 `parseDecorationItemQuery`의 기본값 1로 자연스럽게 떨어진다). "초기화"는 폼 | |
| 23 | - * 제출이 아니라 기본값(검색대상·검색어)으로 되돌린 링크라 현재 입력값과 무관하게 동작한다 | |
| 24 | - * (유형 탭은 초기화 대상이 아니다 — 지금 보고 있는 목록은 유지). 상호작용이 전부 네이티브 폼 | |
| 25 | - * 제출/링크 내비게이션이라 클라이언트 자바스크립트가 필요 없다 — Server Component로 유지한다 | |
| 26 | - * (admin-member-search-bar.tsx와 동일한 설계). | |
| 27 | - */ | |
| 28 | -export function DecorationItemSearchBar({ query }: DecorationItemSearchBarProps) { | |
| 29 | - const resetHref = buildDecorationItemHref(query, { | |
| 30 | - searchField: DEFAULT_DECORATION_ITEM_SEARCH_FIELD, | |
| 31 | - keyword: '', | |
| 32 | - page: 1, | |
| 33 | - }); | |
| 34 | - | |
| 35 | - return ( | |
| 36 | - <form | |
| 37 | - method="get" | |
| 38 | - action={DECORATION_ITEMS_PATH} | |
| 39 | - className="flex flex-wrap items-end gap-3" | |
| 40 | - > | |
| 41 | - <input type="hidden" name="itemType" value={query.itemType} /> | |
| 42 | - <input type="hidden" name="pageSize" value={query.pageSize} /> | |
| 43 | - | |
| 44 | - <Field label="검색 대상"> | |
| 45 | - <Select name="searchField" defaultValue={query.searchField}> | |
| 46 | - {DECORATION_ITEM_SEARCH_FIELD_OPTIONS.map((option) => ( | |
| 47 | - <option key={option.value} value={option.value}> | |
| 48 | - {option.label} | |
| 49 | - </option> | |
| 50 | - ))} | |
| 51 | - </Select> | |
| 52 | - </Field> | |
| 53 | - | |
| 54 | - <Field label="검색어"> | |
| 55 | - <Input | |
| 56 | - type="text" | |
| 57 | - name="keyword" | |
| 58 | - defaultValue={query.keyword} | |
| 59 | - placeholder="검색어를 입력하세요." | |
| 60 | - /> | |
| 61 | - </Field> | |
| 62 | - | |
| 63 | - <Button type="submit" variant="secondary"> | |
| 64 | - 검색 | |
| 65 | - </Button> | |
| 66 | - <Link | |
| 67 | - href={resetHref} | |
| 68 | - className="text-body-md text-foreground-muted underline-offset-2 hover:underline" | |
| 69 | - > | |
| 70 | - 초기화 | |
| 71 | - </Link> | |
| 72 | - </form> | |
| 73 | - ); | |
| 74 | -} |
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-table.tsx
... | ... | @@ -1,109 +0,0 @@ |
| 1 | -import { | |
| 2 | - Table, | |
| 3 | - TableBody, | |
| 4 | - TableCell, | |
| 5 | - TableHead, | |
| 6 | - TableHeaderCell, | |
| 7 | - TableRow, | |
| 8 | -} from '@/components/ui/table'; | |
| 9 | -import { | |
| 10 | - EMPTY_FIELD_PLACEHOLDER, | |
| 11 | - formatDecorationItemActiveLabel, | |
| 12 | - formatDecorationItemCategory, | |
| 13 | - formatDecorationItemPoints, | |
| 14 | - formatDecorationItemUpdatedAt, | |
| 15 | - type DecorationItem, | |
| 16 | -} from '@/lib/domain/decoration-item'; | |
| 17 | -import { DecorationItemRowActions } from './decoration-item-row-actions'; | |
| 18 | - | |
| 19 | -interface DecorationItemTableProps { | |
| 20 | - items: DecorationItem[]; | |
| 21 | - /** 1-based 현재 페이지. "번호" 열의 표시 순번 계산에 쓴다. */ | |
| 22 | - page: number; | |
| 23 | - pageSize: number; | |
| 24 | - /** 수정 팝업의 "(총 등록 N개)" 힌트로 그대로 흘려보낸다(현재 유형 전체 등록 건수). */ | |
| 25 | - typeTotalCount: number; | |
| 26 | -} | |
| 27 | - | |
| 28 | -const COLUMN_HEADERS = [ | |
| 29 | - '번호', | |
| 30 | - '아이템ID', | |
| 31 | - '썸네일', | |
| 32 | - '아이템명', | |
| 33 | - '카테고리', | |
| 34 | - '포인트', | |
| 35 | - '사용여부', | |
| 36 | - '정렬순서', | |
| 37 | - '수정일시', | |
| 38 | - '관리', | |
| 39 | -]; | |
| 40 | - | |
| 41 | -/** | |
| 42 | - * 꾸미기 아이템 목록 테이블(시안 ADM_ITM_101, 10컬럼). | |
| 43 | - * | |
| 44 | - * **"번호"는 admins와 달리 오름차순 표시 순번이다**(`offset + index + 1`) — 이 화면의 기본 | |
| 45 | - * 정렬이 정렬순서 오름차순이라(Repository가 보장) "가장 작은 정렬순서가 1번"이 직관적이다. | |
| 46 | - * admins가 전체 건수에서 거꾸로 세는 이유(생성일 최신순 정렬)가 여기에는 없다. | |
| 47 | - * | |
| 48 | - * **썸네일은 백엔드 이미지 URL을 그대로 건다** — 등록한 파일 ID(`atchFileId`)로 만든 | |
| 49 | - * `GET /api/v1/common/file/image` 주소이며, 이 GET은 인증이 필요 없어 브라우저가 백엔드를 직접 | |
| 50 | - * 호출한다(사용자 확정 사항). URL 문자열은 서버에서 만들어 내려주므로 백엔드 주소 환경변수는 | |
| 51 | - * 여전히 서버 전용이다. `next/image`가 아니라 `<img>`를 쓰는 이유는 외부 호스트를 | |
| 52 | - * `next.config`에 등록해야 하는 설정 변경을 이번 범위(로직)에서 하지 않기 때문이다. | |
| 53 | - * | |
| 54 | - * "관리" 열은 행별 수정/삭제 트리거(`DecorationItemRowActions`)에 위임한다 — 상호작용이 | |
| 55 | - * 필요한 것은 그 셀뿐이라 이 테이블 자체는 Server Component로 유지하고 최말단만 클라이언트 | |
| 56 | - * 경계로 뗀다. | |
| 57 | - */ | |
| 58 | -export function DecorationItemTable({ | |
| 59 | - items, | |
| 60 | - page, | |
| 61 | - pageSize, | |
| 62 | - typeTotalCount, | |
| 63 | -}: DecorationItemTableProps) { | |
| 64 | - const offset = (page - 1) * pageSize; | |
| 65 | - | |
| 66 | - return ( | |
| 67 | - <Table> | |
| 68 | - <TableHead> | |
| 69 | - <TableRow> | |
| 70 | - {COLUMN_HEADERS.map((header) => ( | |
| 71 | - <TableHeaderCell key={header}>{header}</TableHeaderCell> | |
| 72 | - ))} | |
| 73 | - </TableRow> | |
| 74 | - </TableHead> | |
| 75 | - <TableBody> | |
| 76 | - {items.map((item, index) => ( | |
| 77 | - <TableRow key={item.itemSn}> | |
| 78 | - <TableCell>{offset + index + 1}</TableCell> | |
| 79 | - <TableCell>{item.itemSn}</TableCell> | |
| 80 | - <TableCell> | |
| 81 | - {item.imageUrl ? ( | |
| 82 | - // eslint-disable-next-line @next/next/no-img-element -- 위 주석 참조(외부 호스트 설정 회피) | |
| 83 | - <img | |
| 84 | - src={item.imageUrl} | |
| 85 | - alt={`${item.name} 썸네일`} | |
| 86 | - className="size-9 rounded-md object-cover" | |
| 87 | - /> | |
| 88 | - ) : ( | |
| 89 | - EMPTY_FIELD_PLACEHOLDER | |
| 90 | - )} | |
| 91 | - </TableCell> | |
| 92 | - <TableCell>{item.name}</TableCell> | |
| 93 | - <TableCell>{formatDecorationItemCategory(item)}</TableCell> | |
| 94 | - <TableCell>{formatDecorationItemPoints(item.points)}</TableCell> | |
| 95 | - <TableCell>{formatDecorationItemActiveLabel(item.isActive)}</TableCell> | |
| 96 | - <TableCell>{item.sortOrder}</TableCell> | |
| 97 | - <TableCell>{formatDecorationItemUpdatedAt(item.updatedAt)}</TableCell> | |
| 98 | - <TableCell> | |
| 99 | - <DecorationItemRowActions | |
| 100 | - item={item} | |
| 101 | - typeTotalCount={typeTotalCount} | |
| 102 | - /> | |
| 103 | - </TableCell> | |
| 104 | - </TableRow> | |
| 105 | - ))} | |
| 106 | - </TableBody> | |
| 107 | - </Table> | |
| 108 | - ); | |
| 109 | -} |
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-type-tabs.tsx
... | ... | @@ -1,59 +0,0 @@ |
| 1 | -import Link from 'next/link'; | |
| 2 | -import { DECORATION_ITEM_TYPE_OPTIONS } from '@/lib/domain/decoration-item'; | |
| 3 | -import { | |
| 4 | - buildDecorationItemHref, | |
| 5 | - type DecorationItemQuery, | |
| 6 | -} from '@/lib/domain/decoration-item-query'; | |
| 7 | - | |
| 8 | -interface DecorationItemTypeTabsProps { | |
| 9 | - query: DecorationItemQuery; | |
| 10 | -} | |
| 11 | - | |
| 12 | -const TAB_BASE_CLASSES = | |
| 13 | - 'inline-flex h-10 items-center justify-center rounded-md px-4 text-body-md transition-colors duration-200'; | |
| 14 | -const ACTIVE_TAB_CLASSES = 'bg-primary text-on-primary'; | |
| 15 | -const INACTIVE_TAB_CLASSES = | |
| 16 | - 'border border-border bg-surface text-foreground-muted hover:bg-surface-muted hover:text-foreground'; | |
| 17 | - | |
| 18 | -/** | |
| 19 | - * 유형 탭(시안 ADM_ITM_101 — 개별아이템/셋트아이템 전환). **셋트아이템은 별도 화면이 없고 이 | |
| 20 | - * 탭이 같은 목록·같은 폼의 유형만 바꾼다**는 것이 시안 두 곳에 명시된 사양이다 | |
| 21 | - * (`decoration-item-query.ts` 상단 주석 참조). | |
| 22 | - * | |
| 23 | - * 값이 URL(`itemType` 쿼리 파라미터)에 실리므로 순수 `next/link` 내비게이션으로 충분해 | |
| 24 | - * 클라이언트 자바스크립트가 필요 없다 — Server Component로 유지한다. | |
| 25 | - * | |
| 26 | - * 탭 전환 시 검색어/검색대상/페이지크기는 유지하고 페이지만 1로 되돌린다(admin-member의 | |
| 27 | - * 정렬/페이지크기 전환과 같은 규칙) — 다른 유형으로 넘어가면 전체 건수 자체가 달라지기 | |
| 28 | - * 때문이다. 전용 탭 컴포넌트가 없어(§10.3, 신설은 design 레인 소관) 버튼 형태 Link 2개로 | |
| 29 | - * 구성했다. | |
| 30 | - */ | |
| 31 | -export function DecorationItemTypeTabs({ query }: DecorationItemTypeTabsProps) { | |
| 32 | - return ( | |
| 33 | - <div | |
| 34 | - role="tablist" | |
| 35 | - aria-label="아이템 유형" | |
| 36 | - className="flex items-center gap-2" | |
| 37 | - > | |
| 38 | - {DECORATION_ITEM_TYPE_OPTIONS.map((option) => { | |
| 39 | - const isActive = option.value === query.itemType; | |
| 40 | - return ( | |
| 41 | - <Link | |
| 42 | - key={option.value} | |
| 43 | - href={buildDecorationItemHref(query, { | |
| 44 | - itemType: option.value, | |
| 45 | - page: 1, | |
| 46 | - })} | |
| 47 | - role="tab" | |
| 48 | - aria-selected={isActive} | |
| 49 | - className={`${TAB_BASE_CLASSES} ${ | |
| 50 | - isActive ? ACTIVE_TAB_CLASSES : INACTIVE_TAB_CLASSES | |
| 51 | - }`} | |
| 52 | - > | |
| 53 | - {option.label} | |
| 54 | - </Link> | |
| 55 | - ); | |
| 56 | - })} | |
| 57 | - </div> | |
| 58 | - ); | |
| 59 | -} |
--- app/(protected)/(basic)/decoration-items/page.tsx
+++ app/(protected)/(basic)/decoration-items/page.tsx
... | ... | @@ -1,99 +1,60 @@ |
| 1 | 1 |
import type { Metadata } from 'next';
|
| 2 |
-import Link from 'next/link'; |
|
| 3 |
-import { Alert } from '@/components/ui/alert';
|
|
| 4 |
-import { Pagination } from '@/components/ui/pagination';
|
|
| 5 | 2 |
import { verifySession } from '@/lib/auth/dal';
|
| 3 |
+import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository';
|
|
| 6 | 4 |
import { fetchDecorationItems } from '@/lib/data/repositories/decoration-item-repository';
|
| 7 |
-import {
|
|
| 8 |
- buildDecorationItemHref, |
|
| 9 |
- parseDecorationItemQuery, |
|
| 10 |
-} from '@/lib/domain/decoration-item-query'; |
|
| 11 |
-import { DecorationItemListToolbar } from './_components/decoration-item-list-toolbar';
|
|
| 12 |
-import { DecorationItemSearchBar } from './_components/decoration-item-search-bar';
|
|
| 13 |
-import { DecorationItemTable } from './_components/decoration-item-table';
|
|
| 14 |
-import { DecorationItemTypeTabs } from './_components/decoration-item-type-tabs';
|
|
| 5 |
+import { CODE_GROUP } from '@/lib/domain/common-code';
|
|
| 6 |
+import { parseDecorationItemQuery } from '@/lib/domain/decoration-item-query';
|
|
| 7 |
+import { DecorationItemList } from './_components/decoration-item-list';
|
|
| 15 | 8 |
|
| 16 | 9 |
export const metadata: Metadata = {
|
| 17 | 10 |
title: '꾸미기 아이템', |
| 18 | 11 |
}; |
| 12 |
+ |
|
| 13 |
+// cookies()로 이미 동적이지만, 정적 프리렌더로 데이터가 빌드 산출물에 박히는 경로를 |
|
| 14 |
+// 원천 차단하기 위해 명시적으로 강제한다(학생 회원 목록과 동일). |
|
| 15 |
+export const dynamic = 'force-dynamic'; |
|
| 19 | 16 |
|
| 20 | 17 |
interface PageProps {
|
| 21 | 18 |
searchParams: Promise<Record<string, string | string[] | undefined>>; |
| 22 | 19 |
} |
| 23 | 20 |
|
| 24 | 21 |
/** |
| 25 |
- * 꾸미기 아이템 목록(시안 ADM_ITM_101). |
|
| 22 |
+ * 꾸미기 아이템 목록(시안 ADM_ITM_101 — 5227:3549). |
|
| 26 | 23 |
* |
| 27 | 24 |
* **셋트아이템은 별도 화면이 없다** — 유형 탭(개별/셋트)만 바꿔 같은 목록·같은 폼을 재사용 |
| 28 |
- * 한다(시안 두 곳에 명시된 사양). `query.itemType`이 곧 그 탭 상태이고, 다른 검색·페이지 |
|
| 29 |
- * 상태와 마찬가지로 URL(searchParams)이 단일 진실원천이다(`useState`로 들지 않는다). |
|
| 30 |
- * |
|
| 31 |
- * `verifySession()`을 호출만 하고 반환값을 쓰지 않는 이유는 인증 확인 자체가 목적이기 |
|
| 32 |
- * 때문이다 — admins의 "본인 계정 삭제 방지" 같은 사용자 조건부 로직이 이 화면에는 없다. |
|
| 25 |
+ * 한다(시안 두 곳에 명시된 사양). `query.itemType`이 곧 그 탭 상태이고, 검색·페이지와 마찬가지로 |
|
| 26 |
+ * URL(searchParams)이 단일 진실원천이다. |
|
| 33 | 27 |
*/ |
| 34 | 28 |
export default async function Page({ searchParams }: PageProps) {
|
| 35 | 29 |
await verifySession(); |
| 36 | 30 |
|
| 37 | 31 |
const query = parseDecorationItemQuery(await searchParams); |
| 38 |
- const { items, totalCount, isTotalCountExact, typeTotalCount } =
|
|
| 39 |
- await fetchDecorationItems(query); |
|
| 32 |
+ // 목록과 카테고리는 서로를 기다릴 이유가 없어 함께 띄운다. |
|
| 33 |
+ const [{ items, totalCount, isTotalCountExact, typeTotalCount }, categories] =
|
|
| 34 |
+ await Promise.all([ |
|
| 35 |
+ fetchDecorationItems(query), |
|
| 36 |
+ fetchCommonCodes(CODE_GROUP.decorationItemCategory), |
|
| 37 |
+ ]); |
|
| 40 | 38 |
|
| 41 | 39 |
// 전체 건수가 확정되지 않았다면(백엔드가 count를 주지 않아 하한값만 아는 상태) 다음 페이지를 |
| 42 | 40 |
// 한 칸 열어 둔다 — 열어 두지 않으면 가득 찬 페이지 뒤의 데이터에 접근할 방법이 없어진다 |
| 43 |
- // (학생 목록과 동일한 보정. Repository 주석 참조). |
|
| 41 |
+ // (Repository 주석 참조). |
|
| 44 | 42 |
const totalPages = isTotalCountExact |
| 45 | 43 |
? Math.max(1, Math.ceil(totalCount / query.pageSize)) |
| 46 | 44 |
: query.page + 1; |
| 47 |
- // 요청 페이지가 범위를 벗어나면(예: 삭제로 마지막 페이지가 사라짐, 또는 유형 탭 전환으로 |
|
| 48 |
- // 전체 건수가 줄어듦) 마지막 페이지로 맞춘다 — 표의 순번 계산도 이 값을 기준으로 해야 헤더의 |
|
| 49 |
- // "현재페이지"와 어긋나지 않는다. |
|
| 45 |
+ // 요청 페이지가 범위를 벗어나면(삭제로 마지막 페이지가 사라짐, 유형 탭 전환으로 건수가 줄어듦) |
|
| 46 |
+ // 마지막 페이지로 맞춘다 — 표의 순번도 이 값을 기준으로 세야 페이지 표시와 어긋나지 않는다. |
|
| 50 | 47 |
const currentPage = Math.min(query.page, totalPages); |
| 51 | 48 |
|
| 52 | 49 |
return ( |
| 53 |
- <section className="flex flex-1 flex-col gap-6 p-8"> |
|
| 54 |
- <h1 className="text-headline-sm text-foreground">꾸미기 아이템 목록</h1> |
|
| 55 |
- |
|
| 56 |
- <DecorationItemTypeTabs query={query} />
|
|
| 57 |
- |
|
| 58 |
- <DecorationItemSearchBar query={query} />
|
|
| 59 |
- |
|
| 60 |
- <DecorationItemListToolbar query={query} typeTotalCount={typeTotalCount} />
|
|
| 61 |
- |
|
| 62 |
- <p className="text-body-md text-foreground-muted"> |
|
| 63 |
- 총 {totalCount}개{isTotalCountExact ? '' : ' 이상'} | 현재페이지{' '}
|
|
| 64 |
- {currentPage}
|
|
| 65 |
- {isTotalCountExact ? `/${totalPages}` : ''}
|
|
| 66 |
- </p> |
|
| 67 |
- |
|
| 68 |
- {items.length === 0 ? (
|
|
| 69 |
- <Alert |
|
| 70 |
- title="등록된 아이템이 없습니다." |
|
| 71 |
- actions={
|
|
| 72 |
- <Link |
|
| 73 |
- href={buildDecorationItemHref(query, { keyword: '', page: 1 })}
|
|
| 74 |
- className="bg-foreground px-4 py-2 text-background" |
|
| 75 |
- > |
|
| 76 |
- 확인 |
|
| 77 |
- </Link> |
|
| 78 |
- } |
|
| 79 |
- > |
|
| 80 |
- 다른 검색어로 다시 시도해 주세요. |
|
| 81 |
- </Alert> |
|
| 82 |
- ) : ( |
|
| 83 |
- <> |
|
| 84 |
- <DecorationItemTable |
|
| 85 |
- items={items}
|
|
| 86 |
- page={currentPage}
|
|
| 87 |
- pageSize={query.pageSize}
|
|
| 88 |
- typeTotalCount={typeTotalCount}
|
|
| 89 |
- /> |
|
| 90 |
- <Pagination |
|
| 91 |
- currentPage={currentPage}
|
|
| 92 |
- totalPages={totalPages}
|
|
| 93 |
- buildHref={(page: number) => buildDecorationItemHref(query, { page })}
|
|
| 94 |
- /> |
|
| 95 |
- </> |
|
| 96 |
- )} |
|
| 97 |
- </section> |
|
| 50 |
+ <DecorationItemList |
|
| 51 |
+ items={items}
|
|
| 52 |
+ query={query}
|
|
| 53 |
+ currentPage={currentPage}
|
|
| 54 |
+ totalPages={totalPages}
|
|
| 55 |
+ totalCount={totalCount}
|
|
| 56 |
+ typeTotalCount={typeTotalCount}
|
|
| 57 |
+ categories={categories}
|
|
| 58 |
+ /> |
|
| 98 | 59 |
); |
| 99 | 60 |
} |
--- app/(protected)/(basic)/students/_actions.ts
+++ app/(protected)/(basic)/students/_actions.ts
... | ... | @@ -1,34 +1,49 @@ |
| 1 | 1 |
'use server'; |
| 2 | 2 |
|
| 3 |
+import { revalidatePath } from 'next/cache';
|
|
| 3 | 4 |
import { verifySession } from '@/lib/auth/dal';
|
| 5 |
+import { updateStudentMemberUseYn } from '@/lib/data/repositories/student-member-repository';
|
|
| 6 |
+import { STUDENT_MEMBERS_PATH } from '@/lib/domain/student-member-query';
|
|
| 4 | 7 |
|
| 5 | 8 |
export type UpdateStudentActiveStatusState = |
| 6 | 9 |
| { status: 'idle' }
|
| 7 | 10 |
| { status: 'error'; error: string }
|
| 8 | 11 |
| { status: 'success' };
|
| 9 | 12 |
|
| 10 |
-const UNSUPPORTED_ERROR = |
|
| 11 |
- '사용여부 변경은 아직 제공되지 않습니다. (백엔드 API 준비 중)'; |
|
| 13 |
+const INVALID_INPUT_ERROR = '요청 값이 올바르지 않습니다.'; |
|
| 12 | 14 |
|
| 13 | 15 |
/** |
| 14 |
- * 학생 회원 사용여부 변경 Server Action — **현재는 미지원 상태의 이식 지점**이다. |
|
| 16 |
+ * 학생 회원 사용여부 변경 Server Action. |
|
| 15 | 17 |
* |
| 16 |
- * 목록이 백엔드 API(`GET /api/v1/mngr/user/pagination`)로 전환되면서 이 화면의 데이터 원천은 |
|
| 17 |
- * 백엔드가 됐지만, 사용여부는 **조회 응답에 값도 없고 변경 API도 없다.** 이전의 mock 쓰기를 |
|
| 18 |
- * 그대로 두면 mock 배열에만 존재하는 id를 찾다가 "존재하지 않는 학생 회원" 오류가 나므로 |
|
| 19 |
- * (목록의 id는 이제 백엔드 `userId`다) 쓰기 경로를 명시적으로 막았다. 팝업의 사용여부 라디오와 |
|
| 20 |
- * 저장 버튼도 같은 이유로 비활성이다. |
|
| 18 |
+ * PUT /api/v1/mngr/user/{userId}/{useYn}
|
|
| 21 | 19 |
* |
| 22 |
- * 변경 API가 생기면 `formData: FormData` 인자를 되살리고 본문을 "입력 검증 → Repository 호출 |
|
| 23 |
- * → `revalidatePath`"로 되돌리면 된다(지금은 읽을 입력이 없어 인자를 받지 않는다 — 인자를 |
|
| 24 |
- * 줄여도 `useActionState`의 호출 규약에는 어긋나지 않는다). 인증 확인을 본문 맨 앞에 남겨 둔 |
|
| 25 |
- * 것도 그 형태를 유지하기 위함이다 — Server Action은 UI를 거치지 않고 직접 POST될 수 있어 |
|
| 26 |
- * 이 확인이 유일한 최종 방어선이다. |
|
| 20 |
+ * 백엔드에 값도 변경 API도 없어 한동안 막아 두었던 경로다. edupay-backend develop 4d98756에서 |
|
| 21 |
+ * 목록 응답에 `useYn`이 실리고 변경 API가 생기면서 되살렸다. |
|
| 22 |
+ * |
|
| 23 |
+ * 인증 확인을 본문 맨 앞에 둔다 — Server Action은 UI를 거치지 않고 직접 POST될 수 있어 이 |
|
| 24 |
+ * 확인이 유일한 최종 방어선이다. 실패 사유는 일반화된 문구만 화면으로 보낸다(백엔드 message |
|
| 25 |
+ * 원문에는 내부 정보가 실릴 수 있다). |
|
| 27 | 26 |
*/ |
| 28 | 27 |
export async function updateStudentActiveStatus( |
| 29 |
- _prevState: UpdateStudentActiveStatusState |
|
| 28 |
+ _prevState: UpdateStudentActiveStatusState, |
|
| 29 |
+ formData: FormData |
|
| 30 | 30 |
): Promise<UpdateStudentActiveStatusState> {
|
| 31 | 31 |
await verifySession(); |
| 32 | 32 |
|
| 33 |
- return { status: 'error', error: UNSUPPORTED_ERROR };
|
|
| 33 |
+ const id = formData.get('id');
|
|
| 34 |
+ if (typeof id !== 'string' || id.trim() === '') {
|
|
| 35 |
+ return { status: 'error', error: INVALID_INPUT_ERROR };
|
|
| 36 |
+ } |
|
| 37 |
+ |
|
| 38 |
+ // 체크박스는 켜졌을 때만 값이 실린다 — 없으면 꺼진 것이다. |
|
| 39 |
+ const isActive = formData.get('isActive') !== null;
|
|
| 40 |
+ |
|
| 41 |
+ const result = await updateStudentMemberUseYn(id, isActive); |
|
| 42 |
+ if (!result.ok) {
|
|
| 43 |
+ return { status: 'error', error: result.message };
|
|
| 44 |
+ } |
|
| 45 |
+ |
|
| 46 |
+ // 목록의 사용여부 열이 방금 바꾼 값을 반영해야 한다. |
|
| 47 |
+ revalidatePath(STUDENT_MEMBERS_PATH); |
|
| 48 |
+ return { status: 'success' };
|
|
| 34 | 49 |
} |
+++ app/(protected)/(basic)/students/_components/student-detail-modal.module.scss
... | ... | @@ -0,0 +1,19 @@ |
| 1 | +@use "@fox/styles/abstracts" as fox; | |
| 2 | + | |
| 3 | +// 학생 회원 조회 팝업 — 이 화면에만 필요한 배치. | |
| 4 | + | |
| 5 | +// FoxModal의 `contents`가 `align-items: flex-start`인 flex 열이라, 그 안의 블록 요소는 | |
| 6 | +// 내용 너비로 줄어든다. 폼은 조회 목록을 감싸기만 하므로 자리를 그대로 넘겨야 한다. | |
| 7 | +.form { | |
| 8 | + inline-size: 100%; | |
| 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,12 +1,16 @@ |
| 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, useState } from 'react';
|
|
| 4 |
+import { FoxButton } from '@fox/core/components/fox-button';
|
|
| 9 | 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';
|
|
| 11 |
+import { formatPhoneNumber } from '@/lib/domain/phone-number';
|
|
| 12 |
+import {
|
|
| 13 |
+ EMPTY_FIELD_PLACEHOLDER, |
|
| 10 | 14 |
formatGradeClassNumber, |
| 11 | 15 |
formatOptionalValue, |
| 12 | 16 |
type StudentMember, |
... | ... | @@ -15,148 +19,150 @@ |
| 15 | 19 |
updateStudentActiveStatus, |
| 16 | 20 |
type UpdateStudentActiveStatusState, |
| 17 | 21 |
} from '../_actions'; |
| 22 |
+import styles from './student-detail-modal.module.scss'; |
|
| 18 | 23 |
|
| 19 | 24 |
interface StudentDetailModalProps {
|
| 20 | 25 |
member: StudentMember; |
| 21 | 26 |
onClose: () => void; |
| 22 | 27 |
} |
| 23 | 28 |
|
| 24 |
-const ACTIVE_STATUS_OPTIONS = [ |
|
| 25 |
- { value: 'true', label: '활성' },
|
|
| 26 |
- { value: 'false', label: '비활성' },
|
|
| 27 |
-]; |
|
| 28 |
- |
|
| 29 | 29 |
const INITIAL_STATE: UpdateStudentActiveStatusState = { status: 'idle' };
|
| 30 | 30 |
const DETAIL_FORM_ID = 'student-active-status-form'; |
| 31 | 31 |
|
| 32 |
-/** |
|
| 33 |
- * 사용여부 수정 가능 여부의 단일 토글. 백엔드에 사용여부 값도 변경 API도 없어 현재는 false다 — |
|
| 34 |
- * 변경 API가 생기면 이 상수를 true로 되돌리고 `_actions.ts`의 미지원 처리를 실제 구현으로 |
|
| 35 |
- * 바꾸면 된다(라디오·저장 버튼의 비활성이 함께 풀린다). |
|
| 36 |
- */ |
|
| 37 |
-const IS_ACTIVE_STATUS_EDITABLE = false; |
|
| 38 |
-const ACTIVE_STATUS_UNSUPPORTED_NOTE = |
|
| 39 |
- '사용여부 변경은 백엔드 API 준비 후 제공됩니다.'; |
|
| 32 |
+/** 시안은 두 값을 " / "로 묶는다("홍라애 / 010-2222-1234"). 둘 다 없으면 `-`다. */
|
|
| 33 |
+function joinPair(first: string | null, second: string | null) {
|
|
| 34 |
+ const parts = [first, second].filter( |
|
| 35 |
+ (value): value is string => value !== null && value !== '' |
|
| 36 |
+ ); |
|
| 37 |
+ return parts.length > 0 ? parts.join(' / ') : '-';
|
|
| 38 |
+} |
|
| 40 | 39 |
|
| 41 | 40 |
/** |
| 42 |
- * 학생 회원 조회 팝업 — 모든 항목이 readOnly다. 데이터는 목록 행에서 이미 갖고 있는 member를 |
|
| 43 |
- * props로 그대로 받으므로 재조회하지 않는다(백엔드에 단건 조회 API도 아직 없다). |
|
| 44 |
- * 백엔드가 주지 않는 항목은 `-`로 표시한다. |
|
| 41 |
+ * 학생 회원 조회 팝업 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) modal 5227:809 |
|
| 45 | 42 |
* |
| 46 |
- * 사용여부는 원래 이 팝업에서 유일하게 수정 가능한 항목이었지만, 백엔드 목록 응답에 값이 없고 |
|
| 47 |
- * 변경 API도 없어 지금은 라디오·저장 버튼을 비활성으로 둔다(`_actions.ts`의 미지원 처리와 짝). |
|
| 48 |
- * 폼·Server Action 배선은 그대로 남겨 두었으므로 변경 API가 생기면 `disabled`만 걷어내면 된다. |
|
| 49 |
- * 저장(Server Action) 성공 시 자동으로 닫히는 동작도 그대로다. |
|
| 43 |
+ * 모든 항목이 조회 전용이다. 데이터는 목록 행이 이미 갖고 있는 member를 그대로 받으므로 |
|
| 44 |
+ * 재조회하지 않는다(백엔드에 단건 조회 API도 아직 없다). 백엔드가 주지 않는 값은 `-`다. |
|
| 50 | 45 |
* |
| 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 위치에 렌더돼도 안전하다. |
|
| 46 |
+ * 시안은 보호자(이름/연락처)와 학교(학교/학년·반·번호)를 각각 한 줄로 묶는다 — 종전처럼 |
|
| 47 |
+ * 항목을 따로 두지 않고 시안의 8줄 구성을 그대로 따랐다. |
|
| 48 |
+ * |
|
| 49 |
+ * 사용여부는 이 팝업에서 유일하게 수정 가능한 항목이다. 토글을 넘기면 화면이 바로 반응하고, |
|
| 50 |
+ * 저장을 누르면 `PUT /api/v1/mngr/user/{userId}/{useYn}`로 나간다.
|
|
| 51 |
+ * |
|
| 52 |
+ * 저장 버튼은 `form={DETAIL_FORM_ID}`로 폼과 이어 둔다 — FoxModal이 버튼을 `foot` 슬롯에
|
|
| 53 |
+ * 그려 폼의 자손이 아니게 되는데, 네이티브 `form` 속성은 같은 문서 안에서 id만 맞으면 그 |
|
| 54 |
+ * 폼을 제출한다. |
|
| 55 | 55 |
*/ |
| 56 | 56 |
export function StudentDetailModal({ member, onClose }: StudentDetailModalProps) {
|
| 57 | 57 |
const [state, formAction, isPending] = useActionState( |
| 58 | 58 |
updateStudentActiveStatus, |
| 59 | 59 |
INITIAL_STATE |
| 60 | 60 |
); |
| 61 |
+ const formRef = useRef<HTMLFormElement>(null); |
|
| 62 |
+ // 토글은 화면에서 바로 반응해야 하므로 이 컴포넌트가 상태를 갖는다 — prop에서 계산하면 |
|
| 63 |
+ // 스위치를 넘겨도 라벨이 그대로다. 저장하면 이 값이 폼에 실려 나간다. |
|
| 64 |
+ const [isActive, setIsActive] = useState(member.isActive ?? false); |
|
| 61 | 65 |
|
| 66 |
+ // 학년·반이 하나도 없으면 `-`가 오는데, 그때는 "학교 / -"가 아니라 학교만 보여야 한다. |
|
| 67 |
+ const formattedGradeClass = formatGradeClassNumber(member); |
|
| 68 |
+ const gradeClassNumber = |
|
| 69 |
+ formattedGradeClass === EMPTY_FIELD_PLACEHOLDER ? null : formattedGradeClass; |
|
| 70 |
+ |
|
| 71 |
+ // 저장에 성공하면 닫는다. 목록은 Server Action의 revalidatePath가 다시 그린다. |
|
| 62 | 72 |
useEffect(() => {
|
| 63 | 73 |
if (state.status === 'success') {
|
| 64 | 74 |
onClose(); |
| 65 | 75 |
} |
| 66 | 76 |
}, [state, onClose]); |
| 67 | 77 |
|
| 68 |
- const gradeClassNumber = formatGradeClassNumber(member); |
|
| 78 |
+ const items: FoxDescriptionItem[] = [ |
|
| 79 |
+ { key: 'name', term: '이름', description: member.name },
|
|
| 80 |
+ { key: 'loginId', term: 'ID', description: member.loginId },
|
|
| 81 |
+ {
|
|
| 82 |
+ key: 'phoneNumber', |
|
| 83 |
+ term: '휴대전화 번호', |
|
| 84 |
+ description: formatOptionalValue(formatPhoneNumber(member.phoneNumber)), |
|
| 85 |
+ }, |
|
| 86 |
+ {
|
|
| 87 |
+ key: 'email', |
|
| 88 |
+ term: '이메일', |
|
| 89 |
+ description: formatOptionalValue(member.email), |
|
| 90 |
+ }, |
|
| 91 |
+ {
|
|
| 92 |
+ key: 'birthDate', |
|
| 93 |
+ term: '생년월일', |
|
| 94 |
+ description: formatOptionalValue(member.birthDate), |
|
| 95 |
+ }, |
|
| 96 |
+ {
|
|
| 97 |
+ key: 'guardian', |
|
| 98 |
+ term: '보호자 이름 / 연락처', |
|
| 99 |
+ description: joinPair( |
|
| 100 |
+ member.guardianName, |
|
| 101 |
+ formatPhoneNumber(member.guardianPhoneNumber) |
|
| 102 |
+ ), |
|
| 103 |
+ }, |
|
| 104 |
+ {
|
|
| 105 |
+ key: 'school', |
|
| 106 |
+ term: '학교 / 학년·반·번호', |
|
| 107 |
+ description: joinPair(member.schoolName, gradeClassNumber), |
|
| 108 |
+ }, |
|
| 109 |
+ {
|
|
| 110 |
+ key: 'isActive', |
|
| 111 |
+ term: '사용여부', |
|
| 112 |
+ description: ( |
|
| 113 |
+ <FoxToggleSwitch |
|
| 114 |
+ name="isActive" |
|
| 115 |
+ value="true" |
|
| 116 |
+ label={isActive ? '활성' : '비활성'}
|
|
| 117 |
+ checked={isActive}
|
|
| 118 |
+ onChange={(checked) => setIsActive(checked)}
|
|
| 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 |
+ utility={
|
|
| 131 |
+ state.status === 'error' ? ( |
|
| 132 |
+ <p className={styles.error} role="alert">
|
|
| 133 |
+ {state.error}
|
|
| 134 |
+ </p> |
|
| 135 |
+ ) : undefined |
|
| 136 |
+ } |
|
| 137 |
+ actions={
|
|
| 75 | 138 |
<> |
| 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}
|
|
| 84 |
- title={
|
|
| 85 |
- IS_ACTIVE_STATUS_EDITABLE |
|
| 86 |
- ? undefined |
|
| 87 |
- : ACTIVE_STATUS_UNSUPPORTED_NOTE |
|
| 88 |
- } |
|
| 89 |
- > |
|
| 90 |
- {isPending ? '저장 중...' : '저장'}
|
|
| 91 |
- </Button> |
|
| 139 |
+ <FoxButton |
|
| 140 |
+ type="default" |
|
| 141 |
+ size="md" |
|
| 142 |
+ label="취소" |
|
| 143 |
+ onAction={onClose}
|
|
| 144 |
+ /> |
|
| 145 |
+ {/* FoxButton에는 폼을 가리키는 prop이 없다(네이티브 `form` 속성 미노출). 버튼이
|
|
| 146 |
+ foot 슬롯에 그려져 폼의 자손이 아니므로 ref로 직접 제출한다. */} |
|
| 147 |
+ <FoxButton |
|
| 148 |
+ type="primary" |
|
| 149 |
+ size="md" |
|
| 150 |
+ label={isPending ? '저장 중...' : '저장'}
|
|
| 151 |
+ disabled={isPending}
|
|
| 152 |
+ onAction={() => formRef.current?.requestSubmit()}
|
|
| 153 |
+ /> |
|
| 92 | 154 |
</> |
| 93 | 155 |
} |
| 94 | 156 |
> |
| 95 |
- <form id={DETAIL_FORM_ID} action={formAction} className="flex flex-col gap-4">
|
|
| 157 |
+ <form |
|
| 158 |
+ ref={formRef}
|
|
| 159 |
+ id={DETAIL_FORM_ID}
|
|
| 160 |
+ action={formAction}
|
|
| 161 |
+ className={styles.form}
|
|
| 162 |
+ > |
|
| 96 | 163 |
<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> |
|
| 164 |
+ <FoxDescriptionList items={items} />
|
|
| 159 | 165 |
</form> |
| 160 |
- </Modal> |
|
| 166 |
+ </FoxModal> |
|
| 161 | 167 |
); |
| 162 | 168 |
} |
--- app/(protected)/(basic)/students/_components/student-list-toolbar.tsx
... | ... | @@ -1,86 +0,0 @@ |
| 1 | -'use client'; | |
| 2 | - | |
| 3 | -import { useRouter } from 'next/navigation'; | |
| 4 | -import type { ChangeEvent } from 'react'; | |
| 5 | -import { Button } from '@/components/ui/button'; | |
| 6 | -import { Select } from '@/components/ui/select'; | |
| 7 | -import { | |
| 8 | - STUDENT_MEMBERS_EXCEL_PATH, | |
| 9 | - STUDENT_MEMBER_PAGE_SIZE_OPTIONS, | |
| 10 | - STUDENT_MEMBER_SORT_OPTIONS, | |
| 11 | - buildStudentMemberHref, | |
| 12 | - type StudentMemberPageSize, | |
| 13 | - type StudentMemberQuery, | |
| 14 | - type StudentMemberSortOption, | |
| 15 | -} from '@/lib/domain/student-member-query'; | |
| 16 | - | |
| 17 | -interface StudentListToolbarProps { | |
| 18 | - query: StudentMemberQuery; | |
| 19 | -} | |
| 20 | - | |
| 21 | -/** | |
| 22 | - * 목록 툴바 — 정렬/페이지 크기 select는 변경 즉시 URL을 갱신해 서버 데이터를 다시 조회하게 | |
| 23 | - * 한다(select의 onChange가 필요해 이 컴포넌트만 최말단 클라이언트 경계로 분리했다). 두 값 모두 | |
| 24 | - * 바뀌면 1페이지로 되돌린다(기존 페이지 번호가 새 정렬·크기 기준으로는 의미가 달라지므로). | |
| 25 | - * 엑셀다운로드는 이번 범위에서 버튼 배치만 하고 동작은 구현하지 않는다(사용자 지시) — | |
| 26 | - * disabled로 두어 클릭해도 아무 일도 일어나지 않게 한다. | |
| 27 | - * | |
| 28 | - * 정렬 select는 URL의 `sort`만 바꾸고, 그 값을 어떻게 만족시킬지는 Repository가 정한다 — | |
| 29 | - * 가입일순은 백엔드의 고정 순서와 같아 그대로 받아오고, 이름순은 백엔드에 정렬 파라미터가 없어 | |
| 30 | - * 전체를 받아 서버에서 정렬한다. 화면은 그 차이를 알 필요가 없다. | |
| 31 | - */ | |
| 32 | -export function StudentListToolbar({ query }: StudentListToolbarProps) { | |
| 33 | - const router = useRouter(); | |
| 34 | - | |
| 35 | - function handleSortChange(event: ChangeEvent<HTMLSelectElement>) { | |
| 36 | - const sort = event.target.value as StudentMemberSortOption; | |
| 37 | - router.replace(buildStudentMemberHref(query, { sort, page: 1 })); | |
| 38 | - } | |
| 39 | - | |
| 40 | - function handlePageSizeChange(event: ChangeEvent<HTMLSelectElement>) { | |
| 41 | - const pageSize = Number(event.target.value) as StudentMemberPageSize; | |
| 42 | - router.replace(buildStudentMemberHref(query, { pageSize, page: 1 })); | |
| 43 | - } | |
| 44 | - | |
| 45 | - return ( | |
| 46 | - <div className="flex flex-wrap items-center justify-between gap-3"> | |
| 47 | - {/* 네이티브 GET 폼 제출 — 응답이 첨부파일(Content-Disposition: attachment)이라 브라우저가 | |
| 48 | - 현재 화면을 그대로 둔 채 파일만 내려받는다. 라우터 내비게이션(router.push)으로는 | |
| 49 | - 첨부파일 응답을 처리할 수 없고, Button은 <button>이라 링크로 쓸 수 없어 폼으로 감쌌다. | |
| 50 | - 검색·페이징 값을 hidden으로 싣지 않는 것이 사양이다 — 파일은 항상 전체 데이터다. */} | |
| 51 | - <form action={STUDENT_MEMBERS_EXCEL_PATH} method="get"> | |
| 52 | - <Button type="submit" variant="primary"> | |
| 53 | - 엑셀다운로드 | |
| 54 | - </Button> | |
| 55 | - </form> | |
| 56 | - | |
| 57 | - <div className="flex items-center gap-3"> | |
| 58 | - <Select | |
| 59 | - name="sort" | |
| 60 | - aria-label="정렬" | |
| 61 | - defaultValue={query.sort} | |
| 62 | - onChange={handleSortChange} | |
| 63 | - > | |
| 64 | - {STUDENT_MEMBER_SORT_OPTIONS.map((option) => ( | |
| 65 | - <option key={option.value} value={option.value}> | |
| 66 | - {option.label} | |
| 67 | - </option> | |
| 68 | - ))} | |
| 69 | - </Select> | |
| 70 | - | |
| 71 | - <Select | |
| 72 | - name="pageSize" | |
| 73 | - aria-label="페이지 크기" | |
| 74 | - defaultValue={query.pageSize} | |
| 75 | - onChange={handlePageSizeChange} | |
| 76 | - > | |
| 77 | - {STUDENT_MEMBER_PAGE_SIZE_OPTIONS.map((size) => ( | |
| 78 | - <option key={size} value={size}> | |
| 79 | - {size}개씩 | |
| 80 | - </option> | |
| 81 | - ))} | |
| 82 | - </Select> | |
| 83 | - </div> | |
| 84 | - </div> | |
| 85 | - ); | |
| 86 | -} |
+++ app/(protected)/(basic)/students/_components/student-list.tsx
... | ... | @@ -0,0 +1,217 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useRouter } from 'next/navigation'; | |
| 4 | +import { useState } from 'react'; | |
| 5 | +import { FoxBadge } from '@fox/core/components/fox-badge'; | |
| 6 | +import { FoxButton } from '@fox/core/components/fox-button'; | |
| 7 | +import { | |
| 8 | + FoxListContainer, | |
| 9 | + type FoxListColumn, | |
| 10 | +} from '@fox/core/components/fox-list-container'; | |
| 11 | +import { FoxSelectText } from '@fox/core/components/fox-select-text'; | |
| 12 | +import { | |
| 13 | + FoxArrowsDownUpIcon, | |
| 14 | + FoxDownloadSimpleIcon, | |
| 15 | +} from '@fox/core/icons'; | |
| 16 | +import { formatPhoneNumber } from '@/lib/domain/phone-number'; | |
| 17 | +import { | |
| 18 | + formatGradeClassNumber, | |
| 19 | + type StudentMember, | |
| 20 | +} from '@/lib/domain/student-member'; | |
| 21 | +import { | |
| 22 | + STUDENT_MEMBERS_EXCEL_PATH, | |
| 23 | + STUDENT_MEMBER_PAGE_SIZE_OPTIONS, | |
| 24 | + STUDENT_MEMBER_SEARCH_FIELD_OPTIONS, | |
| 25 | + STUDENT_MEMBER_SORT_OPTIONS, | |
| 26 | + buildStudentMemberHref, | |
| 27 | + type StudentMemberPageSize, | |
| 28 | + type StudentMemberQuery, | |
| 29 | + type StudentMemberSearchField, | |
| 30 | + type StudentMemberSortOption, | |
| 31 | +} from '@/lib/domain/student-member-query'; | |
| 32 | +import { StudentDetailModal } from './student-detail-modal'; | |
| 33 | + | |
| 34 | +interface StudentListProps { | |
| 35 | + items: StudentMember[]; | |
| 36 | + query: StudentMemberQuery; | |
| 37 | + /** 화면이 실제로 보여주는 페이지 — 순번 계산의 기준이다. */ | |
| 38 | + currentPage: number; | |
| 39 | + totalPages: number; | |
| 40 | + totalCount: number; | |
| 41 | +} | |
| 42 | + | |
| 43 | +/** 값이 없으면 시안처럼 자리를 비우지 않고 `-`로 채운다. */ | |
| 44 | +function text(value: string | number | null | undefined) { | |
| 45 | + return value === null || value === undefined || value === '' ? '-' : String(value); | |
| 46 | +} | |
| 47 | + | |
| 48 | +/** | |
| 49 | + * 학생 회원 목록 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) container 5199:9014 | |
| 50 | + * | |
| 51 | + * 뼈대는 `FoxListContainer`가 전부 그린다. 이 파일이 갖는 것은 **이 화면 고유의 것**뿐이다 — | |
| 52 | + * 13개 열의 정의, 정렬·페이지 크기 셀렉트, 엑셀 다운로드, 그리고 URL을 갱신하는 방법. | |
| 53 | + * | |
| 54 | + * 이동은 두 갈래로 나뉜다. 페이지네이션은 `buildHref`로 **링크**가 되고(주소가 곧 상태라 | |
| 55 | + * 새로고침·뒤로가기·공유가 그대로 동작한다), 검색·정렬·페이지 크기는 컨트롤이 눌린 순간 | |
| 56 | + * `router.replace`로 같은 주소 규칙을 태운다. 어느 쪽이든 목록을 다시 그리는 것은 서버다. | |
| 57 | + * | |
| 58 | + * 순번은 `query.page`가 아니라 `currentPage`를 기준으로 센다 — 백엔드가 범위를 벗어난 페이지 | |
| 59 | + * 요청을 잘라서 응답하면 둘이 어긋나, "1/1 페이지"인데 순번은 31부터 시작하는 상태가 된다. | |
| 60 | + */ | |
| 61 | +export function StudentList({ | |
| 62 | + items, | |
| 63 | + query, | |
| 64 | + currentPage, | |
| 65 | + totalPages, | |
| 66 | + totalCount, | |
| 67 | +}: StudentListProps) { | |
| 68 | + const router = useRouter(); | |
| 69 | + const [detailMember, setDetailMember] = useState<StudentMember | null>(null); | |
| 70 | + | |
| 71 | + /** 목록 조건이 바뀌면 늘 1페이지로 되돌린다 — 이전 페이지 번호는 새 조건에서 의미가 다르다. */ | |
| 72 | + function go(patch: Partial<StudentMemberQuery>) { | |
| 73 | + router.replace(buildStudentMemberHref(query, { ...patch, page: 1 })); | |
| 74 | + } | |
| 75 | + | |
| 76 | + const columns: FoxListColumn<StudentMember>[] = [ | |
| 77 | + { | |
| 78 | + key: 'no', | |
| 79 | + header: '번호', | |
| 80 | + width: 80, | |
| 81 | + // 저장된 값이 아니라 현재 페이지 기준 표시 순번이다(종전 표와 같은 규칙). | |
| 82 | + render: (_row, index) => (currentPage - 1) * query.pageSize + index + 1, | |
| 83 | + }, | |
| 84 | + { key: 'memberCode', header: '회원코드', width: 120 }, | |
| 85 | + { key: 'name', header: '이름', width: 120 }, | |
| 86 | + { key: 'loginId', header: '아이디', width: 160 }, | |
| 87 | + { | |
| 88 | + key: 'phoneNumber', | |
| 89 | + header: '휴대전화번호', | |
| 90 | + width: 160, | |
| 91 | + render: (row) => text(formatPhoneNumber(row.phoneNumber)), | |
| 92 | + }, | |
| 93 | + { key: 'email', header: '이메일', width: 200, render: (row) => text(row.email) }, | |
| 94 | + { | |
| 95 | + key: 'role', | |
| 96 | + header: '역할', | |
| 97 | + width: 120, | |
| 98 | + render: (row) => ( | |
| 99 | + <FoxBadge type="pastel" color="primary" size="md"> | |
| 100 | + {row.role} | |
| 101 | + </FoxBadge> | |
| 102 | + ), | |
| 103 | + }, | |
| 104 | + { | |
| 105 | + key: 'schoolName', | |
| 106 | + header: '학교', | |
| 107 | + width: 160, | |
| 108 | + render: (row) => text(row.schoolName), | |
| 109 | + }, | |
| 110 | + { key: 'classInfo', header: '학년/반/번호', width: 160, render: formatGradeClassNumber }, | |
| 111 | + { | |
| 112 | + key: 'guardianName', | |
| 113 | + header: '보호자이름', | |
| 114 | + width: 120, | |
| 115 | + render: (row) => text(row.guardianName), | |
| 116 | + }, | |
| 117 | + { | |
| 118 | + key: 'guardianPhoneNumber', | |
| 119 | + header: '보호자연락처', | |
| 120 | + width: 160, | |
| 121 | + render: (row) => text(formatPhoneNumber(row.guardianPhoneNumber)), | |
| 122 | + }, | |
| 123 | + { | |
| 124 | + key: 'joinedAt', | |
| 125 | + header: '가입일', | |
| 126 | + width: 160, | |
| 127 | + render: (row) => text(row.joinedAt), | |
| 128 | + }, | |
| 129 | + ]; | |
| 130 | + | |
| 131 | + return ( | |
| 132 | + <> | |
| 133 | + <FoxListContainer<StudentMember> | |
| 134 | + title="학생 회원 목록" | |
| 135 | + breadcrumb={[ | |
| 136 | + { label: '홈', href: '/' }, | |
| 137 | + { label: '회원정보관리' }, | |
| 138 | + { label: '학생 회원' }, | |
| 139 | + ]} | |
| 140 | + caption="학생 회원 목록" | |
| 141 | + columns={columns} | |
| 142 | + rows={items} | |
| 143 | + rowKey={(row) => row.id} | |
| 144 | + totalCount={totalCount} | |
| 145 | + emptyMessage="검색 결과가 없습니다. 다른 검색어로 다시 시도해 주세요." | |
| 146 | + sorts={ | |
| 147 | + <> | |
| 148 | + <FoxSelectText | |
| 149 | + size="sm" | |
| 150 | + ariaLabel="정렬" | |
| 151 | + icon={<FoxArrowsDownUpIcon />} | |
| 152 | + options={STUDENT_MEMBER_SORT_OPTIONS.map((option) => ({ | |
| 153 | + value: option.value, | |
| 154 | + label: option.label, | |
| 155 | + }))} | |
| 156 | + value={query.sort} | |
| 157 | + onValueChange={(value) => go({ sort: value as StudentMemberSortOption })} | |
| 158 | + /> | |
| 159 | + <FoxSelectText | |
| 160 | + size="sm" | |
| 161 | + ariaLabel="페이지 크기" | |
| 162 | + options={STUDENT_MEMBER_PAGE_SIZE_OPTIONS.map((size) => ({ | |
| 163 | + value: String(size), | |
| 164 | + label: `${size}개씩 보기`, | |
| 165 | + }))} | |
| 166 | + value={String(query.pageSize)} | |
| 167 | + onValueChange={(value) => | |
| 168 | + go({ pageSize: Number(value) as StudentMemberPageSize }) | |
| 169 | + } | |
| 170 | + /> | |
| 171 | + </> | |
| 172 | + } | |
| 173 | + searchFields={STUDENT_MEMBER_SEARCH_FIELD_OPTIONS.map((option) => ({ | |
| 174 | + value: option.value, | |
| 175 | + label: option.label, | |
| 176 | + }))} | |
| 177 | + searchField={query.searchField} | |
| 178 | + keyword={query.keyword} | |
| 179 | + toolbarActions={ | |
| 180 | + // 네이티브 GET 폼이라야 한다 — 응답이 첨부파일(Content-Disposition)이라 라우터 | |
| 181 | + // 내비게이션으로는 처리할 수 없고, 브라우저가 화면을 둔 채 파일만 내려받는다. | |
| 182 | + // 검색·페이징 값을 싣지 않는 것이 사양이다(파일은 항상 전체 데이터). | |
| 183 | + <form action={STUDENT_MEMBERS_EXCEL_PATH} method="get"> | |
| 184 | + <FoxButton | |
| 185 | + type="secondary" | |
| 186 | + size="md" | |
| 187 | + htmlType="submit" | |
| 188 | + leadingIcon={<FoxDownloadSimpleIcon />} | |
| 189 | + label="엑셀 다운로드" | |
| 190 | + /> | |
| 191 | + </form> | |
| 192 | + } | |
| 193 | + onRowSelect={(row) => setDetailMember(row)} | |
| 194 | + page={currentPage} | |
| 195 | + totalPages={totalPages} | |
| 196 | + buildHref={(patch) => | |
| 197 | + buildStudentMemberHref(query, { page: Number(patch.page ?? 1) }) | |
| 198 | + } | |
| 199 | + onQueryChange={(patch) => { | |
| 200 | + if (patch.keyword !== undefined || patch.searchField !== undefined) { | |
| 201 | + go({ | |
| 202 | + keyword: String(patch.keyword ?? ''), | |
| 203 | + searchField: (patch.searchField ?? | |
| 204 | + query.searchField) as StudentMemberSearchField, | |
| 205 | + }); | |
| 206 | + } | |
| 207 | + }} | |
| 208 | + /> | |
| 209 | + {detailMember && ( | |
| 210 | + <StudentDetailModal | |
| 211 | + member={detailMember} | |
| 212 | + onClose={() => setDetailMember(null)} | |
| 213 | + /> | |
| 214 | + )} | |
| 215 | + </> | |
| 216 | + ); | |
| 217 | +} |
--- app/(protected)/(basic)/students/_components/student-row-actions.tsx
... | ... | @@ -1,42 +0,0 @@ |
| 1 | -'use client'; | |
| 2 | - | |
| 3 | -import { useState } from 'react'; | |
| 4 | -import { FoxIconButton } from '@fox/core/components/fox-icon-button'; | |
| 5 | -import { FoxMagnifyingGlassIcon } from '@fox/core/icons'; | |
| 6 | -import type { StudentMember } from '@/lib/domain/student-member'; | |
| 7 | -import { StudentDetailModal } from './student-detail-modal'; | |
| 8 | - | |
| 9 | -interface StudentRowActionsProps { | |
| 10 | - member: StudentMember; | |
| 11 | -} | |
| 12 | - | |
| 13 | -/** | |
| 14 | - * 목록 행의 "관리" 셀 — 조회 팝업의 열림 상태만 소유하는 최말단 상호작용 경계다(테이블 전체를 | |
| 15 | - * 클라이언트로 내리지 않기 위해 이 셀만 분리했다 — §2.4 "'use client'는 최말단에만"). 팝업에 | |
| 16 | - * 필요한 데이터는 이미 이 행이 갖고 있는 member를 그대로 전달한다(재조회 없음). | |
| 17 | - * | |
| 18 | - * 시안의 "돋보기 아이콘 버튼"이다. 버튼은 `components/ui/button`이 아니라 `FoxIconButton`을 | |
| 19 | - * 쓴다 — 아이콘은 스스로 크기를 갖지 않고 감싼 슬롯이 정하는데, 그 슬롯 크기를 실제로 | |
| 20 | - * 정의하는 건 @fox 쪽뿐이다(components/ui는 Tailwind 제거 이후 무스타일이다). | |
| 21 | - */ | |
| 22 | -export function StudentRowActions({ member }: StudentRowActionsProps) { | |
| 23 | - const [isDetailOpen, setIsDetailOpen] = useState(false); | |
| 24 | - | |
| 25 | - return ( | |
| 26 | - <> | |
| 27 | - <FoxIconButton | |
| 28 | - type="ghost" | |
| 29 | - size="sm" | |
| 30 | - icon={<FoxMagnifyingGlassIcon />} | |
| 31 | - label="조회" | |
| 32 | - onAction={() => setIsDetailOpen(true)} | |
| 33 | - /> | |
| 34 | - {isDetailOpen && ( | |
| 35 | - <StudentDetailModal | |
| 36 | - member={member} | |
| 37 | - onClose={() => setIsDetailOpen(false)} | |
| 38 | - /> | |
| 39 | - )} | |
| 40 | - </> | |
| 41 | - ); | |
| 42 | -} |
--- app/(protected)/(basic)/students/_components/student-search-bar.tsx
... | ... | @@ -1,72 +0,0 @@ |
| 1 | -import Link from 'next/link'; | |
| 2 | -import { Button } from '@/components/ui/button'; | |
| 3 | -import { Field } from '@/components/ui/field'; | |
| 4 | -import { Input } from '@/components/ui/input'; | |
| 5 | -import { Select } from '@/components/ui/select'; | |
| 6 | -import { | |
| 7 | - DEFAULT_STUDENT_MEMBER_SEARCH_FIELD, | |
| 8 | - STUDENT_MEMBERS_PATH, | |
| 9 | - STUDENT_MEMBER_SEARCH_FIELD_OPTIONS, | |
| 10 | - buildStudentMemberHref, | |
| 11 | - type StudentMemberQuery, | |
| 12 | -} from '@/lib/domain/student-member-query'; | |
| 13 | - | |
| 14 | -interface StudentSearchBarProps { | |
| 15 | - query: StudentMemberQuery; | |
| 16 | -} | |
| 17 | - | |
| 18 | -/** | |
| 19 | - * 검색 영역 — 순수 `<form method="get">` GET 제출로 URL을 갱신한다(자바스크립트 없이도 | |
| 20 | - * 동작). 현재 `sort`·`pageSize`는 hidden input으로 함께 제출해 검색 후에도 유지되게 하고, | |
| 21 | - * 검색 자체는 항상 1페이지로 돌아간다(hidden page 필드를 두지 않아 parseStudentMemberQuery의 | |
| 22 | - * 기본값 1로 자연스럽게 떨어진다). "초기화"는 폼 제출이 아니라 기본값으로 되돌린 링크라 | |
| 23 | - * 현재 입력값과 무관하게 동작한다. 상호작용이 전부 네이티브 폼 제출/링크 내비게이션이라 | |
| 24 | - * 클라이언트 자바스크립트가 필요 없다 — Server Component로 유지한다. | |
| 25 | - */ | |
| 26 | -export function StudentSearchBar({ query }: StudentSearchBarProps) { | |
| 27 | - const resetHref = buildStudentMemberHref(query, { | |
| 28 | - searchField: DEFAULT_STUDENT_MEMBER_SEARCH_FIELD, | |
| 29 | - keyword: '', | |
| 30 | - page: 1, | |
| 31 | - }); | |
| 32 | - | |
| 33 | - return ( | |
| 34 | - <form | |
| 35 | - method="get" | |
| 36 | - action={STUDENT_MEMBERS_PATH} | |
| 37 | - className="flex flex-wrap items-end gap-3" | |
| 38 | - > | |
| 39 | - <input type="hidden" name="sort" value={query.sort} /> | |
| 40 | - <input type="hidden" name="pageSize" value={query.pageSize} /> | |
| 41 | - | |
| 42 | - <Field label="검색 대상"> | |
| 43 | - <Select name="searchField" defaultValue={query.searchField}> | |
| 44 | - {STUDENT_MEMBER_SEARCH_FIELD_OPTIONS.map((option) => ( | |
| 45 | - <option key={option.value} value={option.value}> | |
| 46 | - {option.label} | |
| 47 | - </option> | |
| 48 | - ))} | |
| 49 | - </Select> | |
| 50 | - </Field> | |
| 51 | - | |
| 52 | - <Field label="검색어"> | |
| 53 | - <Input | |
| 54 | - type="text" | |
| 55 | - name="keyword" | |
| 56 | - defaultValue={query.keyword} | |
| 57 | - placeholder="검색어를 입력하세요" | |
| 58 | - /> | |
| 59 | - </Field> | |
| 60 | - | |
| 61 | - <Button type="submit" variant="secondary"> | |
| 62 | - 검색 | |
| 63 | - </Button> | |
| 64 | - <Link | |
| 65 | - href={resetHref} | |
| 66 | - className="text-body-md text-foreground-muted underline-offset-2 hover:underline" | |
| 67 | - > | |
| 68 | - 초기화 | |
| 69 | - </Link> | |
| 70 | - </form> | |
| 71 | - ); | |
| 72 | -} |
--- app/(protected)/(basic)/students/_components/student-table.tsx
... | ... | @@ -1,86 +0,0 @@ |
| 1 | -import { | |
| 2 | - Table, | |
| 3 | - TableBody, | |
| 4 | - TableCell, | |
| 5 | - TableHead, | |
| 6 | - TableHeaderCell, | |
| 7 | - TableRow, | |
| 8 | -} from '@/components/ui/table'; | |
| 9 | -import { | |
| 10 | - formatGradeClassNumber, | |
| 11 | - formatOptionalValue, | |
| 12 | - type StudentMember, | |
| 13 | -} from '@/lib/domain/student-member'; | |
| 14 | -import { StudentRowActions } from './student-row-actions'; | |
| 15 | - | |
| 16 | -interface StudentTableProps { | |
| 17 | - items: StudentMember[]; | |
| 18 | - page: number; | |
| 19 | - pageSize: number; | |
| 20 | -} | |
| 21 | - | |
| 22 | -const COLUMN_HEADERS = [ | |
| 23 | - '번호', | |
| 24 | - '회원코드', | |
| 25 | - '이름', | |
| 26 | - '아이디', | |
| 27 | - '휴대전화번호', | |
| 28 | - '이메일', | |
| 29 | - '역할', | |
| 30 | - '학교', | |
| 31 | - '학년/반/번호', | |
| 32 | - '보호자이름', | |
| 33 | - '보호자연락처', | |
| 34 | - '가입일', | |
| 35 | - '관리', | |
| 36 | -]; | |
| 37 | - | |
| 38 | -/** | |
| 39 | - * 학생 회원 목록 테이블 — 조회 전용(등록/수정/삭제 없음). "번호"는 저장된 값이 아니라 | |
| 40 | - * 현재 페이지 기준 표시 순번(= (page-1)*pageSize + 행 인덱스 + 1)이다. 개인정보 마스킹은 | |
| 41 | - * 기획 시안에서 명시적으로 해제 지시가 있어 원본 값을 그대로 노출한다. | |
| 42 | - * | |
| 43 | - * 백엔드가 아직 주지 않는 항목(휴대전화·이메일·학교·학년/반/번호·보호자·가입일)은 열을 그대로 | |
| 44 | - * 유지한 채 `-`로 표시한다 — 백엔드가 필드를 추가하면 Repository 매핑만 늘리면 이 파일은 | |
| 45 | - * 그대로 값이 채워진다. | |
| 46 | - * | |
| 47 | - * "관리" 열은 행별 조회 팝업 트리거(StudentRowActions)에 위임한다 — 상호작용이 필요한 것은 | |
| 48 | - * 그 셀뿐이라 이 테이블 자체는 Server Component로 유지하고 최말단만 클라이언트 경계로 뗀다 | |
| 49 | - * (§2.4 "'use client'는 최말단에만"). | |
| 50 | - */ | |
| 51 | -export function StudentTable({ items, page, pageSize }: StudentTableProps) { | |
| 52 | - return ( | |
| 53 | - <Table> | |
| 54 | - <TableHead> | |
| 55 | - <TableRow> | |
| 56 | - {COLUMN_HEADERS.map((header) => ( | |
| 57 | - <TableHeaderCell key={header}>{header}</TableHeaderCell> | |
| 58 | - ))} | |
| 59 | - </TableRow> | |
| 60 | - </TableHead> | |
| 61 | - <TableBody> | |
| 62 | - {items.map((member, index) => ( | |
| 63 | - <TableRow key={member.id}> | |
| 64 | - <TableCell>{(page - 1) * pageSize + index + 1}</TableCell> | |
| 65 | - <TableCell>{member.memberCode}</TableCell> | |
| 66 | - <TableCell>{member.name}</TableCell> | |
| 67 | - <TableCell>{member.loginId}</TableCell> | |
| 68 | - <TableCell>{formatOptionalValue(member.phoneNumber)}</TableCell> | |
| 69 | - <TableCell>{formatOptionalValue(member.email)}</TableCell> | |
| 70 | - <TableCell>{member.role}</TableCell> | |
| 71 | - <TableCell>{formatOptionalValue(member.schoolName)}</TableCell> | |
| 72 | - <TableCell>{formatGradeClassNumber(member)}</TableCell> | |
| 73 | - <TableCell>{formatOptionalValue(member.guardianName)}</TableCell> | |
| 74 | - <TableCell> | |
| 75 | - {formatOptionalValue(member.guardianPhoneNumber)} | |
| 76 | - </TableCell> | |
| 77 | - <TableCell>{formatOptionalValue(member.joinedAt)}</TableCell> | |
| 78 | - <TableCell> | |
| 79 | - <StudentRowActions member={member} /> | |
| 80 | - </TableCell> | |
| 81 | - </TableRow> | |
| 82 | - ))} | |
| 83 | - </TableBody> | |
| 84 | - </Table> | |
| 85 | - ); | |
| 86 | -} |
--- app/(protected)/(basic)/students/page.tsx
+++ app/(protected)/(basic)/students/page.tsx
... | ... | @@ -1,17 +1,11 @@ |
| 1 |
-import type { Metadata } from 'next';
|
|
| 2 |
-import Link from 'next/link'; |
|
| 3 |
-import { Alert } from '@/components/ui/alert';
|
|
| 4 |
-import { Pagination } from '@/components/ui/pagination';
|
|
| 5 | 1 |
import { verifySession } from '@/lib/auth/dal';
|
| 6 | 2 |
import { fetchStudentMembers } from '@/lib/data/repositories/student-member-repository';
|
| 7 |
-import { buildStudentMemberHref, parseStudentMemberQuery } from '@/lib/domain/student-member-query';
|
|
| 8 |
-import { StudentListToolbar } from './_components/student-list-toolbar';
|
|
| 9 |
-import { StudentSearchBar } from './_components/student-search-bar';
|
|
| 10 |
-import { StudentTable } from './_components/student-table';
|
|
| 3 |
+import { parseStudentMemberQuery } from '@/lib/domain/student-member-query';
|
|
| 4 |
+import { StudentList } from './_components/student-list';
|
|
| 11 | 5 |
|
| 12 |
-export const metadata: Metadata = {
|
|
| 13 |
- title: '학생 회원', |
|
| 14 |
-}; |
|
| 6 |
+// cookies()로 이미 동적이지만, 정적 프리렌더로 데이터가 빌드 산출물에 박히는 경로를 |
|
| 7 |
+// 원천 차단하기 위해 명시적으로 강제한다. |
|
| 8 |
+export const dynamic = 'force-dynamic'; |
|
| 15 | 9 |
|
| 16 | 10 |
interface PageProps {
|
| 17 | 11 |
searchParams: Promise<Record<string, string | string[] | undefined>>; |
... | ... | @@ -32,50 +26,12 @@ |
| 32 | 26 |
const currentPage = Math.min(query.page, totalPages); |
| 33 | 27 |
|
| 34 | 28 |
return ( |
| 35 |
- <section className="flex flex-1 flex-col gap-6 p-8"> |
|
| 36 |
- <h1 className="text-headline-sm text-foreground">학생 회원 목록</h1> |
|
| 37 |
- |
|
| 38 |
- <StudentSearchBar query={query} />
|
|
| 39 |
- |
|
| 40 |
- <StudentListToolbar query={query} />
|
|
| 41 |
- |
|
| 42 |
- <p className="text-body-md text-foreground-muted"> |
|
| 43 |
- 총 {totalCount}명{isTotalCountExact ? '' : ' 이상'} | 현재페이지{' '}
|
|
| 44 |
- {currentPage}
|
|
| 45 |
- {isTotalCountExact ? `/${totalPages}` : ''}
|
|
| 46 |
- </p> |
|
| 47 |
- |
|
| 48 |
- {items.length === 0 ? (
|
|
| 49 |
- <Alert |
|
| 50 |
- title="검색 결과가 없습니다." |
|
| 51 |
- actions={
|
|
| 52 |
- <Link |
|
| 53 |
- href={buildStudentMemberHref(query, { keyword: '', page: 1 })}
|
|
| 54 |
- className="bg-foreground px-4 py-2 text-background" |
|
| 55 |
- > |
|
| 56 |
- 확인 |
|
| 57 |
- </Link> |
|
| 58 |
- } |
|
| 59 |
- > |
|
| 60 |
- 다른 검색어로 다시 시도해 주세요. |
|
| 61 |
- </Alert> |
|
| 62 |
- ) : ( |
|
| 63 |
- <> |
|
| 64 |
- {/* 표의 순번 기준은 요청 페이지(query.page)가 아니라 화면이 실제로 보여주는
|
|
| 65 |
- 페이지(currentPage)다 — 백엔드가 범위를 벗어난 페이지 요청을 clamp해서 응답하면 |
|
| 66 |
- 둘이 어긋나 "현재페이지 1/1"인데 순번은 31부터 시작하는 상태가 된다. */} |
|
| 67 |
- <StudentTable |
|
| 68 |
- items={items}
|
|
| 69 |
- page={currentPage}
|
|
| 70 |
- pageSize={query.pageSize}
|
|
| 71 |
- /> |
|
| 72 |
- <Pagination |
|
| 73 |
- currentPage={currentPage}
|
|
| 74 |
- totalPages={totalPages}
|
|
| 75 |
- buildHref={(page: number) => buildStudentMemberHref(query, { page })}
|
|
| 76 |
- /> |
|
| 77 |
- </> |
|
| 78 |
- )} |
|
| 79 |
- </section> |
|
| 29 |
+ <StudentList |
|
| 30 |
+ items={items}
|
|
| 31 |
+ query={query}
|
|
| 32 |
+ currentPage={currentPage}
|
|
| 33 |
+ totalPages={totalPages}
|
|
| 34 |
+ totalCount={totalCount}
|
|
| 35 |
+ /> |
|
| 80 | 36 |
); |
| 81 | 37 |
} |
--- lib/data/mock/admin-member-store.ts
... | ... | @@ -1,45 +0,0 @@ |
| 1 | -import 'server-only'; | |
| 2 | -import type { AdminMember } from '@/lib/domain/admin-member'; | |
| 3 | - | |
| 4 | -/** | |
| 5 | - * 관리자 회원 **삭제**의 mock 오버레이 — 백엔드에 삭제 API가 없어 임시로 두는 계층이다. | |
| 6 | - * 등록·수정·중복확인은 실제 API를 쓰므로(`admin-member-repository.ts`) 여기 남는 책임은 삭제뿐이다. | |
| 7 | - * | |
| 8 | - * 백엔드(edupay-backend develop 924db37) `MngrAdminApiController`에는 GET 3개(목록·단건·중복확인)와 | |
| 9 | - * POST·PUT만 있고 `@DeleteMapping`이 없다. 수정 API로 비활성화를 대신할 수도 없다 — UPDATE 문이 | |
| 10 | - * `USE_YN`을 건드리지 않아 사용여부를 끌 수단 자체가 없다(바꿀 수 있는 건 `ACCT_LOCK_YN`뿐). | |
| 11 | - * | |
| 12 | - * **한계** — 서버 프로세스 메모리에만 있다. 재시작하면 삭제한 행이 되살아나고, 인스턴스가 여럿이면 | |
| 13 | - * 공유되지 않는다. 삭제한 계정의 로그인 ID도 백엔드에는 그대로 남아 재등록 시 중복으로 걸린다. | |
| 14 | - * | |
| 15 | - * 백엔드에 삭제 API가 생기면 이 파일을 지우고 Repository의 `deleteAdminMember` 본문만 교체한다. | |
| 16 | - */ | |
| 17 | - | |
| 18 | -type MockStoreState = { | |
| 19 | - deletedIds: Set<string>; | |
| 20 | -}; | |
| 21 | - | |
| 22 | -/** HMR로 모듈이 다시 평가돼도 삭제 상태가 초기화되지 않도록 globalThis에 붙인다(mock 전용 편법). */ | |
| 23 | -const globalStore = globalThis as typeof globalThis & { | |
| 24 | - __adminMemberMockStore?: MockStoreState; | |
| 25 | -}; | |
| 26 | - | |
| 27 | -function getState(): MockStoreState { | |
| 28 | - globalStore.__adminMemberMockStore ??= { deletedIds: new Set() }; | |
| 29 | - return globalStore.__adminMemberMockStore; | |
| 30 | -} | |
| 31 | - | |
| 32 | -/** 백엔드 목록에서 mock 삭제된 행을 걷어낸다. 정렬·검색·페이징은 Repository가 한다. */ | |
| 33 | -export function applyMockDeletions(backendItems: AdminMember[]): AdminMember[] { | |
| 34 | - const { deletedIds } = getState(); | |
| 35 | - return backendItems.filter((item) => !deletedIds.has(item.id)); | |
| 36 | -} | |
| 37 | - | |
| 38 | -export function isMockDeleted(id: string): boolean { | |
| 39 | - return getState().deletedIds.has(id); | |
| 40 | -} | |
| 41 | - | |
| 42 | -/** 백엔드 행은 매 조회마다 다시 등장하므로 삭제 사실을 따로 기억해야 한다. */ | |
| 43 | -export function markMockDeleted(id: string): void { | |
| 44 | - getState().deletedIds.add(id); | |
| 45 | -} |
--- lib/data/repositories/admin-member-repository.ts
+++ lib/data/repositories/admin-member-repository.ts
... | ... | @@ -6,29 +6,24 @@ |
| 6 | 6 |
AdminMemberQuery, |
| 7 | 7 |
AdminMemberSearchField, |
| 8 | 8 |
} from '@/lib/domain/admin-member-query'; |
| 9 |
-import {
|
|
| 10 |
- applyMockDeletions, |
|
| 11 |
- isMockDeleted, |
|
| 12 |
- markMockDeleted, |
|
| 13 |
-} from '@/lib/data/mock/admin-member-store'; |
|
| 14 | 9 |
|
| 15 | 10 |
/** |
| 16 | 11 |
* 관리자 회원 Repository — 이 도메인을 백엔드에서 "어떻게 읽고 쓰는지"만 안다(엔드포인트·파라미터· |
| 17 | 12 |
* 응답 매핑). 백엔드와 말하는 공통 규약(URL·헤더·응답 봉투·실패 정규화)은 |
| 18 | 13 |
* `lib/http/backend-fetch.ts`가, 토큰 보관·검증은 `lib/auth`가 소유하므로 여기에 들어오지 않는다. |
| 19 | 14 |
* |
| 20 |
- * GET /api/v1/mngr/admin/pagination 목록 |
|
| 21 |
- * GET /api/v1/mngr/admin/{admUserId} 단건
|
|
| 22 |
- * GET /api/v1/mngr/admin/duplication/{id} 로그인 ID 중복 확인
|
|
| 23 |
- * POST /api/v1/mngr/admin 등록 |
|
| 24 |
- * PUT /api/v1/mngr/admin 수정 |
|
| 15 |
+ * GET /api/v1/mngr/admin/pagination 목록 |
|
| 16 |
+ * GET /api/v1/mngr/admin/{admUserId} 단건
|
|
| 17 |
+ * GET /api/v1/mngr/admin/duplication/{id} 로그인 ID 중복 확인
|
|
| 18 |
+ * POST /api/v1/mngr/admin 등록 |
|
| 19 |
+ * PUT /api/v1/mngr/admin/{admUserId} 수정
|
|
| 20 |
+ * DELETE /api/v1/mngr/admin/{admUserId} 삭제
|
|
| 25 | 21 |
* |
| 26 |
- * 아래는 백엔드 저장소(edupay-backend, develop 924db37)의 실제 구현과 dev 서버 응답을 확인한 것이다. |
|
| 22 |
+ * 아래는 백엔드 저장소(edupay-backend, develop 4d98756)의 실제 구현을 확인한 것이다. |
|
| 27 | 23 |
* |
| 28 |
- * - **등록·수정은 JSON 본문을 받지 않는다.** 두 핸들러 모두 `@RequestBody` 없이 |
|
| 29 |
- * `@ParameterObject MngrAdmin*RequestVo`를 받아 query/form으로만 바인딩된다. 그래서 `form`으로 보낸다. |
|
| 30 |
- * - **이메일·휴대전화번호를 되읽을 수 없다.** 저장은 되지만 조회 SQL의 select 목록에 두 컬럼이 없어 |
|
| 31 |
- * 응답은 항상 `null`이다(실측). 매핑은 미리 해 두었으므로 백엔드가 컬럼을 추가하면 화면까지 그대로 흐른다. |
|
| 24 |
+ * - **등록과 수정의 본문 형식이 서로 다르다.** 등록은 `@RequestBody` 없이 `@ParameterObject`라 |
|
| 25 |
+ * query/form으로만 바인딩되고(→ `form`), 수정은 `@RequestBody`라 JSON이다(→ `body`). |
|
| 26 |
+ * 같은 도메인인데 갈린 것이라 헷갈리기 쉽다 — 한쪽 방식으로 통일해 보내면 값이 조용히 비어 저장된다. |
|
| 32 | 27 |
* - **이름(`admNm`)은 수정 대상이 아니다** — `MngrAdminUpdateRequestVo`에 필드가 없다. |
| 33 | 28 |
* - **정렬 파라미터가 없다** — 목록 SQL의 `ORDER BY RNUM DESC`가 하드코딩돼 있다. rnum이 |
| 34 | 29 |
* `ROW_NUMBER() OVER (ORDER BY frst_reg_dt, adm_nm DESC)`, 즉 최초등록일시 오름차순 순번이라 |
... | ... | @@ -36,6 +31,8 @@ |
| 36 | 31 |
* 그대로 쓴다"는 뜻이고, 이름순만 우리가 정렬한다. |
| 37 | 32 |
* - **`totalCount`가 전체 건수가 아니다.** count 쿼리가 없어 `PaginationUtil.execute`가 |
| 38 | 33 |
* `list.size()`(= 그 페이지의 행 수)를 총건수로 그대로 쓴다. 그래서 이 값은 신뢰하지 않는다. |
| 34 |
+ * - **삭제는 soft delete다**(`DEL_YN='Y'`). 조회 SQL이 모두 `DEL_YN != 'Y'`로 거르므로 삭제한 행은 |
|
| 35 |
+ * 목록·단건에서 함께 사라진다. |
|
| 39 | 36 |
* |
| 40 | 37 |
* 인증: `/api/v1/mngr/**`는 ROLE_ADMIN 전용이다. 세션에 보관된 백엔드 accessToken을 DAL에서 |
| 41 | 38 |
* 꺼내 Bearer로 붙인다. |
... | ... | @@ -54,10 +51,8 @@ |
| 54 | 51 |
* 페이지가 가득 찰 때마다 `totalPages`가 1로 계산돼 2페이지 이후에 영원히 닿을 수 없다. |
| 55 | 52 |
* 시안(ADM_ADM_101)은 "총 N명 | 현재페이지 1/1"과 번호 열(총건수에서 거꾸로 세는 순번)을 |
| 56 | 53 |
* 요구하는데, 둘 다 정확한 전체 건수를 전제한다. |
| 57 |
- * 2. **검색·정렬을 백엔드에 맡길 수 없다.** 이름순 정렬 파라미터가 없고, 휴대전화번호 검색 |
|
| 58 |
- * 분기는 관리자 테이블에 없는 컬럼을 참조한다(`admin-member-query.ts` 주석 참조). |
|
| 59 |
- * 3. **삭제가 mock이다.** 삭제된 행을 걷어낸 뒤 세어야 총건수가 맞는데, 페이징이 백엔드에 있으면 |
|
| 60 |
- * 페이지마다 구멍이 생겨 건수와 순번이 어긋난다. |
|
| 54 |
+ * 2. **이름순 정렬을 백엔드에 맡길 수 없다.** 정렬 파라미터가 없고 목록 SQL의 ORDER BY가 |
|
| 55 |
+ * 하드코딩돼 있다. |
|
| 61 | 56 |
* |
| 62 | 57 |
* 관리자 계정은 본래 수십 건 규모라 전체를 받아도 부담이 없다. 이 전제가 깨질 정도로 늘면 |
| 63 | 58 |
* 백엔드에 count·정렬·검색 파라미터가 필요하다 — 상한 인상은 임시방편일 뿐이다. |
... | ... | @@ -115,12 +110,13 @@ |
| 115 | 110 |
id: readRequiredString(raw, 'admUserId'), |
| 116 | 111 |
name: readRequiredString(raw, 'admNm'), |
| 117 | 112 |
loginId: readRequiredString(raw, 'loginId'), |
| 118 |
- // 지금은 항상 null이다 — 조회 SQL이 두 컬럼을 select하지 않는다(위 주석 참조). |
|
| 119 | 113 |
phoneNumber: readOptionalString(raw, 'admTelNo'), |
| 120 | 114 |
email: readOptionalString(raw, 'admEmlAddr'), |
| 121 | 115 |
roleCode: readOptionalString(raw, 'admRoleCd') ?? '', |
| 116 |
+ // 백엔드에 관리자별 메뉴 권한 개념이 없다 — 저장도 조회도 되지 않는다(쓰기 경로 주석 참조). |
|
| 122 | 117 |
menuCodes: [], |
| 123 |
- createdAt: null, |
|
| 118 |
+ // `DATE_FORMAT(FRST_REG_DT, '%Y-%m-%d')`라 이미 화면 표기 형식이다. |
|
| 119 |
+ createdAt: readOptionalString(raw, 'frstRegDtStr'), |
|
| 124 | 120 |
isLocked: parseYesNo(raw.acctLockYn), |
| 125 | 121 |
isActive: parseYesNo(raw.useYn), |
| 126 | 122 |
loginFailCount: typeof loginFailCnt === 'number' ? loginFailCnt : null, |
... | ... | @@ -130,9 +126,9 @@ |
| 130 | 126 |
/** |
| 131 | 127 |
* 백엔드 목록 전체를 한 번에 받아온다. |
| 132 | 128 |
* |
| 133 |
- * 검색 파라미터(`searchCondition`/`searchKeyword`)를 **의도적으로 보내지 않는다** — 검색은 |
|
| 134 |
- * mock 행까지 포함해 일관되게 걸러야 하므로 아래 `filterByKeyword`가 전담한다. 백엔드에 검색을 |
|
| 135 |
- * 맡기면 백엔드 행만 걸러지고 mock 행은 그대로 남아 결과가 어긋난다. |
|
| 129 |
+ * 검색 파라미터(`searchCondition`/`searchKeyword`)를 **의도적으로 보내지 않는다** — 백엔드에 |
|
| 130 |
+ * 맡기면 페이징도 함께 백엔드가 하게 되는데 그 총건수를 믿을 수 없다(위 상수 주석). 어차피 전체를 |
|
| 131 |
+ * 손에 쥐므로 `filterByKeyword`가 같은 조건으로 거른다. |
|
| 136 | 132 |
*/ |
| 137 | 133 |
async function fetchAllAdminMembers(): Promise<AdminMember[]> {
|
| 138 | 134 |
const accessToken = await getSessionAccessToken(); |
... | ... | @@ -165,6 +161,7 @@ |
| 165 | 161 |
> = {
|
| 166 | 162 |
name: (member) => member.name, |
| 167 | 163 |
loginId: (member) => member.loginId, |
| 164 |
+ phoneNumber: (member) => member.phoneNumber ?? '', |
|
| 168 | 165 |
}; |
| 169 | 166 |
|
| 170 | 167 |
function filterByKeyword( |
... | ... | @@ -181,9 +178,8 @@ |
| 181 | 178 |
} |
| 182 | 179 |
|
| 183 | 180 |
/** |
| 184 |
- * 정렬. 생성일순은 **정렬하지 않는다** — 백엔드가 이미 생성일 최신순으로 내려주고(고정 |
|
| 185 |
- * `ORDER BY rnum DESC`), mock 신규 등록 행은 오버레이가 맨 앞에 붙여 두기 때문이다. 응답에 |
|
| 186 |
- * `createdAt` 값 자체가 없어 우리가 다시 정렬할 수단도 없다. |
|
| 181 |
+ * 정렬. 생성일순은 **정렬하지 않는다** — 백엔드의 고정 `ORDER BY rnum DESC`가 곧 생성일 |
|
| 182 |
+ * 최신순이라 받은 순서가 이미 답이다(rnum이 `frst_reg_dt` 오름차순 순번이다). |
|
| 187 | 183 |
*/ |
| 188 | 184 |
function sortItems( |
| 189 | 185 |
items: AdminMember[], |
... | ... | @@ -205,7 +201,7 @@ |
| 205 | 201 |
export async function fetchAdminMembers( |
| 206 | 202 |
query: AdminMemberQuery |
| 207 | 203 |
): Promise<AdminMemberPage> {
|
| 208 |
- const all = applyMockDeletions(await fetchAllAdminMembers()); |
|
| 204 |
+ const all = await fetchAllAdminMembers(); |
|
| 209 | 205 |
const matched = sortItems(filterByKeyword(all, query), query); |
| 210 | 206 |
|
| 211 | 207 |
const offset = (query.page - 1) * query.pageSize; |
... | ... | @@ -216,14 +212,10 @@ |
| 216 | 212 |
}; |
| 217 | 213 |
} |
| 218 | 214 |
|
| 219 |
-/** 단건 조회 — 수정 팝업이 쓰는 진입점. mock 삭제된 행은 없는 것으로 취급한다. */ |
|
| 215 |
+/** 단건 조회 — 수정 팝업이 쓰는 진입점. */ |
|
| 220 | 216 |
export async function findAdminMemberById( |
| 221 | 217 |
id: string |
| 222 | 218 |
): Promise<AdminMember | null> {
|
| 223 |
- if (isMockDeleted(id)) {
|
|
| 224 |
- return null; |
|
| 225 |
- } |
|
| 226 |
- |
|
| 227 | 219 |
const accessToken = await getSessionAccessToken(); |
| 228 | 220 |
const result = await backendFetch<unknown>( |
| 229 | 221 |
`${ADMIN_MEMBER_BASE_PATH}/${encodeURIComponent(id)}`,
|
... | ... | @@ -272,10 +264,9 @@ |
| 272 | 264 |
|
| 273 | 265 |
/* |
| 274 | 266 |
* ─── 쓰기 경로 ──────────────────────────────────────────────────────────────── |
| 275 |
- * 등록·수정은 실제 API를 쓴다. 삭제만 백엔드에 엔드포인트가 없어 mock 오버레이에 남아 있다. |
|
| 267 |
+ * 등록·수정·삭제 모두 실제 API를 쓴다. 성공 응답은 `data: null`이라 `canHaveNullData`가 필요하다. |
|
| 276 | 268 |
* |
| 277 |
- * 두 요청 모두 `form`으로 보낸다 — 백엔드 핸들러가 `@RequestBody` 없이 VO를 받아 JSON 본문을 |
|
| 278 |
- * 바인딩하지 못하기 때문이다. 성공 응답은 `data: null`이라 `canHaveNullData`가 필요하다. |
|
| 269 |
+ * **본문 형식이 등록과 수정에서 갈린다** — 등록은 form, 수정은 JSON이다(파일 상단 주석 참조). |
|
| 279 | 270 |
* |
| 280 | 271 |
* 메뉴 선택(`menuCodes`)은 **보내지 않는다** — 백엔드에 관리자별 메뉴 권한 개념이 없다 |
| 281 | 272 |
* (`/api/v1/common/menu`는 개인 북마크용이고 등록·수정 VO에도 해당 필드가 없다). |
... | ... | @@ -292,8 +283,9 @@ |
| 292 | 283 |
|
| 293 | 284 |
export type UpdateAdminMemberInput = {
|
| 294 | 285 |
/** |
| 295 |
- * 백엔드 UPDATE 문이 `LOGIN_PW`를 무조건 덮어쓰므로 빈 값을 보내면 그 계정이 로그인 불가가 된다. |
|
| 296 |
- * 그래서 이 필드는 선택이 아니라 필수다 — 호출부가 빈 값을 걸러 여기까지 오지 않게 한다. |
|
| 286 |
+ * 비우면 비밀번호를 바꾸지 않는다 — 백엔드 UPDATE의 `LOGIN_PW`가 |
|
| 287 |
+ * `<if test='loginPw != null and loginPw != ""'>`로 감싸여 있어 빈 값은 SET 절에서 빠진다. |
|
| 288 |
+ * (예전에는 조건 없이 덮어써서 빈 값을 보내면 그 계정이 로그인 불가가 됐다.) |
|
| 297 | 289 |
*/ |
| 298 | 290 |
password: string; |
| 299 | 291 |
phoneNumber: string; |
... | ... | @@ -338,26 +330,43 @@ |
| 338 | 330 |
): Promise<void> {
|
| 339 | 331 |
const accessToken = await getSessionAccessToken(); |
| 340 | 332 |
|
| 341 |
- const result = await backendFetch<unknown>(ADMIN_MEMBER_BASE_PATH, {
|
|
| 342 |
- method: 'PUT', |
|
| 343 |
- form: {
|
|
| 344 |
- admUserId: id, |
|
| 345 |
- loginPw: input.password, |
|
| 346 |
- admEmlAddr: input.email, |
|
| 347 |
- admTelNo: input.phoneNumber, |
|
| 348 |
- admRoleCd: input.roleCode, |
|
| 349 |
- acctLockYn: toYesNo(input.isLocked), |
|
| 350 |
- }, |
|
| 351 |
- accessToken: accessToken ?? undefined, |
|
| 352 |
- canHaveNullData: true, |
|
| 353 |
- }); |
|
| 333 |
+ const result = await backendFetch<unknown>( |
|
| 334 |
+ `${ADMIN_MEMBER_BASE_PATH}/${encodeURIComponent(id)}`,
|
|
| 335 |
+ {
|
|
| 336 |
+ method: 'PUT', |
|
| 337 |
+ // 수정만 `@RequestBody`라 JSON이다. form으로 보내면 모든 필드가 null로 들어가 |
|
| 338 |
+ // 이메일·전화번호가 지워지고 역할이 비워진다. |
|
| 339 |
+ body: {
|
|
| 340 |
+ loginPw: input.password, |
|
| 341 |
+ admEmlAddr: input.email, |
|
| 342 |
+ admTelNo: input.phoneNumber, |
|
| 343 |
+ admRoleCd: input.roleCode, |
|
| 344 |
+ acctLockYn: toYesNo(input.isLocked), |
|
| 345 |
+ }, |
|
| 346 |
+ accessToken: accessToken ?? undefined, |
|
| 347 |
+ canHaveNullData: true, |
|
| 348 |
+ } |
|
| 349 |
+ ); |
|
| 354 | 350 |
|
| 355 | 351 |
if (!result.ok) {
|
| 356 | 352 |
throw new BackendRequestError(result); |
| 357 | 353 |
} |
| 358 | 354 |
} |
| 359 | 355 |
|
| 360 |
-/** 삭제 API가 없어 화면에서만 감춘다 — 한계는 `admin-member-store.ts` 주석 참조. */ |
|
| 356 |
+/** 삭제(soft delete). 백엔드가 `DEL_YN='Y'`로 표시하고 조회 SQL이 그 행을 제외한다. */ |
|
| 361 | 357 |
export async function deleteAdminMember(id: string): Promise<void> {
|
| 362 |
- markMockDeleted(id); |
|
| 358 |
+ const accessToken = await getSessionAccessToken(); |
|
| 359 |
+ |
|
| 360 |
+ const result = await backendFetch<unknown>( |
|
| 361 |
+ `${ADMIN_MEMBER_BASE_PATH}/${encodeURIComponent(id)}`,
|
|
| 362 |
+ {
|
|
| 363 |
+ method: 'DELETE', |
|
| 364 |
+ accessToken: accessToken ?? undefined, |
|
| 365 |
+ canHaveNullData: true, |
|
| 366 |
+ } |
|
| 367 |
+ ); |
|
| 368 |
+ |
|
| 369 |
+ if (!result.ok) {
|
|
| 370 |
+ throw new BackendRequestError(result); |
|
| 371 |
+ } |
|
| 363 | 372 |
} |
+++ lib/data/repositories/common-code-repository.ts
... | ... | @@ -0,0 +1,71 @@ |
| 1 | +import 'server-only'; | |
| 2 | +import { cache } from 'react'; | |
| 3 | +import { getSessionAccessToken } from '@/lib/auth/dal'; | |
| 4 | +import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch'; | |
| 5 | +import type { CommonCode } from '@/lib/domain/common-code'; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * 공통코드 Repository. | |
| 9 | + * | |
| 10 | + * ``` | |
| 11 | + * GET /api/v1/mngr/code/list/{comCd} 그룹의 상세코드 목록 (ROLE_ADMIN) | |
| 12 | + * ``` | |
| 13 | + * | |
| 14 | + * 백엔드(edupay-backend, develop)의 MngrCodeApiController / MngrCodeMapper.xml을 읽고 확인한 것: | |
| 15 | + * | |
| 16 | + * - 응답은 `TB_SYS_COM_CD_DTL` 행 배열이고, 쓸 값은 **`comDtlCd`(코드)와 `cdNm`(이름)** 둘이다. | |
| 17 | + * - 삭제된 코드(`DEL_YN='Y'`)는 SQL이 이미 걸러 준다. | |
| 18 | + * - **정렬은 백엔드가 준 순서를 그대로 쓴다.** SQL이 `ROW_NUMBER() OVER (ORDER BY FRST_REG_DT | |
| 19 | + * DESC, SORT_SEQ)`를 다시 `ORDER BY RNUM DESC`로 뒤집어, 결과는 등록일 오름차순 + 정렬순서 | |
| 20 | + * 내림차순이다(아이템 목록과 같은 이중 역순 패턴). 화면에서 다시 정렬하면 다른 화면과 순서가 | |
| 21 | + * 어긋나므로 손대지 않는다. | |
| 22 | + * | |
| 23 | + * `cache()`로 감싼 이유 — 한 요청 안에서 화면(선택지 그리기)과 Server Action(입력값 검증)이 | |
| 24 | + * 같은 그룹을 각각 부른다. 요청 단위로 기억해 두면 왕복이 한 번으로 줄고, 그리는 데 쓴 목록과 | |
| 25 | + * 검증에 쓴 목록이 반드시 같아진다. | |
| 26 | + * | |
| 27 | + * 캐시: `no-store` — 코드는 자주 바뀌지 않지만 관리 화면에서 코드를 고치자마자 반영돼야 하고, | |
| 28 | + * 위 `cache()`가 이미 요청 안의 중복 호출을 막는다. | |
| 29 | + */ | |
| 30 | +export const fetchCommonCodes = cache(async function fetchCommonCodes( | |
| 31 | + groupCode: string | |
| 32 | +): Promise<CommonCode[]> { | |
| 33 | + const accessToken = await getSessionAccessToken(); | |
| 34 | + | |
| 35 | + const result = await backendFetch<unknown>(`/api/v1/mngr/code/list/${groupCode}`, { | |
| 36 | + method: 'GET', | |
| 37 | + accessToken: accessToken ?? undefined, | |
| 38 | + cache: 'no-store', | |
| 39 | + }); | |
| 40 | + | |
| 41 | + if (!result.ok) { | |
| 42 | + throw new BackendRequestError(result); | |
| 43 | + } | |
| 44 | + | |
| 45 | + if (!Array.isArray(result.data)) { | |
| 46 | + throw new Error('공통코드 목록 응답의 형식이 올바르지 않습니다.'); | |
| 47 | + } | |
| 48 | + | |
| 49 | + return result.data.flatMap(toCommonCode); | |
| 50 | +}); | |
| 51 | + | |
| 52 | +/** | |
| 53 | + * 응답 1건 → 도메인 타입. 코드나 이름이 없는 행은 선택지로 쓸 수 없으므로 **예외 대신 건너뛴다** | |
| 54 | + * — 코드 한 줄이 깨졌다고 화면 전체를 못 쓰게 만들 이유가 없다(목록 조회의 fail-fast와 다른 | |
| 55 | + * 판단이다. 그쪽은 없으면 화면의 존재 이유가 사라진다). | |
| 56 | + */ | |
| 57 | +function toCommonCode(raw: unknown): CommonCode[] { | |
| 58 | + if (raw === null || typeof raw !== 'object') { | |
| 59 | + return []; | |
| 60 | + } | |
| 61 | + | |
| 62 | + const source = raw as Record<string, unknown>; | |
| 63 | + const code = source.comDtlCd; | |
| 64 | + const label = source.cdNm; | |
| 65 | + | |
| 66 | + if (typeof code !== 'string' || code.length === 0) { | |
| 67 | + return []; | |
| 68 | + } | |
| 69 | + | |
| 70 | + return [{ code, label: typeof label === 'string' && label ? label : code }]; | |
| 71 | +} |
--- lib/data/repositories/decoration-item-repository.ts
+++ lib/data/repositories/decoration-item-repository.ts
... | ... | @@ -26,14 +26,20 @@ |
| 26 | 26 |
* 아래는 백엔드 저장소(edupay-backend, develop)의 실제 구현을 읽고 확인한 것이다 — |
| 27 | 27 |
* MngrItemApiController / MngrItemServiceImpl / MngrItemMapper.xml / FileCommonApiController. |
| 28 | 28 |
* |
| 29 |
- * - **쓰기 API는 JSON이 아니라 form 인코딩이다.** 컨트롤러가 `@RequestBody`가 아닌 |
|
| 30 |
- * `@ParameterObject`로 받으므로 JSON을 보내면 전 필드가 null인 채 저장된다(오류도 나지 않는다). |
|
| 29 |
+ * - **등록과 수정의 본문 형식이 다르다.** 등록은 `@ParameterObject`(form 인코딩), 수정은 |
|
| 30 |
+ * `@RequestBody`(JSON)로 받는다 — 한쪽 형식으로 통일해 보내면 반대쪽이 전 필드 null이 되거나 |
|
| 31 |
+ * 415로 떨어진다. 백엔드 커밋 "FIX API 수정"에서 수정만 JSON으로 바뀌었다. |
|
| 31 | 32 |
* - **목록은 `searchItemType`이 필수다** — SQL의 WHERE에 `AND a.ITEM_TYPE = #{searchItemType}`이
|
| 32 | 33 |
* 무조건 붙는다. 값이 없으면 아무것도 조회되지 않는다. |
| 33 | 34 |
* - **검색은 아이템명(`searchCondition="1"`)만 구현돼 있다.** 아이템ID 검색 분기는 없어, 그 |
| 34 | 35 |
* 값을 보내면 조건 없이 전체가 반환된다(사용자 지시로 화면 선택지는 유지하고 백엔드에 추가 요청). |
| 35 | 36 |
* - **정렬은 고정이다** — `ROW_NUMBER() OVER (ORDER BY SORT_ORDR DESC)`를 다시 역순으로 정렬해 |
| 36 | 37 |
* 결과적으로 정렬순서 오름차순이며(시안 ADM_ITM_101과 일치) 정렬 파라미터는 없다. |
| 38 |
+ * - **수정일시는 `lastMdfcnDt`가 아니라 `lastMdfcnDtStr`로 온다.** VO의 `lastMdfcnDt`는 |
|
| 39 |
+ * `@JsonIgnore`라 응답에 실리지 않고, SQL이 `DATE_FORMAT(...) AS last_mdfcn_dt_str`로 따로 |
|
| 40 |
+ * 내려 준다(`mapUnderscoreToCamelCase`). |
|
| 41 |
+ * - **수정으로는 설명을 비울 수 없다** — UPDATE의 `<if test="itemExplan != ''">` 가드가 빈 문자열을 |
|
| 42 |
+ * 건너뛴다. 지우려는 의도가 조용히 무시되므로 백엔드에 조건 완화를 요청한다. |
|
| 37 | 43 |
* - **응답의 `totalCount`는 전체 건수가 아니라 그 페이지의 행 수다**(학생·관리자 목록과 동일한 |
| 38 | 44 |
* `PaginationUtil` 결함). 그대로 믿으면 페이지가 가득 찰 때마다 다음 페이지에 도달할 수 없어 |
| 39 | 45 |
* 하한값으로 보정한다. |
... | ... | @@ -137,7 +143,7 @@ |
| 137 | 143 |
sortOrder: readNumber(raw, 'sortOrdr') ?? 0, |
| 138 | 144 |
imageFileId, |
| 139 | 145 |
imageUrl: buildImageUrl(imageFileId), |
| 140 |
- updatedAt: readString(raw, 'lastMdfcnDt'), |
|
| 146 |
+ updatedAt: readString(raw, 'lastMdfcnDtStr'), |
|
| 141 | 147 |
}; |
| 142 | 148 |
} |
| 143 | 149 |
|
... | ... | @@ -247,7 +253,8 @@ |
| 247 | 253 |
|
| 248 | 254 |
/* |
| 249 | 255 |
* ─── 쓰기 경로 ──────────────────────────────────────────────────────────────── |
| 250 |
- * 컨트롤러가 `@ParameterObject`로 받으므로 전부 form 인코딩으로 보낸다(파일 상단 주석 참조). |
|
| 256 |
+ * 보내는 값은 등록·수정이 같고 **실어 보내는 형식만 다르다** — 등록은 form, 수정은 JSON |
|
| 257 |
+ * (파일 상단 주석 참조). |
|
| 251 | 258 |
*/ |
| 252 | 259 |
|
| 253 | 260 |
/** 등록·수정이 공유하는 전송 필드. */ |
... | ... | @@ -267,19 +274,23 @@ |
| 267 | 274 |
}; |
| 268 | 275 |
} |
| 269 | 276 |
|
| 277 |
+/** |
|
| 278 |
+ * 등록·수정 공통 호출. `payload`가 본문 형식을 정한다 — 이 한 곳에서만 갈린다. |
|
| 279 |
+ * |
|
| 280 |
+ * 등록·수정 성공 응답은 `ApiResponseVO.success(null)`이라 data가 정상적으로 null이다. |
|
| 281 |
+ */ |
|
| 270 | 282 |
async function sendWrite( |
| 271 | 283 |
path: string, |
| 272 | 284 |
method: 'POST' | 'PUT', |
| 273 |
- values: DecorationItemEditableValues |
|
| 285 |
+ payload: { form: Record<string, string | number | undefined> } | { body: unknown }
|
|
| 274 | 286 |
): Promise<void> {
|
| 275 | 287 |
const accessToken = await getSessionAccessToken(); |
| 276 | 288 |
|
| 277 | 289 |
const result = await backendFetch<null>(path, {
|
| 278 | 290 |
method, |
| 279 |
- form: buildWriteForm(values), |
|
| 291 |
+ ...payload, |
|
| 280 | 292 |
accessToken: accessToken ?? undefined, |
| 281 | 293 |
cache: 'no-store', |
| 282 |
- // 등록·수정 성공 응답은 `ApiResponseVO.success(null)` — data가 정상적으로 null이다. |
|
| 283 | 294 |
canHaveNullData: true, |
| 284 | 295 |
}); |
| 285 | 296 |
|
... | ... | @@ -288,26 +299,27 @@ |
| 288 | 299 |
} |
| 289 | 300 |
} |
| 290 | 301 |
|
| 302 |
+/** 등록 — 컨트롤러가 `@ParameterObject`라 form 인코딩이다. */ |
|
| 291 | 303 |
export async function createDecorationItem( |
| 292 | 304 |
values: DecorationItemEditableValues |
| 293 | 305 |
): Promise<void> {
|
| 294 |
- await sendWrite(DECORATION_ITEM_PATH, 'POST', values); |
|
| 306 |
+ await sendWrite(DECORATION_ITEM_PATH, 'POST', { form: buildWriteForm(values) });
|
|
| 295 | 307 |
} |
| 296 | 308 |
|
| 297 | 309 |
/** |
| 298 |
- * 수정. |
|
| 310 |
+ * 수정(시안 ADM_ITM_103_p) — 컨트롤러가 `@RequestBody`라 **JSON**이다. 등록과 같은 필드를 |
|
| 311 |
+ * 보내지만 형식이 다르다. |
|
| 299 | 312 |
* |
| 300 |
- * ⚠️ **백엔드 결함**: 컨트롤러의 update가 요청 VO에서 `itemType`을 빌더에 담지 않는데 UPDATE 문은 |
|
| 301 |
- * `ITEM_TYPE = #{itemType}`을 쓴다 — 그래서 수정하면 ITEM_TYPE이 null이 되고, 목록은
|
|
| 302 |
- * `ITEM_TYPE = searchItemType`으로 필터하므로 그 아이템이 개별·셋트 양쪽 탭에서 사라진다. |
|
| 303 |
- * 우리가 `itemType`을 보내도 컨트롤러가 버리므로 프론트에서 우회할 수 없다(사용자 지시로 그대로 |
|
| 304 |
- * 연동하고 이슈로 보고한다). `lastMdfcnDt`도 컨트롤러가 채우지 않아 수정일시가 null이 된다. |
|
| 313 |
+ * UPDATE 문이 필드마다 `<if>`로 감싸여 있어 **보내지 않은 값은 건드리지 않는다** — 썸네일 |
|
| 314 |
+ * 파일 ID(`thumbAtchFileId`)를 보내지 않는 것이 기존 값을 지우지 않는 이유다. |
|
| 305 | 315 |
*/ |
| 306 | 316 |
export async function updateDecorationItem( |
| 307 | 317 |
itemSn: number, |
| 308 | 318 |
values: DecorationItemEditableValues |
| 309 | 319 |
): Promise<void> {
|
| 310 |
- await sendWrite(`${DECORATION_ITEM_PATH}/${itemSn}`, 'PUT', values);
|
|
| 320 |
+ await sendWrite(`${DECORATION_ITEM_PATH}/${itemSn}`, 'PUT', {
|
|
| 321 |
+ body: buildWriteForm(values), |
|
| 322 |
+ }); |
|
| 311 | 323 |
} |
| 312 | 324 |
|
| 313 | 325 |
export async function deleteDecorationItem(itemSn: number): Promise<void> {
|
--- lib/data/repositories/student-member-repository.ts
+++ lib/data/repositories/student-member-repository.ts
... | ... | @@ -1,6 +1,10 @@ |
| 1 | 1 |
import 'server-only'; |
| 2 | 2 |
import { getSessionAccessToken } from '@/lib/auth/dal';
|
| 3 |
-import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
|
|
| 3 |
+import {
|
|
| 4 |
+ BackendRequestError, |
|
| 5 |
+ backendFetch, |
|
| 6 |
+ type BackendResult, |
|
| 7 |
+} from '@/lib/http/backend-fetch'; |
|
| 4 | 8 |
import type { StudentMember } from '@/lib/domain/student-member';
|
| 5 | 9 |
import type {
|
| 6 | 10 |
StudentMemberQuery, |
... | ... | @@ -13,7 +17,8 @@ |
| 13 | 17 |
* `lib/http/backend-fetch.ts`가, 토큰 보관·검증은 `lib/auth`가 소유하므로 여기에 들어오지 않는다. |
| 14 | 18 |
* |
| 15 | 19 |
* GET /api/v1/mngr/user/pagination (ROLE_ADMIN 전용) |
| 16 |
- * → data: { list: [{ rnum, userId, loginId, userNm }], page, size, totalCount, totalPages }
|
|
| 20 |
+ * → data: { list: [{ rnum, userId, userNm, userTelno, userEmlAddr, userType, loginId, schNm,
|
|
| 21 |
+ * grade, clsNo, birth, useYn, … }], page, size, totalCount, totalPages } |
|
| 17 | 22 |
* |
| 18 | 23 |
* 아래 내용은 백엔드 저장소(edupay-backend, develop b742bb4)의 실제 구현을 읽고 확인한 것이다 |
| 19 | 24 |
* — MngrUserApiController / MngrUserServiceImpl / PaginationUtil / MngrUserMapper.xml. |
... | ... | @@ -37,6 +42,7 @@ |
| 37 | 42 |
*/ |
| 38 | 43 |
|
| 39 | 44 |
const STUDENT_MEMBER_PAGINATION_PATH = '/api/v1/mngr/user/pagination'; |
| 45 |
+const STUDENT_MEMBER_BASE_PATH = '/api/v1/mngr/user'; |
|
| 40 | 46 |
|
| 41 | 47 |
/** |
| 42 | 48 |
* 화면의 "검색 대상" → 백엔드 `searchCondition` 값 매핑. 값은 MngrUserMapper.xml의 |
... | ... | @@ -115,6 +121,25 @@ |
| 115 | 121 |
return value; |
| 116 | 122 |
} |
| 117 | 123 |
|
| 124 |
+/** 있으면 문자열로, 없거나 비어 있으면 null. 백엔드가 빈 문자열로 "없음"을 표현하기도 한다. */ |
|
| 125 |
+function readOptionalString(raw: Record<string, unknown>, key: string): string | null {
|
|
| 126 |
+ const value = raw[key]; |
|
| 127 |
+ if (typeof value === 'string') {
|
|
| 128 |
+ return value.trim() === '' ? null : value; |
|
| 129 |
+ } |
|
| 130 |
+ return typeof value === 'number' ? String(value) : null; |
|
| 131 |
+} |
|
| 132 |
+ |
|
| 133 |
+/** 학년·반처럼 숫자로 쓰는 값. 숫자로 읽히지 않으면 null이다(백엔드가 문자열로 준다). */ |
|
| 134 |
+function readOptionalNumber(raw: Record<string, unknown>, key: string): number | null {
|
|
| 135 |
+ const text = readOptionalString(raw, key); |
|
| 136 |
+ if (text === null) {
|
|
| 137 |
+ return null; |
|
| 138 |
+ } |
|
| 139 |
+ const parsed = Number(text); |
|
| 140 |
+ return Number.isFinite(parsed) ? parsed : null; |
|
| 141 |
+} |
|
| 142 |
+ |
|
| 118 | 143 |
/** |
| 119 | 144 |
* 백엔드 응답 1건 → 도메인 타입. 백엔드가 주지 않는 항목은 `null`로 둔다(설계서 §8.1 5층 — |
| 120 | 145 |
* 원본 응답을 그대로 흘리지 않고 화면에 필요한 필드만 골라 담는다). |
... | ... | @@ -125,6 +150,15 @@ |
| 125 | 150 |
* 식별자·이름 세 필드는 없으면 예외로 끊는다(fail-fast) — 목록의 존재 이유인 값이라 |
| 126 | 151 |
* 빈 화면을 조용히 보여주는 것보다 계약 위반을 즉시 드러내는 편이 낫다. |
| 127 | 152 |
*/ |
| 153 |
+/** 백엔드의 `useYn`은 'Y'/'N' 문자열이다. 값이 없으면 판단하지 않고 null로 둔다. */ |
|
| 154 |
+function readUseYn(raw: Record<string, unknown>): boolean | null {
|
|
| 155 |
+ const value = readOptionalString(raw, 'useYn'); |
|
| 156 |
+ if (value === null) {
|
|
| 157 |
+ return null; |
|
| 158 |
+ } |
|
| 159 |
+ return value.toUpperCase() === 'Y'; |
|
| 160 |
+} |
|
| 161 |
+ |
|
| 128 | 162 |
function toStudentMember(raw: unknown): StudentMember {
|
| 129 | 163 |
if (!isRecord(raw)) {
|
| 130 | 164 |
throw new Error('학생 회원 응답 항목의 형식이 올바르지 않습니다.');
|
... | ... | @@ -138,18 +172,20 @@ |
| 138 | 172 |
memberCode: userId, |
| 139 | 173 |
name: readRequiredString(raw, 'userNm'), |
| 140 | 174 |
loginId: readRequiredString(raw, 'loginId'), |
| 141 |
- phoneNumber: null, |
|
| 142 |
- email: null, |
|
| 175 |
+ phoneNumber: readOptionalString(raw, 'userTelno'), |
|
| 176 |
+ email: readOptionalString(raw, 'userEmlAddr'), |
|
| 143 | 177 |
role: STUDENT_ROLE_LABEL, |
| 144 |
- schoolName: null, |
|
| 145 |
- grade: null, |
|
| 146 |
- classNumber: null, |
|
| 178 |
+ schoolName: readOptionalString(raw, 'schNm'), |
|
| 179 |
+ grade: readOptionalNumber(raw, 'grade'), |
|
| 180 |
+ classNumber: readOptionalNumber(raw, 'clsNo'), |
|
| 181 |
+ // 학생 번호는 응답에 없다 — 목록 SQL에도 VO에도 해당 컬럼이 없다. |
|
| 147 | 182 |
studentNumber: null, |
| 183 |
+ // 보호자 정보와 가입일도 아직 응답에 없다. |
|
| 148 | 184 |
guardianName: null, |
| 149 | 185 |
guardianPhoneNumber: null, |
| 150 | 186 |
joinedAt: null, |
| 151 |
- birthDate: null, |
|
| 152 |
- isActive: null, |
|
| 187 |
+ birthDate: readOptionalString(raw, 'birth'), |
|
| 188 |
+ isActive: readUseYn(raw), |
|
| 153 | 189 |
}; |
| 154 | 190 |
} |
| 155 | 191 |
|
... | ... | @@ -277,3 +313,38 @@ |
| 277 | 313 |
): Promise<StudentMemberPage> {
|
| 278 | 314 |
return query.sort === 'name' ? fetchByName(query) : fetchByJoinedAt(query); |
| 279 | 315 |
} |
| 316 |
+ |
|
| 317 |
+/** |
|
| 318 |
+ * 사용여부 변경. |
|
| 319 |
+ * |
|
| 320 |
+ * PUT /api/v1/mngr/user/{userId}/{useYn} (ROLE_ADMIN 전용)
|
|
| 321 |
+ * |
|
| 322 |
+ * 값과 대상이 모두 **경로에** 실린다 — 본문도 쿼리도 쓰지 않는다. |
|
| 323 |
+ * |
|
| 324 |
+ * ⚠️ 이 엔드포인트는 **성공 시 본문이 비어 온다.** 인터페이스에는 `ApiResponseVO` 반환으로 |
|
| 325 |
+ * 문서화돼 있지만 구현이 `void`이고 `ApiResponseVO.success(null)`을 만들어 놓고 버린다 |
|
| 326 |
+ * (edupay-backend develop 4d98756의 `MngrUserApiController.updateUserUseYn`). 그래서 |
|
| 327 |
+ * `canHaveEmptyBody`로 봉투 없는 2xx를 성공으로 받는다 — 백엔드가 봉투를 돌려주도록 고쳐도 |
|
| 328 |
+ * 그대로 동작한다. |
|
| 329 |
+ * |
|
| 330 |
+ * 최종수정자 정보는 보내지 않는다. 매퍼가 `LAST_MDFR_*`를 쓰지만 그 값은 백엔드의 |
|
| 331 |
+ * `CrudLogInterceptor`가 UPDATE마다 자동으로 채운다. |
|
| 332 |
+ */ |
|
| 333 |
+export async function updateStudentMemberUseYn( |
|
| 334 |
+ userId: string, |
|
| 335 |
+ isActive: boolean |
|
| 336 |
+): Promise<BackendResult<null>> {
|
|
| 337 |
+ const accessToken = await getSessionAccessToken(); |
|
| 338 |
+ |
|
| 339 |
+ return backendFetch<null>( |
|
| 340 |
+ `${STUDENT_MEMBER_BASE_PATH}/${encodeURIComponent(userId)}/${
|
|
| 341 |
+ isActive ? 'Y' : 'N' |
|
| 342 |
+ }`, |
|
| 343 |
+ {
|
|
| 344 |
+ method: 'PUT', |
|
| 345 |
+ accessToken: accessToken ?? undefined, |
|
| 346 |
+ canHaveEmptyBody: true, |
|
| 347 |
+ canHaveNullData: true, |
|
| 348 |
+ } |
|
| 349 |
+ ); |
|
| 350 |
+} |
--- lib/domain/admin-member-form.ts
+++ lib/domain/admin-member-form.ts
... | ... | @@ -2,7 +2,7 @@ |
| 2 | 2 |
* 관리자 등록/수정 입력 규칙 — 순수 검증 로직만 담는다(외부 의존 없음). |
| 3 | 3 |
* |
| 4 | 4 |
* 규칙은 시안(ADM_ADM_102_p / ADM_ADM_103_p)의 안내 문구를 그대로 옮긴 것이다: |
| 5 |
- * - ID : "영어 소문자, 숫자를 조합하여 입력 후 중복여부를 확인합니다." |
|
| 5 |
+ * - ID : "영어 소문자, 숫자를 조합하여 입력 후 중복여부를 확인하세요." |
|
| 6 | 6 |
* - 비밀번호: "영어 소문자, 숫자, 특수문자 중 2종류 이상 조합, 최소 10자리 이상" |
| 7 | 7 |
* |
| 8 | 8 |
* **이 파일이 검증의 단일 진실원천이다.** Server Action(`_actions.ts`)이 저장 직전에 여기를 |
... | ... | @@ -26,29 +26,28 @@ |
| 26 | 26 |
} from '@/lib/domain/admin-member'; |
| 27 | 27 |
|
| 28 | 28 |
export const ADMIN_LOGIN_ID_HELP_TEXT = |
| 29 |
- '영어 소문자, 숫자를 조합하여 입력 후 중복여부를 확인합니다.'; |
|
| 29 |
+ '영어 소문자, 숫자를 조합하여 입력 후 중복여부를 확인하세요.'; |
|
| 30 | 30 |
export const ADMIN_PASSWORD_HELP_TEXT = |
| 31 | 31 |
'영어 소문자, 숫자, 특수문자 중 2종류 이상 조합, 최소 10자리 이상'; |
| 32 | 32 |
|
| 33 |
-/** |
|
| 34 |
- * 수정 시 비밀번호를 비울 수 없는 이유를 화면에 그대로 보여준다. |
|
| 35 |
- * |
|
| 36 |
- * 백엔드 UPDATE 문이 `LOGIN_PW`를 조건 없이 덮어쓴다(`MngrAdminMapper.xml`의 update). 빈 값을 |
|
| 37 |
- * 보내면 비밀번호가 빈 문자열이 되어 그 계정은 다시 로그인할 수 없다 — 로그인은 암호화된 값과 |
|
| 38 |
- * 비교하기 때문이다. 그래서 "바꾸지 않음"을 표현할 방법이 없고, 프론트에서 빈 값을 막는 것이 |
|
| 39 |
- * 유일한 안전장치다. 백엔드가 빈 값을 UPDATE에서 제외하도록 고치면 이 제약을 없앨 수 있다. |
|
| 40 |
- */ |
|
| 41 |
-export const ADMIN_PASSWORD_REQUIRED_ON_UPDATE_TEXT = |
|
| 42 |
- '수정하려면 비밀번호를 다시 입력해 주세요. (빈 값으로 저장하면 로그인할 수 없게 됩니다)'; |
|
| 43 | 33 |
|
| 44 | 34 |
const LOGIN_ID_MIN_LENGTH = 4; |
| 45 | 35 |
const LOGIN_ID_MAX_LENGTH = 20; |
| 46 |
-const PASSWORD_MIN_LENGTH = 10; |
|
| 36 |
+ |
|
| 37 |
+/** |
|
| 38 |
+ * 비밀번호 규칙 — **화면과 서버가 같은 값을 본다.** 팝업은 이 값을 `foxPasswordValidator`에 |
|
| 39 |
+ * 그대로 넘겨 입력 중에 안내하고, 서버 검증은 아래 `validateEditableValues`가 같은 값으로 판정한다. |
|
| 40 |
+ * 한쪽만 고치면 화면은 통과시키고 저장은 거부하는 상태가 된다. |
|
| 41 |
+ */ |
|
| 42 |
+export const ADMIN_PASSWORD_POLICY = {
|
|
| 43 |
+ minLength: 10, |
|
| 44 |
+ kinds: 2, |
|
| 45 |
+} as const; |
|
| 47 | 46 |
const NAME_MAX_LENGTH = 50; |
| 48 | 47 |
const EMAIL_MAX_LENGTH = 100; |
| 49 | 48 |
|
| 50 |
-/** 휴대전화번호는 시안처럼 3칸으로 나뉘어 입력된다. 앞자리는 010 등 3자리, 가운데 3~4자리, 끝 4자리. */ |
|
| 51 |
-const PHONE_PART_PATTERNS = [/^\d{3}$/, /^\d{3,4}$/, /^\d{4}$/] as const;
|
|
| 49 |
+/** 앞 3자리 + 가운데 3~4자리 + 끝 4자리. 화면은 숫자만 다루고 하이픈은 여기서 붙인다. */ |
|
| 50 |
+const PHONE_DIGITS_PATTERN = /^(\d{3})(\d{3,4})(\d{4})$/;
|
|
| 52 | 51 |
|
| 53 | 52 |
/** 등록·수정 양쪽에서 실제로 바뀔 수 있는 항목. */ |
| 54 | 53 |
export type AdminMemberEditableValues = {
|
... | ... | @@ -124,26 +123,20 @@ |
| 124 | 123 |
} |
| 125 | 124 |
|
| 126 | 125 |
/** |
| 127 |
- * 휴대전화번호 3칸을 하나의 문자열(`010-1234-5678`)로 합친다. 비어 있는 칸이 하나라도 있거나 |
|
| 128 |
- * 형식이 맞지 않으면 null — 부분적으로 채워진 번호를 저장하지 않기 위해서다. |
|
| 126 |
+ * 화면이 넘긴 숫자열(`01012345678`)을 저장 형식(`010-1234-5678`)으로 바꾼다. 자릿수가 맞지 않으면 |
|
| 127 |
+ * null — 부분적으로 채워진 번호를 저장하지 않기 위해서다. |
|
| 128 |
+ * |
|
| 129 |
+ * 화면(`FoxPhoneNumber`)은 하이픈을 그리기만 하고 값으로는 숫자만 내보낸다. 반대로 목록·엑셀은 |
|
| 130 |
+ * 하이픈이 있는 형태로 보여주므로, 두 표현 사이의 변환을 이 한 쌍이 책임진다. |
|
| 129 | 131 |
*/ |
| 130 |
-export function joinPhoneNumber(parts: string[]): string | null {
|
|
| 131 |
- if (parts.length !== PHONE_PART_PATTERNS.length) {
|
|
| 132 |
- return null; |
|
| 133 |
- } |
|
| 134 |
- |
|
| 135 |
- const trimmed = parts.map((part) => part.trim()); |
|
| 136 |
- const isValid = trimmed.every((part, index) => |
|
| 137 |
- PHONE_PART_PATTERNS[index].test(part) |
|
| 138 |
- ); |
|
| 139 |
- |
|
| 140 |
- return isValid ? trimmed.join('-') : null;
|
|
| 132 |
+export function formatPhoneNumber(digits: string): string | null {
|
|
| 133 |
+ const matched = PHONE_DIGITS_PATTERN.exec(digits.trim()); |
|
| 134 |
+ return matched === null ? null : `${matched[1]}-${matched[2]}-${matched[3]}`;
|
|
| 141 | 135 |
} |
| 142 | 136 |
|
| 143 |
-/** 저장된 번호를 다시 3칸으로 나눈다(수정 팝업의 초기값). 형식이 다르면 빈 칸들을 돌려준다. */ |
|
| 144 |
-export function splitPhoneNumber(phoneNumber: string | null): string[] {
|
|
| 145 |
- const parts = (phoneNumber ?? '').split('-');
|
|
| 146 |
- return parts.length === PHONE_PART_PATTERNS.length ? parts : ['', '', '']; |
|
| 137 |
+/** 저장된 번호에서 숫자만 남긴다(수정 팝업의 초기값). */ |
|
| 138 |
+export function toPhoneDigits(phoneNumber: string | null): string {
|
|
| 139 |
+ return (phoneNumber ?? '').replace(/\D/g, ''); |
|
| 147 | 140 |
} |
| 148 | 141 |
|
| 149 | 142 |
function isValidEmail(email: string): boolean {
|
... | ... | @@ -157,24 +150,27 @@ |
| 157 | 150 |
/** |
| 158 | 151 |
* 등록·수정 공통 항목 검증. 오류는 넘겨받은 객체에 채워 넣고, 정규화된 값을 돌려준다. |
| 159 | 152 |
* |
| 160 |
- * 비밀번호는 등록·수정 모두 필수다. 빈 값일 때의 안내 문구만 호출부가 정한다 — 등록은 형식 안내, |
|
| 161 |
- * 수정은 "비우면 로그인 불가"라는 백엔드 제약을 알려야 하기 때문이다 |
|
| 162 |
- * (`ADMIN_PASSWORD_REQUIRED_ON_UPDATE_TEXT` 주석 참조). |
|
| 153 |
+ * **비밀번호·이메일은 등록에서만 필수다.** 수정에서 비우면 "바꾸지 않음"이 된다 — 백엔드 UPDATE의 |
|
| 154 |
+ * `LOGIN_PW`가 `<if test='loginPw != null and loginPw != ""'>`로 감싸여 있어 빈 값은 SET 절에서 |
|
| 155 |
+ * 빠진다(예전에는 조건 없이 덮어써서, 빈 값을 보내면 그 계정이 로그인 불가가 됐다). 값이 있으면 |
|
| 156 |
+ * 등록·수정 모두 같은 형식 규칙을 통과해야 한다. |
|
| 163 | 157 |
*/ |
| 164 | 158 |
function validateEditableValues( |
| 165 | 159 |
values: AdminMemberEditableValues, |
| 166 | 160 |
errors: AdminMemberFormErrors, |
| 167 |
- options: { emptyPasswordMessage: string }
|
|
| 161 |
+ options: { passwordRequired: boolean; emailRequired: boolean }
|
|
| 168 | 162 |
): AdminMemberEditableValues {
|
| 169 | 163 |
const password = values.password; |
| 170 | 164 |
const phoneNumber = values.phoneNumber.trim(); |
| 171 | 165 |
const email = values.email.trim(); |
| 172 | 166 |
|
| 173 | 167 |
if (!password) {
|
| 174 |
- errors.password = options.emptyPasswordMessage; |
|
| 168 |
+ if (options.passwordRequired) {
|
|
| 169 |
+ errors.password = ADMIN_PASSWORD_HELP_TEXT; |
|
| 170 |
+ } |
|
| 175 | 171 |
} else if ( |
| 176 |
- password.length < PASSWORD_MIN_LENGTH || |
|
| 177 |
- countCharacterKinds(password) < 2 |
|
| 172 |
+ password.length < ADMIN_PASSWORD_POLICY.minLength || |
|
| 173 |
+ countCharacterKinds(password) < ADMIN_PASSWORD_POLICY.kinds |
|
| 178 | 174 |
) {
|
| 179 | 175 |
errors.password = ADMIN_PASSWORD_HELP_TEXT; |
| 180 | 176 |
} |
... | ... | @@ -183,8 +179,12 @@ |
| 183 | 179 |
errors.phoneNumber = '휴대전화 번호를 정확히 입력해 주세요.'; |
| 184 | 180 |
} |
| 185 | 181 |
|
| 186 |
- // 이메일은 선택 항목이라 비어 있는 것 자체는 오류가 아니다(시안에 필수 표시가 없다). |
|
| 187 |
- if (email && !isValidEmail(email)) {
|
|
| 182 |
+ // 이메일 필수 여부는 두 시안이 다르다 — 등록(102_p)에는 `*`가 있고 수정(103_p)에는 없다. |
|
| 183 |
+ if (!email) {
|
|
| 184 |
+ if (options.emailRequired) {
|
|
| 185 |
+ errors.email = '이메일을 입력해 주세요.'; |
|
| 186 |
+ } |
|
| 187 |
+ } else if (!isValidEmail(email)) {
|
|
| 188 | 188 |
errors.email = '이메일 형식이 올바르지 않습니다.'; |
| 189 | 189 |
} |
| 190 | 190 |
|
... | ... | @@ -192,12 +192,12 @@ |
| 192 | 192 |
errors.roleCode = '역할을 선택해 주세요.'; |
| 193 | 193 |
} |
| 194 | 194 |
|
| 195 |
+ // 메뉴 선택은 **필수가 아니다**(사용자 확정) — 백엔드에 관리자별 메뉴 권한이 없어 고른 값이 |
|
| 196 |
+ // 저장되지 않는데, 저장되지도 않는 값 때문에 등록이 막히면 안 된다. 허용 목록 밖의 코드만 |
|
| 197 |
+ // 걸러 둔다(권한 API가 생기면 여기에 필수 규칙을 되살린다). |
|
| 195 | 198 |
const menuCodes = values.menuCodes.filter((code) => |
| 196 | 199 |
ADMIN_MENU_OPTIONS.some((option) => option.value === code) |
| 197 | 200 |
); |
| 198 |
- if (menuCodes.length === 0) {
|
|
| 199 |
- errors.menuCodes = '메뉴를 1개 이상 선택해 주세요.'; |
|
| 200 |
- } |
|
| 201 | 201 |
|
| 202 | 202 |
return { password, phoneNumber, email, roleCode: values.roleCode, menuCodes };
|
| 203 | 203 |
} |
... | ... | @@ -221,7 +221,8 @@ |
| 221 | 221 |
} |
| 222 | 222 |
|
| 223 | 223 |
const editable = validateEditableValues(values, errors, {
|
| 224 |
- emptyPasswordMessage: ADMIN_PASSWORD_HELP_TEXT, |
|
| 224 |
+ passwordRequired: true, |
|
| 225 |
+ emailRequired: true, |
|
| 225 | 226 |
}); |
| 226 | 227 |
|
| 227 | 228 |
if (Object.keys(errors).length > 0) {
|
... | ... | @@ -241,7 +242,8 @@ |
| 241 | 242 |
const errors: AdminMemberFormErrors = {};
|
| 242 | 243 |
|
| 243 | 244 |
const editable = validateEditableValues(values, errors, {
|
| 244 |
- emptyPasswordMessage: ADMIN_PASSWORD_REQUIRED_ON_UPDATE_TEXT, |
|
| 245 |
+ passwordRequired: false, |
|
| 246 |
+ emailRequired: false, |
|
| 245 | 247 |
}); |
| 246 | 248 |
|
| 247 | 249 |
if (Object.keys(errors).length > 0) {
|
--- lib/domain/admin-member-query.ts
+++ lib/domain/admin-member-query.ts
... | ... | @@ -14,21 +14,19 @@ |
| 14 | 14 |
/** 라우트 경로 — 이 파일 안에서만 하드코딩하고 나머지는 이 상수를 참조한다. */ |
| 15 | 15 |
export const ADMIN_MEMBERS_PATH = '/admins'; |
| 16 | 16 |
|
| 17 |
+/** 엑셀 다운로드 라우트 핸들러의 경로 — 목록 툴바의 폼이 이 주소로 GET 제출한다. */ |
|
| 18 |
+export const ADMIN_MEMBERS_EXCEL_PATH = `${ADMIN_MEMBERS_PATH}/excel`;
|
|
| 19 |
+ |
|
| 17 | 20 |
/** |
| 18 |
- * 검색 대상 — 시안(ADM_ADM_101 ①)은 회원명/ID/휴대전화번호 셋을 요구하지만 **휴대전화번호는 뺐다.** |
|
| 21 |
+ * 검색 대상 — 시안(ADM_ADM_101 ①)의 회원명/ID/휴대전화번호 셋. |
|
| 19 | 22 |
* |
| 20 |
- * 두 가지 이유가 겹친다: |
|
| 21 |
- * 1. 목록 응답에 휴대전화번호가 없다(`MngrAdminMapper.xml`의 select 목록에도, `MngrAdminVo`에도 |
|
| 22 |
- * 없다). 화면에 보이지도 않는 값으로 걸러낸 결과는 사용자가 검증할 수 없다. |
|
| 23 |
- * 2. 백엔드의 휴대전화번호 검색 분기(`searchCondition == "3"`)는 `USER_TELNO` 컬럼을 참조하는데, |
|
| 24 |
- * 이는 학생 목록 쿼리(TB_COM_USER 대상)에서 그대로 복사된 것으로 보인다 — 관리자 테이블 |
|
| 25 |
- * (TB_ADM_USER)의 select 목록에는 그런 컬럼이 없다. 컬럼이 실제로 없다면 이 검색은 SQL 오류로 |
|
| 26 |
- * 떨어져 화면 전체가 에러 경계로 넘어간다. |
|
| 23 |
+ * 휴대전화번호는 한동안 빠져 있었다. 목록 응답에 그 값이 없어 결과를 눈으로 검증할 수 없었고, |
|
| 24 |
+ * 백엔드의 해당 분기가 관리자 테이블에 없는 `USER_TELNO`를 참조해 SQL 오류가 날 상태였다. |
|
| 25 |
+ * 백엔드가 `ADM_TEL_NO`를 select 목록과 검색 분기 양쪽에 넣으면서 두 이유가 모두 사라졌다. |
|
| 27 | 26 |
* |
| 28 |
- * 백엔드가 휴대전화번호를 응답에 포함하면 여기와 `admin-member-repository.ts`의 필터에 함께 |
|
| 29 |
- * 추가한다. |
|
| 27 |
+ * 실제 필터링은 Repository가 전체를 손에 쥐고 수행한다 — 이 값은 "어느 열로 거를지"만 정한다. |
|
| 30 | 28 |
*/ |
| 31 |
-export type AdminMemberSearchField = 'name' | 'loginId'; |
|
| 29 |
+export type AdminMemberSearchField = 'name' | 'loginId' | 'phoneNumber'; |
|
| 32 | 30 |
|
| 33 | 31 |
export const ADMIN_MEMBER_SEARCH_FIELD_OPTIONS: ReadonlyArray<{
|
| 34 | 32 |
value: AdminMemberSearchField; |
... | ... | @@ -36,14 +34,15 @@ |
| 36 | 34 |
}> = [ |
| 37 | 35 |
{ value: 'name', label: '회원명' },
|
| 38 | 36 |
{ value: 'loginId', label: 'ID' },
|
| 37 |
+ { value: 'phoneNumber', label: '휴대전화번호' },
|
|
| 39 | 38 |
]; |
| 40 | 39 |
|
| 41 | 40 |
/** |
| 42 | 41 |
* 정렬 기준 — 시안의 select는 "가입일순"이지만 관리자 회원의 해당 값은 생성일이라 이름을 맞췄다. |
| 43 | 42 |
* |
| 44 |
- * `createdAt`은 백엔드가 응답에 담아 주지 않는데도 정렬이 가능한 이유는, 목록 SQL의 고정 |
|
| 45 |
- * `ORDER BY rnum DESC`가 곧 생성일 최신순이기 때문이다(rnum은 `frst_reg_dt` 오름차순 행번호). |
|
| 46 |
- * 즉 이 정렬은 "백엔드가 준 순서를 그대로 쓴다"는 뜻이다 — 자세한 근거는 Repository 주석 참조. |
|
| 43 |
+ * 생성일순이 곧 "백엔드가 준 순서를 그대로 쓴다"는 뜻이다 — 목록 SQL의 고정 `ORDER BY rnum DESC`가 |
|
| 44 |
+ * 생성일 최신순이기 때문이다(rnum은 `frst_reg_dt` 오름차순 행번호). 정렬 파라미터가 없어 이름순만 |
|
| 45 |
+ * 우리가 정렬한다. |
|
| 47 | 46 |
*/ |
| 48 | 47 |
export type AdminMemberSortOption = 'createdAt' | 'name'; |
| 49 | 48 |
|
+++ lib/domain/common-code.ts
... | ... | @@ -0,0 +1,34 @@ |
| 1 | +/** | |
| 2 | + * 공통코드 도메인 — 순수 데이터 표현, 외부 의존 없음. | |
| 3 | + * | |
| 4 | + * 백엔드 `TB_SYS_COM_CD_DTL`의 한 그룹(`COM_CD`)에 속한 상세코드 목록이며, | |
| 5 | + * `GET /api/v1/mngr/code/list/{comCd}`(ROLE_ADMIN)로 가져온다. | |
| 6 | + */ | |
| 7 | + | |
| 8 | +export type CommonCode = { | |
| 9 | + /** 백엔드 `comDtlCd` — 저장·전송에 쓰는 코드값. */ | |
| 10 | + code: string; | |
| 11 | + /** 백엔드 `cdNm` — 화면에 보이는 이름. */ | |
| 12 | + label: string; | |
| 13 | +}; | |
| 14 | + | |
| 15 | +/** 코드 그룹 ID — 화면이 문자열을 직접 적지 않도록 여기 모은다. */ | |
| 16 | +export const CODE_GROUP = { | |
| 17 | + /** 꾸미기 아이템 카테고리. */ | |
| 18 | + decorationItemCategory: 'ITEM_CATE_CD', | |
| 19 | +} as const; | |
| 20 | + | |
| 21 | +/** 코드값 → 이름. 목록에 없는 코드는 코드값 자체를 보여준다(이름을 지어내지 않는다). */ | |
| 22 | +export function formatCommonCode( | |
| 23 | + codes: readonly CommonCode[], | |
| 24 | + code: string | |
| 25 | +): string { | |
| 26 | + return codes.find((item) => item.code === code)?.label ?? code; | |
| 27 | +} | |
| 28 | + | |
| 29 | +export function isKnownCommonCode( | |
| 30 | + codes: readonly CommonCode[], | |
| 31 | + code: string | |
| 32 | +): boolean { | |
| 33 | + return codes.some((item) => item.code === code); | |
| 34 | +} |
--- lib/domain/decoration-item-form.ts
+++ lib/domain/decoration-item-form.ts
... | ... | @@ -13,8 +13,8 @@ |
| 13 | 13 |
* **이 파일이 검증의 단일 진실원천이다.** Server Action(`_actions.ts`)이 저장 직전에 여기를 거친다. |
| 14 | 14 |
*/ |
| 15 | 15 |
|
| 16 |
+import { isKnownCommonCode, type CommonCode } from '@/lib/domain/common-code';
|
|
| 16 | 17 |
import {
|
| 17 |
- DECORATION_ITEM_CATEGORIES, |
|
| 18 | 18 |
DECORATION_ITEM_TYPE_OPTIONS, |
| 19 | 19 |
type DecorationItemType, |
| 20 | 20 |
} from '@/lib/domain/decoration-item'; |
... | ... | @@ -69,10 +69,6 @@ |
| 69 | 69 |
return DECORATION_ITEM_TYPE_OPTIONS.some((option) => option.value === value); |
| 70 | 70 |
} |
| 71 | 71 |
|
| 72 |
-function isDecorationItemCategoryCode(value: string): boolean {
|
|
| 73 |
- return DECORATION_ITEM_CATEGORIES.some((category) => category.code === value); |
|
| 74 |
-} |
|
| 75 |
- |
|
| 76 | 72 |
/** |
| 77 | 73 |
* 등록·수정 공통 검증. 오류는 넘겨받은 객체에 채워 넣고, 정규화된 값을 돌려준다. |
| 78 | 74 |
* |
... | ... | @@ -83,6 +79,7 @@ |
| 83 | 79 |
*/ |
| 84 | 80 |
function validateEditableValues( |
| 85 | 81 |
values: DecorationItemEditableValues, |
| 82 |
+ categories: readonly CommonCode[], |
|
| 86 | 83 |
errors: DecorationItemFormErrors |
| 87 | 84 |
): DecorationItemEditableValues {
|
| 88 | 85 |
const name = values.name.trim(); |
... | ... | @@ -98,7 +95,7 @@ |
| 98 | 95 |
errors.name = `아이템명은 ${NAME_MAX_LENGTH}자 이내로 입력해 주세요.`;
|
| 99 | 96 |
} |
| 100 | 97 |
|
| 101 |
- if (!isDecorationItemCategoryCode(values.categoryCode)) {
|
|
| 98 |
+ if (!isKnownCommonCode(categories, values.categoryCode)) {
|
|
| 102 | 99 |
errors.categoryCode = '카테고리를 선택해 주세요.'; |
| 103 | 100 |
} |
| 104 | 101 |
|
... | ... | @@ -123,12 +120,18 @@ |
| 123 | 120 |
return { ...values, name, description };
|
| 124 | 121 |
} |
| 125 | 122 |
|
| 126 |
-/** 시안 ADM_ITM_102_p — 등록 검증. */ |
|
| 123 |
+/** |
|
| 124 |
+ * 시안 ADM_ITM_102_p — 등록 검증. |
|
| 125 |
+ * |
|
| 126 |
+ * 허용 카테고리는 상수가 아니라 **인자로 받는다** — 값이 백엔드 공통코드(`ITEM_CATE_CD`)에서 |
|
| 127 |
+ * 오므로 이 파일이 알 수 없고, 알아서도 안 된다(domain 계층은 통신을 하지 않는다). |
|
| 128 |
+ */ |
|
| 127 | 129 |
export function validateDecorationItemCreate( |
| 128 |
- values: DecorationItemEditableValues |
|
| 130 |
+ values: DecorationItemEditableValues, |
|
| 131 |
+ categories: readonly CommonCode[] |
|
| 129 | 132 |
): ValidationResult<DecorationItemEditableValues> {
|
| 130 | 133 |
const errors: DecorationItemFormErrors = {};
|
| 131 |
- const editable = validateEditableValues(values, errors); |
|
| 134 |
+ const editable = validateEditableValues(values, categories, errors); |
|
| 132 | 135 |
|
| 133 | 136 |
if (Object.keys(errors).length > 0) {
|
| 134 | 137 |
return { ok: false, errors };
|
... | ... | @@ -139,7 +142,8 @@ |
| 139 | 142 |
|
| 140 | 143 |
/** 시안 ADM_ITM_103_p — 수정 검증. 아이템ID는 읽기 전용이라 검증 대상이 아니다. */ |
| 141 | 144 |
export function validateDecorationItemUpdate( |
| 142 |
- values: DecorationItemEditableValues |
|
| 145 |
+ values: DecorationItemEditableValues, |
|
| 146 |
+ categories: readonly CommonCode[] |
|
| 143 | 147 |
): ValidationResult<DecorationItemEditableValues> {
|
| 144 |
- return validateDecorationItemCreate(values); |
|
| 148 |
+ return validateDecorationItemCreate(values, categories); |
|
| 145 | 149 |
} |
--- lib/domain/decoration-item.ts
+++ lib/domain/decoration-item.ts
... | ... | @@ -26,27 +26,6 @@ |
| 26 | 26 |
|
| 27 | 27 |
export const DEFAULT_DECORATION_ITEM_TYPE: DecorationItemType = 'individual'; |
| 28 | 28 |
|
| 29 |
-/** |
|
| 30 |
- * 카테고리 — **임시 값이다.** 백엔드는 공통코드테이블(`TB_SYS_COM_CD_DTL`, `COM_CD='ITEM_CATE_CD'`)을 |
|
| 31 |
- * 조인해 `itemCateNm`을 내려주지만 그 코드가 아직 정비되지 않아, 사용자 지시에 따라 프론트에서 |
|
| 32 |
- * 임의 코드로 개발한다(시안 등록 팝업의 예시값 계절/축하/시즌을 그대로 씀). |
|
| 33 |
- * |
|
| 34 |
- * **백엔드에 코드가 추가되면 이 상수를 지우고 `GET /api/v1/common/code/ITEM_CATE_CD`(인증 불필요) |
|
| 35 |
- * 응답으로 교체한다.** 그때까지 저장되는 `itemCateCd`는 여기 정의된 임시 코드라, 실제 코드 체계가 |
|
| 36 |
- * 정해지면 기존 데이터의 코드값 마이그레이션이 필요하다. |
|
| 37 |
- */ |
|
| 38 |
-export const DECORATION_ITEM_CATEGORIES: ReadonlyArray<{
|
|
| 39 |
- code: string; |
|
| 40 |
- label: string; |
|
| 41 |
-}> = [ |
|
| 42 |
- { code: 'CATE01', label: '계절' },
|
|
| 43 |
- { code: 'CATE02', label: '축하' },
|
|
| 44 |
- { code: 'CATE03', label: '시즌' },
|
|
| 45 |
-]; |
|
| 46 |
- |
|
| 47 |
-export const DEFAULT_DECORATION_ITEM_CATEGORY_CODE = |
|
| 48 |
- DECORATION_ITEM_CATEGORIES[0].code; |
|
| 49 |
- |
|
| 50 | 29 |
export type DecorationItem = {
|
| 51 | 30 |
/** |
| 52 | 31 |
* 백엔드 `itemSn` — 자동증가 PK이자 화면의 "아이템ID"로 그대로 노출하는 값(사용자 확정 사항). |
... | ... | @@ -56,12 +35,9 @@ |
| 56 | 35 |
itemSn: number; |
| 57 | 36 |
itemType: DecorationItemType; |
| 58 | 37 |
name: string; |
| 59 |
- /** 백엔드 `itemCateCd` — 저장·전송에 쓰는 코드값. */ |
|
| 38 |
+ /** 백엔드 `itemCateCd` — 저장·전송에 쓰는 코드값(공통코드 `ITEM_CATE_CD`의 상세코드). */ |
|
| 60 | 39 |
categoryCode: string; |
| 61 |
- /** |
|
| 62 |
- * 백엔드 `itemCateNm` — 공통코드테이블 조인 결과. 코드가 코드테이블에 없으면 null로 온다 |
|
| 63 |
- * (지금은 임시 코드를 쓰므로 대개 null이다 — 화면은 `formatDecorationItemCategory`로 보완한다). |
|
| 64 |
- */ |
|
| 40 |
+ /** 백엔드 `itemCateNm` — 공통코드테이블 조인 결과. 코드가 코드테이블에 없으면 null이다. */ |
|
| 65 | 41 |
categoryName: string | null; |
| 66 | 42 |
/** 백엔드 `itemAmount` — 오픈 가능한 포인트. */ |
| 67 | 43 |
points: number; |
... | ... | @@ -82,9 +58,9 @@ |
| 82 | 58 |
/** 값이 없는 항목의 화면 표기. */ |
| 83 | 59 |
export const EMPTY_FIELD_PLACEHOLDER = '-'; |
| 84 | 60 |
|
| 85 |
-/** 사용여부 → 화면 표기(시안: O/X). */ |
|
| 61 |
+/** 사용여부 → 화면 표기(시안 ADM_ITM_101: 점 + "사용"/"미사용"). */ |
|
| 86 | 62 |
export function formatDecorationItemActiveLabel(isActive: boolean): string {
|
| 87 |
- return isActive ? 'O' : 'X'; |
|
| 63 |
+ return isActive ? '사용' : '미사용'; |
|
| 88 | 64 |
} |
| 89 | 65 |
|
| 90 | 66 |
/** 포인트 → 화면 표기(시안: "100 P" 형태). */ |
... | ... | @@ -93,19 +69,11 @@ |
| 93 | 69 |
} |
| 94 | 70 |
|
| 95 | 71 |
/** |
| 96 |
- * 카테고리 표기 — 백엔드가 코드테이블에서 찾은 이름을 우선 쓰고, 없으면 프론트 임시 목록에서 |
|
| 97 |
- * 찾고, 그것도 없으면 코드값 자체를 보여준다. 임시 코드 단계에서는 두 번째 경로가 주로 쓰이고, |
|
| 98 |
- * 백엔드 코드가 정비되면 자연스럽게 첫 번째 경로로 넘어간다. |
|
| 72 |
+ * 카테고리 표기 — 목록 SQL이 코드테이블을 조인해 내려 준 이름을 쓴다. 코드가 코드테이블에서 |
|
| 73 |
+ * 지워졌거나 값 자체가 비었을 때만 코드값·`-`로 떨어진다(이름을 지어내지 않는다). |
|
| 99 | 74 |
*/ |
| 100 | 75 |
export function formatDecorationItemCategory(item: DecorationItem): string {
|
| 101 |
- if (item.categoryName) {
|
|
| 102 |
- return item.categoryName; |
|
| 103 |
- } |
|
| 104 |
- |
|
| 105 |
- const known = DECORATION_ITEM_CATEGORIES.find( |
|
| 106 |
- (category) => category.code === item.categoryCode |
|
| 107 |
- ); |
|
| 108 |
- return known?.label ?? item.categoryCode ?? EMPTY_FIELD_PLACEHOLDER; |
|
| 76 |
+ return item.categoryName || item.categoryCode || EMPTY_FIELD_PLACEHOLDER; |
|
| 109 | 77 |
} |
| 110 | 78 |
|
| 111 | 79 |
/** 수정일시 표기 — 백엔드가 이미 `YYYY-MM-DD`로 포맷해 주므로 그대로 쓰고 null만 보완한다. */ |
+++ lib/domain/phone-number.ts
... | ... | @@ -0,0 +1,59 @@ |
| 1 | +/** | |
| 2 | + * 전화번호 표기. | |
| 3 | + * | |
| 4 | + * 백엔드는 하이픈 없이 숫자만 저장한다(예: `01011233400`). 시안은 `010-1123-3400`으로 | |
| 5 | + * 끊어 보여주므로 화면에 나가기 직전에 여기서 끊는다 — 저장된 값 자체는 건드리지 않는다. | |
| 6 | + * | |
| 7 | + * 도메인마다 두지 않고 한 파일에 모은 이유는 학생·관리자·보호자가 같은 규칙을 써야 하기 | |
| 8 | + * 때문이다. | |
| 9 | + */ | |
| 10 | + | |
| 11 | +/** 자릿수별 묶음 규칙. 앞자리가 `02`인 서울 번호만 지역번호가 두 자리다. */ | |
| 12 | +function splitGroups(digits: string): string[] | null { | |
| 13 | + if (digits.startsWith('02')) { | |
| 14 | + if (digits.length === 9) { | |
| 15 | + return [digits.slice(0, 2), digits.slice(2, 5), digits.slice(5)]; | |
| 16 | + } | |
| 17 | + if (digits.length === 10) { | |
| 18 | + return [digits.slice(0, 2), digits.slice(2, 6), digits.slice(6)]; | |
| 19 | + } | |
| 20 | + return null; | |
| 21 | + } | |
| 22 | + | |
| 23 | + if (digits.length === 11) { | |
| 24 | + return [digits.slice(0, 3), digits.slice(3, 7), digits.slice(7)]; | |
| 25 | + } | |
| 26 | + if (digits.length === 10) { | |
| 27 | + return [digits.slice(0, 3), digits.slice(3, 6), digits.slice(6)]; | |
| 28 | + } | |
| 29 | + // 지역번호 없이 저장된 국번+번호. | |
| 30 | + if (digits.length === 8) { | |
| 31 | + return [digits.slice(0, 4), digits.slice(4)]; | |
| 32 | + } | |
| 33 | + | |
| 34 | + return null; | |
| 35 | +} | |
| 36 | + | |
| 37 | +/** | |
| 38 | + * 하이픈을 넣어 돌려준다. | |
| 39 | + * | |
| 40 | + * - 이미 하이픈이 들어 있으면 그대로 둔다 — 백엔드가 형식을 갖춰 준 값을 다시 끊지 않는다. | |
| 41 | + * - 자릿수가 알려진 규칙에 맞지 않으면 **원본을 그대로** 돌려준다. 임의로 끊으면 잘못된 | |
| 42 | + * 번호를 그럴듯하게 보여주게 되므로, 판단이 서지 않을 때는 손대지 않는다. | |
| 43 | + * - 값이 없으면 null 그대로다. `-` 표기는 호출부의 `formatOptionalValue`가 맡는다. | |
| 44 | + */ | |
| 45 | +export function formatPhoneNumber(value: string | null): string | null { | |
| 46 | + if (value === null) { | |
| 47 | + return null; | |
| 48 | + } | |
| 49 | + | |
| 50 | + const trimmed = value.trim(); | |
| 51 | + if (trimmed === '' || trimmed.includes('-')) { | |
| 52 | + return trimmed === '' ? null : trimmed; | |
| 53 | + } | |
| 54 | + | |
| 55 | + const digits = trimmed.replace(/\D/g, ''); | |
| 56 | + const groups = digits.length === trimmed.length ? splitGroups(digits) : null; | |
| 57 | + | |
| 58 | + return groups === null ? trimmed : groups.join('-'); | |
| 59 | +} |
--- lib/domain/student-member.ts
+++ lib/domain/student-member.ts
... | ... | @@ -52,15 +52,19 @@ |
| 52 | 52 |
|
| 53 | 53 |
/** |
| 54 | 54 |
* "학년/반/번호" 합성 표기. 표와 조회 팝업이 같은 규칙을 쓰도록 한 곳에 둔다. |
| 55 |
- * 세 값 중 하나라도 없으면 부분 문장("1학년 -반 -번")을 만들지 않고 통째로 `-`로 표기한다 —
|
|
| 56 |
- * 셋이 함께여야 의미가 성립하는 한 덩어리이기 때문이다. |
|
| 55 |
+ * |
|
| 56 |
+ * **있는 값만 이어 붙인다.** 백엔드 `TB_COM_USER`에는 GRADE·CLS_NO만 있고 학생 번호 컬럼이 |
|
| 57 |
+ * 아예 없어(코드베이스 전체 VO가 그 둘만 쓴다) 번호는 영영 오지 않는다. 셋이 다 있어야 |
|
| 58 |
+ * 표기하던 종전 규칙으로는 실제로 오는 학년·반까지 함께 묻혔다. |
|
| 59 |
+ * |
|
| 60 |
+ * 하나도 없으면 `-`다. |
|
| 57 | 61 |
*/ |
| 58 | 62 |
export function formatGradeClassNumber(member: StudentMember): string {
|
| 59 |
- const { grade, classNumber, studentNumber } = member;
|
|
| 63 |
+ const parts = [ |
|
| 64 |
+ member.grade === null ? null : `${member.grade}학년`,
|
|
| 65 |
+ member.classNumber === null ? null : `${member.classNumber}반`,
|
|
| 66 |
+ member.studentNumber === null ? null : `${member.studentNumber}번`,
|
|
| 67 |
+ ].filter((part): part is string => part !== null); |
|
| 60 | 68 |
|
| 61 |
- if (grade === null || classNumber === null || studentNumber === null) {
|
|
| 62 |
- return EMPTY_FIELD_PLACEHOLDER; |
|
| 63 |
- } |
|
| 64 |
- |
|
| 65 |
- return `${grade}학년 ${classNumber}반 ${studentNumber}번`;
|
|
| 69 |
+ return parts.length > 0 ? parts.join(' ') : EMPTY_FIELD_PLACEHOLDER;
|
|
| 66 | 70 |
} |
--- lib/http/backend-fetch.ts
+++ lib/http/backend-fetch.ts
... | ... | @@ -57,10 +57,13 @@ |
| 57 | 57 |
* form 인코딩 본문(`application/x-www-form-urlencoded`). `body`와 함께 지정하지 않는다. |
| 58 | 58 |
* |
| 59 | 59 |
* 백엔드의 일부 쓰기 API는 `@RequestBody`가 아니라 **`@ParameterObject`(= ModelAttribute |
| 60 |
- * 바인딩)** 로 파라미터를 받는다(예: 관리자 등록·수정 `MngrAdminRequestVo`, |
|
| 61 |
- * 아이템 등록·수정 `MngrItemRequestVo`). 그런 엔드포인트에 JSON을 보내면 바인딩이 하나도 되지 |
|
| 62 |
- * 않아 **전 필드가 null인 채로 저장된다** — 400도 나지 않고 조용히 빈 레코드가 생기므로, |
|
| 63 |
- * 엔드포인트가 어느 쪽인지 확인하고 맞는 형식을 골라야 한다. |
|
| 60 |
+ * 바인딩)** 로 파라미터를 받는다(예: 관리자 등록·수정 `MngrAdminRequestVo`, 아이템 **등록** |
|
| 61 |
+ * `MngrItemRequestVo`). 그런 엔드포인트에 JSON을 보내면 바인딩이 하나도 되지 않아 **전 필드가 |
|
| 62 |
+ * null인 채로 저장된다** — 400도 나지 않고 조용히 빈 레코드가 생기므로, 엔드포인트가 어느 |
|
| 63 |
+ * 쪽인지 확인하고 맞는 형식을 골라야 한다. |
|
| 64 |
+ * |
|
| 65 |
+ * ⚠️ **같은 도메인 안에서도 갈린다** — 아이템 **수정**은 `@RequestBody`(JSON)다. 등록만 보고 |
|
| 66 |
+ * 도메인 전체를 form으로 단정하면 수정이 조용히 깨진다. |
|
| 64 | 67 |
* |
| 65 | 68 |
* `undefined`인 값은 전송에서 제외한다(백엔드가 "미전송"과 "빈 문자열"을 다르게 볼 수 있다). |
| 66 | 69 |
*/ |
... | ... | @@ -89,6 +92,20 @@ |
| 89 | 92 |
* null이 직렬화됨). |
| 90 | 93 |
*/ |
| 91 | 94 |
canHaveNullData?: boolean; |
| 95 |
+ /** |
|
| 96 |
+ * 이 호출은 성공 시 **본문이 비어 올 수 있다.** 봉투를 파싱하지 않고 2xx를 그대로 성공으로 |
|
| 97 |
+ * 본다(`data`는 null). |
|
| 98 |
+ * |
|
| 99 |
+ * 백엔드가 봉투를 안 주는 엔드포인트가 실제로 있다 — 사용여부 변경 |
|
| 100 |
+ * (`PUT /api/v1/mngr/user/{userId}/{useYn}`)은 인터페이스에 `ApiResponseVO` 반환으로
|
|
| 101 |
+ * 문서화돼 있지만 구현이 `void`이고 `ApiResponseVO.success(null)`을 만들어 놓고 버려서, |
|
| 102 |
+ * `@RestController` + `void` + `HttpServletResponse` 조합상 본문 없는 200이 나간다 |
|
| 103 |
+ * (edupay-backend develop 4d98756 확인). |
|
| 104 |
+ * |
|
| 105 |
+ * 백엔드가 봉투를 돌려주도록 고쳐도 이 플래그를 그대로 둘 수 있다 — 본문이 있으면 아래에서 |
|
| 106 |
+ * 정상적으로 파싱한다. |
|
| 107 |
+ */ |
|
| 108 |
+ canHaveEmptyBody?: boolean; |
|
| 92 | 109 |
/** 기본 타임아웃보다 오래 걸리는 호출(파일 업로드 등)이 값을 올려 잡는다. */ |
| 93 | 110 |
timeoutMs?: number; |
| 94 | 111 |
}; |
... | ... | @@ -268,6 +285,30 @@ |
| 268 | 285 |
return communicationError(`예상치 못한 HTTP 상태: ${response.status}`, undefined);
|
| 269 | 286 |
} |
| 270 | 287 |
|
| 288 |
+ // 본문 없는 2xx를 허용한 호출은 먼저 글자를 읽어 비었는지 본다 — 비어 있으면 `response.json()`이 |
|
| 289 |
+ // 파싱 오류를 던지므로 그 전에 갈라야 한다. |
|
| 290 |
+ if (init.canHaveEmptyBody) {
|
|
| 291 |
+ let raw: string; |
|
| 292 |
+ try {
|
|
| 293 |
+ raw = await response.text(); |
|
| 294 |
+ } catch (error) {
|
|
| 295 |
+ return communicationError('응답 읽기 실패', error);
|
|
| 296 |
+ } |
|
| 297 |
+ |
|
| 298 |
+ if (raw.trim() === '') {
|
|
| 299 |
+ return { ok: true, data: null as T };
|
|
| 300 |
+ } |
|
| 301 |
+ |
|
| 302 |
+ try {
|
|
| 303 |
+ const parsed = JSON.parse(raw) as BackendEnvelope<T>; |
|
| 304 |
+ return parsed.success |
|
| 305 |
+ ? { ok: true, data: parsed.data as T }
|
|
| 306 |
+ : { ok: false, code: parsed.code, message: parsed.message };
|
|
| 307 |
+ } catch (error) {
|
|
| 308 |
+ return communicationError('응답 파싱 실패', error);
|
|
| 309 |
+ } |
|
| 310 |
+ } |
|
| 311 |
+ |
|
| 271 | 312 |
let envelope: BackendEnvelope<T>; |
| 272 | 313 |
try {
|
| 273 | 314 |
envelope = (await response.json()) as BackendEnvelope<T>; |
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?