+++ @fox/core/components/fox-alert/fox-alert.tsx
... | ... | @@ -0,0 +1,132 @@ |
| 1 | +import type { ReactNode, Ref } from "react"; | |
| 2 | +import { | |
| 3 | + FoxChatDotsIcon, | |
| 4 | + FoxCheckCircleIcon, | |
| 5 | + FoxInfoIcon, | |
| 6 | + FoxProhibitIcon, | |
| 7 | + FoxWarningIcon, | |
| 8 | +} from "../../icons"; | |
| 9 | +import { cx } from "../../utils"; | |
| 10 | + | |
| 11 | +export type FoxAlertType = | |
| 12 | + | "default" | |
| 13 | + | "information" | |
| 14 | + | "success" | |
| 15 | + | "warning" | |
| 16 | + | "danger"; | |
| 17 | + | |
| 18 | +export interface FoxAlertProps { | |
| 19 | + /** | |
| 20 | + * 알림의 성격. 아이콘·배경·테두리·제목 색이 여기서 갈린다. | |
| 21 | + * | |
| 22 | + * 계열이 바꾸는 것은 배경 · 테두리 · 제목 글자색과 아이콘이다. 본문 글자색은 계열과 | |
| 23 | + * 무관하게 같다. | |
| 24 | + * | |
| 25 | + * 아이콘은 계열이 정하고 굵기는 시안대로 `duotone`이며, 색은 제목 글자와 같은 계열색이다. | |
| 26 | + */ | |
| 27 | + type?: FoxAlertType; | |
| 28 | + /** 굵은 제목 줄. 넘기지 않으면 영역을 렌더링하지 않는다. */ | |
| 29 | + title?: ReactNode; | |
| 30 | + /** 제목 아래 본문. 넘기지 않으면 영역을 렌더링하지 않는다. */ | |
| 31 | + message?: ReactNode; | |
| 32 | + /** | |
| 33 | + * 보조기술이 읽는 방식을 정한다(`FoxToast`와 같은 규칙). | |
| 34 | + * | |
| 35 | + * - `false`(기본): `role="status"` — 하던 말을 끊지 않는다. 화면과 함께 처음부터 놓여 | |
| 36 | + * 있는 알림은 이쪽이다. | |
| 37 | + * - `true`: `role="alert"` — 읽던 것을 끊고 바로 읽는다. 사용자의 동작 결과로 **그 자리에 | |
| 38 | + * 나타난** 알림에만 쓴다. | |
| 39 | + * | |
| 40 | + * `type="danger"`가 자동으로 이걸 켜지 않는다 — 위험한 내용인 것과 지금 끼어들어야 하는 | |
| 41 | + * 것은 다른 문제이고, 페이지와 함께 그려지는 경고까지 낭독을 끊으면 방해만 된다. | |
| 42 | + */ | |
| 43 | + urgent?: boolean; | |
| 44 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 45 | + hidden?: boolean; | |
| 46 | + id?: string; | |
| 47 | + /** 배치 조정용. 시안의 폭(720px)을 바꿔야 할 때도 여기로 준다. */ | |
| 48 | + className?: string; | |
| 49 | + ref?: Ref<HTMLDivElement>; | |
| 50 | +} | |
| 51 | + | |
| 52 | +/** `Record`로 고정해 계열을 추가하면 항목 누락이 타입 에러가 되게 한다. */ | |
| 53 | +const TYPE_CLASS: Record<FoxAlertType, string> = { | |
| 54 | + default: "fox-alert--default", | |
| 55 | + information: "fox-alert--information", | |
| 56 | + success: "fox-alert--success", | |
| 57 | + warning: "fox-alert--warning", | |
| 58 | + danger: "fox-alert--danger", | |
| 59 | +}; | |
| 60 | + | |
| 61 | +/** | |
| 62 | + * 계열이 정하는 아이콘. 호출부가 넘기지 않는다 — 무엇을 그릴지는 계열이 이미 답한다 | |
| 63 | + * (`FoxTag`와 같은 규칙). | |
| 64 | + * | |
| 65 | + * 시안 `ico/*`가 Phosphor 원본이라 이름이 그대로 붙는다(`ico/Prohibit` → | |
| 66 | + * `FoxProhibitIcon`). 손으로 SVG를 그려 넣지 않는다 — 아이콘은 생성물이 단일 출처다. | |
| 67 | + * | |
| 68 | + * 굵기는 시안이 `Weight=Duotone`이라 다섯 개 모두 `duotone`이다. 기본값(`regular`)으로 두면 | |
| 69 | + * 속이 빈 선 그림이 되어 시안과 달라지므로 반드시 적는다. | |
| 70 | + * | |
| 71 | + * duotone은 연한 면(`opacity` 0.2)과 진한 선을 겹쳐 그리는데 **둘 다 `currentColor`다** — | |
| 72 | + * 색은 스타일이 `.fox-alert__icon`에 준 하나를 쓰고, 연한 쪽만 알아서 흐려진다. | |
| 73 | + */ | |
| 74 | +const TYPE_ICON: Record<FoxAlertType, ReactNode> = { | |
| 75 | + default: <FoxChatDotsIcon weight="duotone" />, | |
| 76 | + information: <FoxInfoIcon weight="duotone" />, | |
| 77 | + success: <FoxCheckCircleIcon weight="duotone" />, | |
| 78 | + warning: <FoxWarningIcon weight="duotone" />, | |
| 79 | + danger: <FoxProhibitIcon weight="duotone" />, | |
| 80 | +}; | |
| 81 | + | |
| 82 | +/** | |
| 83 | + * @fox 알럿. 화면 안에 놓이는 띠 모양 알림이다 — 아이콘 상자 · 제목 · 본문이 한 줄로 선다. | |
| 84 | + * | |
| 85 | + * **모달이 아니다.** 화면을 덮지도, 포커스를 가두지도, 확인 버튼을 갖지도 않는다. 내용 흐름 | |
| 86 | + * 안에 그대로 놓이고, 언제 나타나고 사라질지는 호출부가 정한다(`FoxToast`와 같은 방식). | |
| 87 | + * | |
| 88 | + * 상호작용이 없어 `"use client"`가 아니다 — 서버 컴포넌트로 렌더된다. | |
| 89 | + * | |
| 90 | + * ⚠️ 시안 주석: **본문은 두 줄 이내**로 쓴다. 길이를 코드가 막지는 않는다 — 세 줄이 되면 | |
| 91 | + * 아이콘 상자만 가운데 남고 띠가 늘어난다. | |
| 92 | + * | |
| 93 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 94 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 95 | + */ | |
| 96 | +export function FoxAlert({ | |
| 97 | + type = "default", | |
| 98 | + title, | |
| 99 | + message, | |
| 100 | + urgent = false, | |
| 101 | + hidden = false, | |
| 102 | + id, | |
| 103 | + className, | |
| 104 | + ref, | |
| 105 | +}: FoxAlertProps) { | |
| 106 | + if (hidden) { | |
| 107 | + return null; | |
| 108 | + } | |
| 109 | + | |
| 110 | + return ( | |
| 111 | + <div | |
| 112 | + ref={ref} | |
| 113 | + id={id} | |
| 114 | + role={urgent ? "alert" : "status"} | |
| 115 | + className={cx("fox-alert", TYPE_CLASS[type], className)} | |
| 116 | + > | |
| 117 | + {/* 아이콘이 정해지기 전에도 상자는 그린다 — 시안이 흰 판을 배치의 일부로 준다. */} | |
| 118 | + <span className="fox-alert__icon" aria-hidden="true"> | |
| 119 | + {TYPE_ICON[type]} | |
| 120 | + </span> | |
| 121 | + | |
| 122 | + <div className="fox-alert__content"> | |
| 123 | + {title === undefined ? null : ( | |
| 124 | + <strong className="fox-alert__title">{title}</strong> | |
| 125 | + )} | |
| 126 | + {message === undefined ? null : ( | |
| 127 | + <span className="fox-alert__message">{message}</span> | |
| 128 | + )} | |
| 129 | + </div> | |
| 130 | + </div> | |
| 131 | + ); | |
| 132 | +} |
+++ @fox/core/components/fox-alert/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export { FoxAlert, type FoxAlertProps, type FoxAlertType } from "./fox-alert"; |
+++ @fox/core/components/fox-contextual-trigger/fox-contextual-trigger.tsx
... | ... | @@ -0,0 +1,132 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import type { ComponentPropsWithRef, ReactNode } from "react"; | |
| 4 | +import { FoxInfoIcon, FoxQuestionIcon } from "../../icons"; | |
| 5 | +import { cx } from "../../utils"; | |
| 6 | + | |
| 7 | +/** 무엇을 여는 단추인지. 아이콘과 기본 이름이 여기서 갈린다. */ | |
| 8 | +export type FoxContextualTriggerType = "help" | "information"; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * 네이티브 `<button>`이 받는 것을 그대로 넘겨받는다 — onFocus·onBlur·onKeyDown·form 등. | |
| 12 | + * 툴팁을 띄우는 자리에서 `onFocus`·`onBlur`가 실제로 필요하다(마우스만 보면 키보드 | |
| 13 | + * 사용자가 툴팁을 볼 수 없다). 의미가 겹치는 것만 걷어내고 아래에서 다시 정의한다. | |
| 14 | + */ | |
| 15 | +type NativeButtonProps = Omit< | |
| 16 | + ComponentPropsWithRef<"button">, | |
| 17 | + "type" | "className" | "children" | "hidden" | "onClick" | |
| 18 | +>; | |
| 19 | + | |
| 20 | +export interface FoxContextualTriggerProps extends NativeButtonProps { | |
| 21 | + /** 무엇을 여는 단추인지. 네이티브 `type`은 언제나 `"button"`으로 고정된다. */ | |
| 22 | + type?: FoxContextualTriggerType; | |
| 23 | + /** | |
| 24 | + * 읽어 줄 이름. 넘기지 않으면 계열이 정한 기본값을 쓴다(`help`는 "도움말", | |
| 25 | + * `information`은 "설명"). | |
| 26 | + * | |
| 27 | + * 아이콘만 있는 단추라 이 글자가 **유일한 이름이다.** 한 화면에 여러 개가 있으면 | |
| 28 | + * "배송비 도움말"처럼 무엇에 대한 것인지 적어 준다 — 같은 이름이 여럿이면 소리로는 | |
| 29 | + * 구분되지 않는다. | |
| 30 | + */ | |
| 31 | + ariaLabel?: string; | |
| 32 | + /** | |
| 33 | + * 이 단추가 설명하는 내용의 id. **툴팁을 여는 자리에 쓴다** — 트리거가 | |
| 34 | + * `aria-describedby`로 툴팁을 가리켜야 보조기술이 그 글을 읽어 준다(`FoxTooltip`· | |
| 35 | + * `FoxTooltipRich`는 스스로 아무 역할도 갖지 않는다). | |
| 36 | + */ | |
| 37 | + describedBy?: string; | |
| 38 | + /** | |
| 39 | + * 눌러서 여는 판이 있을 때 그 판의 id. **팝오버를 여는 자리에 쓴다.** | |
| 40 | + * `expanded`와 함께 넘긴다. | |
| 41 | + */ | |
| 42 | + controls?: string; | |
| 43 | + /** 눌러서 여는 판이 지금 열려 있는지(`aria-expanded`). 팝오버를 여는 자리에서만 쓴다. */ | |
| 44 | + expanded?: boolean; | |
| 45 | + disabled?: boolean; | |
| 46 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 47 | + hidden?: boolean; | |
| 48 | + /** 눌렀을 때. 팝오버처럼 눌러서 여는 판에 쓴다. */ | |
| 49 | + onAction?: () => void; | |
| 50 | + /** | |
| 51 | + * 마우스가 올라오고 벗어날 때. **툴팁을 띄우고 지우는 자리에 쓴다** — 이 단추는 상태를 | |
| 52 | + * 갖지 않으므로 무엇을 보일지는 호출부가 정한다. | |
| 53 | + * | |
| 54 | + * ⚠️ 마우스만 보면 키보드 사용자가 툴팁을 볼 수 없다. 호출부가 `onFocus`·`onBlur`도 함께 | |
| 55 | + * 다뤄야 한다 — 네이티브 속성이라 그대로 넘기면 된다. | |
| 56 | + */ | |
| 57 | + onHoverChange?: (hovered: boolean) => void; | |
| 58 | + /** 배치 조정용. 모양이 달라야 하면 여기 말고 `type`을 쓴다. */ | |
| 59 | + className?: string; | |
| 60 | +} | |
| 61 | + | |
| 62 | +/** `Record`로 고정해 계열을 추가하면 항목 누락이 타입 에러가 되게 한다. */ | |
| 63 | +const TYPE_CLASS: Record<FoxContextualTriggerType, string> = { | |
| 64 | + help: "fox-contextual-trigger--help", | |
| 65 | + information: "fox-contextual-trigger--information", | |
| 66 | +}; | |
| 67 | + | |
| 68 | +/** 계열이 정하는 아이콘. 시안 `ico/Question`·`ico/Info`이고 굵기는 둘 다 regular다. */ | |
| 69 | +const TYPE_ICON: Record<FoxContextualTriggerType, ReactNode> = { | |
| 70 | + help: <FoxQuestionIcon />, | |
| 71 | + information: <FoxInfoIcon />, | |
| 72 | +}; | |
| 73 | + | |
| 74 | +/** 이름을 안 주면 쓰는 기본값. 무엇을 여는 단추인지까지만 말한다. */ | |
| 75 | +const TYPE_LABEL: Record<FoxContextualTriggerType, string> = { | |
| 76 | + help: "도움말", | |
| 77 | + information: "설명", | |
| 78 | +}; | |
| 79 | + | |
| 80 | +/** | |
| 81 | + * @fox 도움말·설명 단추. 툴팁이나 팝오버를 여는 24px짜리 아이콘 단추다. | |
| 82 | + * | |
| 83 | + * 상태를 갖지 않는다 — 무엇을 언제 보여 줄지는 호출부가 정하고, 이 단추는 그 배선을 걸 자리 | |
| 84 | + * (`describedBy`·`controls`·`expanded`)와 손이 닿는 자리를 준다. | |
| 85 | + * | |
| 86 | + * `FoxIconButton`과 다른 조각이다. 저쪽은 폼 컨트롤과 높이를 맞추는 단추라 크기가 | |
| 87 | + * `form(height-*)`을 따르는데(가장 작은 xsm도 32px), 이쪽은 글줄 옆에 붙는 24px 고정이고 | |
| 88 | + * 모서리도 `radius-2`(4px)다. | |
| 89 | + * | |
| 90 | + * 배경은 세 상태 모두 `action-secondary` 계열이다 — 기본이 투명이라 아래 배경이 그대로 | |
| 91 | + * 비치고, hover·pressed에서만 연한 판이 깔린다(`FoxTag`와 같은 규칙). | |
| 92 | + * | |
| 93 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 94 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 95 | + */ | |
| 96 | +export function FoxContextualTrigger({ | |
| 97 | + type = "help", | |
| 98 | + ariaLabel, | |
| 99 | + describedBy, | |
| 100 | + controls, | |
| 101 | + expanded, | |
| 102 | + disabled = false, | |
| 103 | + hidden = false, | |
| 104 | + onAction, | |
| 105 | + onHoverChange, | |
| 106 | + className, | |
| 107 | + ...rest | |
| 108 | +}: FoxContextualTriggerProps) { | |
| 109 | + if (hidden) { | |
| 110 | + return null; | |
| 111 | + } | |
| 112 | + | |
| 113 | + return ( | |
| 114 | + <button | |
| 115 | + {...rest} | |
| 116 | + type="button" | |
| 117 | + disabled={disabled} | |
| 118 | + aria-label={ariaLabel ?? TYPE_LABEL[type]} | |
| 119 | + aria-describedby={describedBy} | |
| 120 | + aria-controls={controls} | |
| 121 | + aria-expanded={expanded} | |
| 122 | + className={cx("fox-contextual-trigger", TYPE_CLASS[type], className)} | |
| 123 | + onClick={onAction} | |
| 124 | + onMouseEnter={onHoverChange ? () => onHoverChange(true) : undefined} | |
| 125 | + onMouseLeave={onHoverChange ? () => onHoverChange(false) : undefined} | |
| 126 | + > | |
| 127 | + <span className="fox-contextual-trigger__icon" aria-hidden="true"> | |
| 128 | + {TYPE_ICON[type]} | |
| 129 | + </span> | |
| 130 | + </button> | |
| 131 | + ); | |
| 132 | +} |
+++ @fox/core/components/fox-contextual-trigger/index.ts
... | ... | @@ -0,0 +1,5 @@ |
| 1 | +export { | |
| 2 | + FoxContextualTrigger, | |
| 3 | + type FoxContextualTriggerProps, | |
| 4 | + type FoxContextualTriggerType, | |
| 5 | +} from "./fox-contextual-trigger"; |
--- @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-heading-group/fox-heading-group.tsx
... | ... | @@ -0,0 +1,64 @@ |
| 1 | +import type { ReactNode, Ref } from "react"; | |
| 2 | +import { cx } from "../../utils"; | |
| 3 | + | |
| 4 | +export interface FoxHeadingGroupProps { | |
| 5 | + /** 구역 제목. 화면 제목(`FoxPageHeader`)이 아니라 그 아래 구역의 이름이다. */ | |
| 6 | + title: ReactNode; | |
| 7 | + /** | |
| 8 | + * 제목을 그릴 태그. 한 화면에 여러 구역이 서면 문서 구조가 어긋나지 않게 단계를 고른다. | |
| 9 | + * 화면 제목은 `FoxPageHeader`가 `<h1>`으로 그리므로 여기 기본은 `h2`다. | |
| 10 | + */ | |
| 11 | + as?: "h2" | "h3" | "h4"; | |
| 12 | + /** 제목 아래 한 줄 설명. 넘기지 않으면 영역을 그리지 않는다. */ | |
| 13 | + description?: ReactNode; | |
| 14 | + /** 제목 오른쪽 자리. 보통 버튼이다. */ | |
| 15 | + actions?: ReactNode; | |
| 16 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 17 | + hidden?: boolean; | |
| 18 | + /** 배치 조정용. */ | |
| 19 | + className?: string; | |
| 20 | + ref?: Ref<HTMLDivElement>; | |
| 21 | +} | |
| 22 | + | |
| 23 | +/** | |
| 24 | + * @fox 구역 머리말 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) heading-group (3002:7177) | |
| 25 | + * | |
| 26 | + * 한 화면 안에서 목록·표 같은 구역을 이름 짓는다. `FoxPageHeader`와 역할이 다르다 — 그쪽은 | |
| 27 | + * 화면에 하나뿐인 제목(`<h1>`)과 현재 위치를 갖고, 이쪽은 화면 안에 여럿 설 수 있다. | |
| 28 | + * 그래서 제목 태그를 `as`로 고를 수 있고 breadcrumb이 없다. | |
| 29 | + * | |
| 30 | + * 아래 여백은 이 조각이 갖는다(시안 spacing/bottom/md) — 뒤따르는 도구 줄·표가 간격을 따로 | |
| 31 | + * 두지 않아도 되게 하려는 것이고, `FoxPageHeader`와 같은 규칙이다. | |
| 32 | + * | |
| 33 | + * 상호작용이 없어 `"use client"`가 아니다 — 서버 컴포넌트로 렌더된다. | |
| 34 | + * | |
| 35 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 36 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 37 | + */ | |
| 38 | +export function FoxHeadingGroup({ | |
| 39 | + title, | |
| 40 | + as: Tag = "h2", | |
| 41 | + description, | |
| 42 | + actions, | |
| 43 | + hidden = false, | |
| 44 | + className, | |
| 45 | + ref, | |
| 46 | +}: FoxHeadingGroupProps) { | |
| 47 | + if (hidden) { | |
| 48 | + return null; | |
| 49 | + } | |
| 50 | + | |
| 51 | + return ( | |
| 52 | + <div ref={ref} className={cx("fox-heading-group", className)}> | |
| 53 | + <div className="fox-heading-group__text"> | |
| 54 | + <div className="fox-heading-group__heading"> | |
| 55 | + <Tag className="fox-heading-group__title">{title}</Tag> | |
| 56 | + </div> | |
| 57 | + {description && ( | |
| 58 | + <p className="fox-heading-group__description">{description}</p> | |
| 59 | + )} | |
| 60 | + </div> | |
| 61 | + {actions && <div className="fox-heading-group__actions">{actions}</div>} | |
| 62 | + </div> | |
| 63 | + ); | |
| 64 | +} |
+++ @fox/core/components/fox-heading-group/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export { FoxHeadingGroup, type FoxHeadingGroupProps } from "./fox-heading-group"; |
+++ @fox/core/components/fox-helper-text/fox-helper-text.tsx
... | ... | @@ -0,0 +1,105 @@ |
| 1 | +import type { ReactNode, Ref } from "react"; | |
| 2 | +import { | |
| 3 | + FoxChatDotsIcon, | |
| 4 | + FoxCheckCircleIcon, | |
| 5 | + FoxInfoIcon, | |
| 6 | + FoxProhibitIcon, | |
| 7 | + FoxWarningIcon, | |
| 8 | +} from "../../icons"; | |
| 9 | +import { cx } from "../../utils"; | |
| 10 | + | |
| 11 | +/** `FoxAlert`와 같은 다섯 계열이다 — 시안이 두 컴포넌트에 같은 축을 쓴다. */ | |
| 12 | +export type FoxHelperTextType = | |
| 13 | + | "default" | |
| 14 | + | "information" | |
| 15 | + | "success" | |
| 16 | + | "warning" | |
| 17 | + | "danger"; | |
| 18 | + | |
| 19 | +export interface FoxHelperTextProps { | |
| 20 | + /** 글자색과 아이콘이 여기서 갈린다. */ | |
| 21 | + type?: FoxHelperTextType; | |
| 22 | + /** 보여 줄 문구. */ | |
| 23 | + message: ReactNode; | |
| 24 | + /** | |
| 25 | + * 아이콘을 그릴지. 시안은 다섯 계열 모두 아이콘을 달지만, 글자만 필요한 자리가 있어 | |
| 26 | + * 끌 수 있게 둔다 — 폼 컴포넌트의 헬퍼가 지금 아이콘 없이 쓰이는 자리가 많다 | |
| 27 | + * (`FoxInput.messageIcon`이 선택값인 것과 같은 사정). | |
| 28 | + */ | |
| 29 | + showIcon?: boolean; | |
| 30 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 31 | + hidden?: boolean; | |
| 32 | + /** | |
| 33 | + * 입력의 `aria-describedby`가 가리킬 값. 폼 옆에 놓을 때 넘겨서 이어 준다 — | |
| 34 | + * 이어 주지 않으면 화면에만 보이고 소리로는 전달되지 않는다. | |
| 35 | + */ | |
| 36 | + id?: string; | |
| 37 | + /** 배치 조정용. 모양이 달라야 하면 여기 말고 `type`을 쓴다. */ | |
| 38 | + className?: string; | |
| 39 | + ref?: Ref<HTMLParagraphElement>; | |
| 40 | +} | |
| 41 | + | |
| 42 | +/** `Record`로 고정해 계열을 추가하면 항목 누락이 타입 에러가 되게 한다. */ | |
| 43 | +const TYPE_CLASS: Record<FoxHelperTextType, string> = { | |
| 44 | + default: "fox-helper-text--default", | |
| 45 | + information: "fox-helper-text--information", | |
| 46 | + success: "fox-helper-text--success", | |
| 47 | + warning: "fox-helper-text--warning", | |
| 48 | + danger: "fox-helper-text--danger", | |
| 49 | +}; | |
| 50 | + | |
| 51 | +/** | |
| 52 | + * 계열이 정하는 아이콘. 글리프는 `FoxAlert`와 같은 것을 쓰고 **굵기만 다르다** — | |
| 53 | + * 알럿은 `duotone`, 여기는 `regular`다(시안 확인). | |
| 54 | + */ | |
| 55 | +const TYPE_ICON: Record<FoxHelperTextType, ReactNode> = { | |
| 56 | + default: <FoxChatDotsIcon />, | |
| 57 | + information: <FoxInfoIcon />, | |
| 58 | + success: <FoxCheckCircleIcon />, | |
| 59 | + warning: <FoxWarningIcon />, | |
| 60 | + danger: <FoxProhibitIcon />, | |
| 61 | +}; | |
| 62 | + | |
| 63 | +/** | |
| 64 | + * @fox 헬퍼 텍스트. 입력 아래 한 줄로 붙는 안내·오류 문구다. | |
| 65 | + * | |
| 66 | + * 상태를 갖지 않는다 — 어떤 계열로 보일지는 호출부가 정한다. | |
| 67 | + * | |
| 68 | + * TODO(폼 통합, 적용 후 이 문단 삭제): 폼 컴포넌트 여섯 곳(`FoxInput`·`FoxTextArea`· | |
| 69 | + * `FoxEmail`·`FoxPhoneNumber`·`FoxAddress`의 `__message`, `FoxSelect`의 `__hint`)이 | |
| 70 | + * 아직 각자 그린다. 이걸로 모으는 절차와 주의점은 `@fox/styles/_fox-helper-text.scss` | |
| 71 | + * 상단에 적어 두었다 — `FoxSelect`만 간격과 오류 아이콘 색이 달라지므로 확인이 필요하다. | |
| 72 | + * | |
| 73 | + * 상호작용이 없어 `"use client"`가 아니다 — 서버 컴포넌트로 렌더된다. | |
| 74 | + * | |
| 75 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 76 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 77 | + */ | |
| 78 | +export function FoxHelperText({ | |
| 79 | + type = "default", | |
| 80 | + message, | |
| 81 | + showIcon = true, | |
| 82 | + hidden = false, | |
| 83 | + id, | |
| 84 | + className, | |
| 85 | + ref, | |
| 86 | +}: FoxHelperTextProps) { | |
| 87 | + if (hidden) { | |
| 88 | + return null; | |
| 89 | + } | |
| 90 | + | |
| 91 | + return ( | |
| 92 | + <p | |
| 93 | + ref={ref} | |
| 94 | + id={id} | |
| 95 | + className={cx("fox-helper-text", TYPE_CLASS[type], className)} | |
| 96 | + > | |
| 97 | + {showIcon ? ( | |
| 98 | + <span className="fox-helper-text__icon" aria-hidden="true"> | |
| 99 | + {TYPE_ICON[type]} | |
| 100 | + </span> | |
| 101 | + ) : null} | |
| 102 | + <span className="fox-helper-text__message">{message}</span> | |
| 103 | + </p> | |
| 104 | + ); | |
| 105 | +} |
+++ @fox/core/components/fox-helper-text/index.ts
... | ... | @@ -0,0 +1,5 @@ |
| 1 | +export { | |
| 2 | + FoxHelperText, | |
| 3 | + type FoxHelperTextProps, | |
| 4 | + type FoxHelperTextType, | |
| 5 | +} from "./fox-helper-text"; |
--- @fox/core/components/fox-input/fox-input.tsx
+++ @fox/core/components/fox-input/fox-input.tsx
... | ... | @@ -48,6 +48,8 @@ |
| 48 | 48 |
messageIcon?: ReactNode; |
| 49 | 49 |
/** 입력 오른쪽 아이콘. `currentColor`로 그린 SVG여야 색이 적용된다. */ |
| 50 | 50 |
icon?: ReactNode; |
| 51 |
+ /** 입력 오른쪽에 붙는 글자. 아이콘 슬롯과 달리 크기가 글자에 맞춰진다. */ |
|
| 52 |
+ suffix?: ReactNode; |
|
| 51 | 53 |
/** 포커스 중이고 값이 있으면 지우기 버튼을 보여준다. */ |
| 52 | 54 |
clearable?: boolean; |
| 53 | 55 |
clearLabel?: string; |
... | ... | @@ -108,6 +110,7 @@ |
| 108 | 110 |
message, |
| 109 | 111 |
messageIcon, |
| 110 | 112 |
icon, |
| 113 |
+ suffix, |
|
| 111 | 114 |
clearable = false, |
| 112 | 115 |
clearLabel = "입력 지우기", |
| 113 | 116 |
invalid = false, |
... | ... | @@ -253,6 +256,8 @@ |
| 253 | 256 |
</button> |
| 254 | 257 |
)} |
| 255 | 258 |
|
| 259 |
+ {suffix && <span className="fox-input__suffix">{suffix}</span>}
|
|
| 260 |
+ |
|
| 256 | 261 |
{icon && (
|
| 257 | 262 |
<span className="fox-input__icon" aria-hidden="true"> |
| 258 | 263 |
{icon}
|
--- @fox/core/components/fox-list-container/fox-list-container.tsx
+++ @fox/core/components/fox-list-container/fox-list-container.tsx
... | ... | @@ -42,6 +42,11 @@ |
| 42 | 42 |
title?: ReactNode; |
| 43 | 43 |
breadcrumb?: FoxBreadcrumbItem[]; |
| 44 | 44 |
headerActions?: ReactNode; |
| 45 |
+ /** |
|
| 46 |
+ * 머리말과 목록 사이. 시안에서 이 자리에 오는 것은 `FoxTab`이다 — 목록 전체를 갈아 끼우는 |
|
| 47 |
+ * 전환이라 도구 줄(정렬·검색)보다 위에 선다. |
|
| 48 |
+ */ |
|
| 49 |
+ tabs?: ReactNode; |
|
| 45 | 50 |
|
| 46 | 51 |
// ── 표 ──────────────────────────────────────────────────────────────────── |
| 47 | 52 |
columns: FoxListColumn<T>[]; |
... | ... | @@ -107,6 +112,7 @@ |
| 107 | 112 |
title, |
| 108 | 113 |
breadcrumb, |
| 109 | 114 |
headerActions, |
| 115 |
+ tabs, |
|
| 110 | 116 |
columns, |
| 111 | 117 |
rows, |
| 112 | 118 |
rowKey, |
... | ... | @@ -170,6 +176,8 @@ |
| 170 | 176 |
/> |
| 171 | 177 |
)} |
| 172 | 178 |
|
| 179 |
+ {tabs}
|
|
| 180 |
+ |
|
| 173 | 181 |
<section className="fox-list-container__section"> |
| 174 | 182 |
{hasToolbar && (
|
| 175 | 183 |
<FoxListToolbar |
+++ @fox/core/components/fox-popover/fox-popover.tsx
... | ... | @@ -0,0 +1,151 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import type { ReactNode, Ref } from "react"; | |
| 4 | +import { FoxCaretRightIcon, FoxXIcon } from "../../icons"; | |
| 5 | +import { cx } from "../../utils"; | |
| 6 | +import { FoxTextButton } from "../fox-text-button"; | |
| 7 | + | |
| 8 | +/** 꼬리가 판의 위에 붙는지 아래에 붙는지. 판이 트리거의 아래/위 어느 쪽에 뜨는지가 따라온다. */ | |
| 9 | +export type FoxPopoverDirection = "top" | "bottom"; | |
| 10 | + | |
| 11 | +/** 꼬리가 판의 가로 어디에 붙는지. */ | |
| 12 | +export type FoxPopoverArrow = "left" | "center" | "right"; | |
| 13 | + | |
| 14 | +export interface FoxPopoverProps { | |
| 15 | + /** | |
| 16 | + * `top`이면 꼬리가 판 위에 붙는다 — 트리거 아래에 뜨는 말풍선이다. | |
| 17 | + * `bottom`이면 꼬리가 판 아래에 붙는다 — 트리거 위에 뜨는 말풍선이다. | |
| 18 | + */ | |
| 19 | + direction?: FoxPopoverDirection; | |
| 20 | + /** 꼬리의 가로 위치. 판 가장자리에서 24px 안쪽에 선다(가운데는 예외). */ | |
| 21 | + arrow?: FoxPopoverArrow; | |
| 22 | + /** 굵은 제목 줄. 넘기지 않으면 렌더링하지 않는다. */ | |
| 23 | + title?: ReactNode; | |
| 24 | + /** 제목 아래 본문. 넘기지 않으면 렌더링하지 않는다. */ | |
| 25 | + message?: ReactNode; | |
| 26 | + /** 아래쪽 글자 버튼의 문구(예: "바로가기"). 없으면 버튼을 그리지 않는다. */ | |
| 27 | + actionLabel?: string; | |
| 28 | + /** 글자 버튼을 눌렀을 때. `actionLabel`이 없으면 의미 없다. */ | |
| 29 | + onAction?: () => void; | |
| 30 | + /** 닫기(X)를 눌렀을 때. **필수다** — 시안이 닫기를 항상 그린다. */ | |
| 31 | + onClose: () => void; | |
| 32 | + /** 닫기 버튼을 읽어 줄 이름. X 아이콘은 장식이라 이 글자가 유일한 이름이다. */ | |
| 33 | + closeLabel?: string; | |
| 34 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 35 | + hidden?: boolean; | |
| 36 | + id?: string; | |
| 37 | + /** 배치 조정용. 트리거 옆에 놓는 위치와 시안의 폭(360px)을 여기로 덮는다. */ | |
| 38 | + className?: string; | |
| 39 | + ref?: Ref<HTMLDivElement>; | |
| 40 | +} | |
| 41 | + | |
| 42 | +const DIRECTION_CLASS: Record<FoxPopoverDirection, string> = { | |
| 43 | + top: "fox-popover--top", | |
| 44 | + bottom: "fox-popover--bottom", | |
| 45 | +}; | |
| 46 | + | |
| 47 | +const ARROW_CLASS: Record<FoxPopoverArrow, string> = { | |
| 48 | + left: "fox-popover--arrow-left", | |
| 49 | + center: "fox-popover--arrow-center", | |
| 50 | + right: "fox-popover--arrow-right", | |
| 51 | +}; | |
| 52 | + | |
| 53 | +/** | |
| 54 | + * @fox 팝오버. 꼬리가 달린 말풍선 판이다. 제목·본문·글자 버튼을 담고 오른쪽 위에 닫기가 붙는다. | |
| 55 | + * | |
| 56 | + * **상태를 갖지 않고 스스로 자리를 잡지도 않는다.** 언제 열리고 닫힐지는 호출부가 정하고, | |
| 57 | + * 트리거 옆 어디에 놓을지는 `className`으로 준다 — `direction`·`arrow`는 꼬리가 어디에 | |
| 58 | + * 붙는지만 정한다(자리가 모자랄 때 위아래를 뒤집는 자동 배치는 없다. 시안이 방향을 값으로 | |
| 59 | + * 주므로 그 판단은 호출부의 몫이다). | |
| 60 | + * | |
| 61 | + * ⚠️ 판을 띄우는 방식은 `FoxSelect`·`FoxChip`의 목록과 같다 — 감싼 요소 기준의 일반 흐름이라 | |
| 62 | + * `overflow: hidden` 조상 안에서는 잘린다. HTML `popover` 속성으로 top layer에 올리는 방법이 | |
| 63 | + * 있지만, 위치를 잡아 주는 CSS Anchor Positioning이 아직 크롬 계열에만 있어 미룬다 — 옮기게 | |
| 64 | + * 되면 셀렉트·칩·툴팁까지 한 번에 옮긴다. | |
| 65 | + * | |
| 66 | + * 꼬리는 **CSS가 그린다** — 이 컴포넌트가 내보내는 것은 빈 요소 하나뿐이다. 시안이 준 SVG는 | |
| 67 | + * 1px 흰 띠로 판의 1px 테두리를 지우는 구조라 소수 배율 화면에서 이음새에 실선이 비쳤고, | |
| 68 | + * 회전한 정사각형을 판에 절반 걸치는 방식으로 바꿨다(까닭은 스타일 파일 상단 참고). | |
| 69 | + * | |
| 70 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 71 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 72 | + */ | |
| 73 | +export function FoxPopover({ | |
| 74 | + direction = "top", | |
| 75 | + arrow = "left", | |
| 76 | + title, | |
| 77 | + message, | |
| 78 | + actionLabel, | |
| 79 | + onAction, | |
| 80 | + onClose, | |
| 81 | + closeLabel = "닫기", | |
| 82 | + hidden = false, | |
| 83 | + id, | |
| 84 | + className, | |
| 85 | + ref, | |
| 86 | +}: FoxPopoverProps) { | |
| 87 | + if (hidden) { | |
| 88 | + return null; | |
| 89 | + } | |
| 90 | + | |
| 91 | + // 꼬리는 CSS가 그린다 — 이 요소는 자리만 잡는다(스타일 파일 참고). | |
| 92 | + const arrowSlot = ( | |
| 93 | + <div className="fox-popover__arrow-area" aria-hidden="true"> | |
| 94 | + <span className="fox-popover__arrow" /> | |
| 95 | + </div> | |
| 96 | + ); | |
| 97 | + | |
| 98 | + return ( | |
| 99 | + <div | |
| 100 | + ref={ref} | |
| 101 | + id={id} | |
| 102 | + className={cx( | |
| 103 | + "fox-popover", | |
| 104 | + DIRECTION_CLASS[direction], | |
| 105 | + ARROW_CLASS[arrow], | |
| 106 | + className | |
| 107 | + )} | |
| 108 | + > | |
| 109 | + {direction === "top" ? arrowSlot : null} | |
| 110 | + | |
| 111 | + <div className="fox-popover__content"> | |
| 112 | + {title === undefined && message === undefined ? null : ( | |
| 113 | + <div className="fox-popover__body"> | |
| 114 | + {title === undefined ? null : ( | |
| 115 | + <div className="fox-popover__title-row"> | |
| 116 | + <strong className="fox-popover__title">{title}</strong> | |
| 117 | + </div> | |
| 118 | + )} | |
| 119 | + {message === undefined ? null : ( | |
| 120 | + <div className="fox-popover__message-row"> | |
| 121 | + <span className="fox-popover__message">{message}</span> | |
| 122 | + </div> | |
| 123 | + )} | |
| 124 | + </div> | |
| 125 | + )} | |
| 126 | + | |
| 127 | + {actionLabel ? ( | |
| 128 | + <FoxTextButton | |
| 129 | + type="secondary" | |
| 130 | + size="sm" | |
| 131 | + label={actionLabel} | |
| 132 | + trailingIcon={<FoxCaretRightIcon />} | |
| 133 | + onAction={onAction} | |
| 134 | + className="fox-popover__action" | |
| 135 | + /> | |
| 136 | + ) : null} | |
| 137 | + | |
| 138 | + <button | |
| 139 | + type="button" | |
| 140 | + className="fox-popover__close" | |
| 141 | + aria-label={closeLabel} | |
| 142 | + onClick={onClose} | |
| 143 | + > | |
| 144 | + <FoxXIcon /> | |
| 145 | + </button> | |
| 146 | + </div> | |
| 147 | + | |
| 148 | + {direction === "bottom" ? arrowSlot : null} | |
| 149 | + </div> | |
| 150 | + ); | |
| 151 | +} |
+++ @fox/core/components/fox-popover/index.ts
... | ... | @@ -0,0 +1,6 @@ |
| 1 | +export { | |
| 2 | + FoxPopover, | |
| 3 | + type FoxPopoverProps, | |
| 4 | + type FoxPopoverDirection, | |
| 5 | + type FoxPopoverArrow, | |
| 6 | +} from "./fox-popover"; |
+++ @fox/core/components/fox-snackbar-list/fox-snackbar-list.tsx
... | ... | @@ -0,0 +1,56 @@ |
| 1 | +import type { ReactNode, Ref } from "react"; | |
| 2 | +import { cx } from "../../utils"; | |
| 3 | + | |
| 4 | +export interface FoxSnackbarListProps { | |
| 5 | + /** 묶음을 읽어 줄 이름. 화면에 보이는 제목이 이미 있으면 `labelledBy`를 쓴다. */ | |
| 6 | + label?: string; | |
| 7 | + /** 묶음 이름 역할을 하는 요소의 id. `label`보다 우선한다 — 화면의 글자와 어긋나지 않는다. */ | |
| 8 | + labelledBy?: string; | |
| 9 | + /** `FoxSnackbar`들. */ | |
| 10 | + children?: ReactNode; | |
| 11 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 12 | + hidden?: boolean; | |
| 13 | + /** 배치 조정용. 띄우는 자리(fixed·bottom 등)와 시안의 폭(640px)을 여기로 덮는다. */ | |
| 14 | + className?: string; | |
| 15 | + ref?: Ref<HTMLDivElement>; | |
| 16 | +} | |
| 17 | + | |
| 18 | +/** | |
| 19 | + * @fox 스낵바 묶음. 스낵바가 여러 개일 때 세로로 쌓는다. 배치만 책임지고 상태를 갖지 않는다 — | |
| 20 | + * 무엇이 몇 개 떠 있는지, 어떤 순서로 쌓을지는 호출부가 소유한다. | |
| 21 | + * | |
| 22 | + * 자식에게 덮어쓰는 값이 없다(`FoxTagList`·`FoxChipArea`와 다른 점이다) — 스낵바에는 계열도 | |
| 23 | + * 크기도 없어서 묶음이 통일할 것이 폭과 간격뿐이다. | |
| 24 | + * | |
| 25 | + * 방향 수식어가 없다 — 스낵바는 세로로 쌓이는 것이 유일한 배치다. | |
| 26 | + * | |
| 27 | + * `role="group"`이다. 각 스낵바가 스스로 `role="status"`로 읽히므로 묶음은 자리만 만든다 — | |
| 28 | + * 묶음까지 live region으로 만들면 하나가 뜰 때마다 목록 전체가 다시 읽힌다. | |
| 29 | + * | |
| 30 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 31 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 32 | + */ | |
| 33 | +export function FoxSnackbarList({ | |
| 34 | + label, | |
| 35 | + labelledBy, | |
| 36 | + children, | |
| 37 | + hidden = false, | |
| 38 | + className, | |
| 39 | + ref, | |
| 40 | +}: FoxSnackbarListProps) { | |
| 41 | + if (hidden) { | |
| 42 | + return null; | |
| 43 | + } | |
| 44 | + | |
| 45 | + return ( | |
| 46 | + <div | |
| 47 | + ref={ref} | |
| 48 | + role="group" | |
| 49 | + aria-label={labelledBy ? undefined : label} | |
| 50 | + aria-labelledby={labelledBy} | |
| 51 | + className={cx("fox-snackbar-list", className)} | |
| 52 | + > | |
| 53 | + {children} | |
| 54 | + </div> | |
| 55 | + ); | |
| 56 | +} |
+++ @fox/core/components/fox-snackbar-list/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export { FoxSnackbarList, type FoxSnackbarListProps } from "./fox-snackbar-list"; |
+++ @fox/core/components/fox-snackbar/fox-snackbar.tsx
... | ... | @@ -0,0 +1,131 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import type { ReactNode, Ref } from "react"; | |
| 4 | +import { FoxXIcon } from "../../icons"; | |
| 5 | +import { cx } from "../../utils"; | |
| 6 | +import { FoxTextButton } from "../fox-text-button"; | |
| 7 | + | |
| 8 | +export interface FoxSnackbarProps { | |
| 9 | + /** | |
| 10 | + * 글자 앞에 붙는 아이콘. 넘기지 않으면 자리를 만들지 않는다. | |
| 11 | + * | |
| 12 | + * 계열이 없는 조각이라 무엇을 그릴지는 호출부가 정한다(`FoxAlert`처럼 계열이 아이콘을 | |
| 13 | + * 고르지 않는다). `@fox/core/icons`에서 가져다 넘긴다 — 시안은 `fill` 굵기다: | |
| 14 | + * `<FoxCheckCircleIcon weight="fill" />`. | |
| 15 | + * | |
| 16 | + * 색은 스타일이 정한다(`icon-theme-primary`) — 글리프가 `fill: currentColor`라 넘기는 | |
| 17 | + * 쪽에서 색을 줄 필요가 없다. | |
| 18 | + */ | |
| 19 | + icon?: ReactNode; | |
| 20 | + /** 굵은 첫 줄. 넘기지 않으면 영역을 렌더링하지 않는다 — 본문만 있는 스낵바가 된다. */ | |
| 21 | + title?: ReactNode; | |
| 22 | + /** 제목 아래 본문. 넘기지 않으면 영역을 렌더링하지 않는다. */ | |
| 23 | + message?: ReactNode; | |
| 24 | + /** | |
| 25 | + * 오른쪽 글자 버튼의 문구. 없으면 버튼을 그리지 않는다. | |
| 26 | + * | |
| 27 | + * 버튼은 `FoxTextButton`의 `primary`·`md`로 고정이다(시안 지정) — 계열·크기를 열지 않는 | |
| 28 | + * 것은 스낵바마다 버튼 모양이 달라지면 같은 조각으로 읽히지 않기 때문이다. | |
| 29 | + */ | |
| 30 | + actionLabel?: string; | |
| 31 | + /** 글자 버튼을 눌렀을 때. `actionLabel`이 없으면 의미 없다. */ | |
| 32 | + onAction?: () => void; | |
| 33 | + /** | |
| 34 | + * 닫기(X)를 눌렀을 때. **필수다** — 시안이 닫기를 항상 그리므로, 눌러도 아무 일이 없는 | |
| 35 | + * 버튼이 남지 않게 호출부가 반드시 처리를 준다. | |
| 36 | + */ | |
| 37 | + onClose: () => void; | |
| 38 | + /** 닫기 버튼을 읽어 줄 이름. X 아이콘은 장식이라 이 글자가 유일한 이름이다. */ | |
| 39 | + closeLabel?: string; | |
| 40 | + /** | |
| 41 | + * 보조기술이 읽는 방식을 정한다(`FoxToast`·`FoxAlert`와 같은 규칙). | |
| 42 | + * | |
| 43 | + * - `false`(기본): `role="status"` — 하던 말을 끊지 않는다. | |
| 44 | + * - `true`: `role="alert"` — 읽던 것을 끊고 바로 읽는다. 지금 손을 멈춰야 하는 알림에만. | |
| 45 | + */ | |
| 46 | + urgent?: boolean; | |
| 47 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 48 | + hidden?: boolean; | |
| 49 | + id?: string; | |
| 50 | + /** 배치 조정용. 띄우는 자리(fixed·bottom 등)와 시안의 폭(640px)을 여기로 덮는다. */ | |
| 51 | + className?: string; | |
| 52 | + ref?: Ref<HTMLDivElement>; | |
| 53 | +} | |
| 54 | + | |
| 55 | +/** | |
| 56 | + * @fox 스낵바. 흰 판 위에 굵은 한 줄과 글자 버튼이 서고, 오른쪽 끝에 닫기가 붙는다. | |
| 57 | + * 크기 수식어가 없다 — 시안에 하나뿐이다. | |
| 58 | + * | |
| 59 | + * `FoxToast`와 형제지만 **누를 것이 있다.** 토스트는 읽고 지나가는 조각이라 스스로 사라지고, | |
| 60 | + * 스낵바는 사용자가 되돌리거나(실행 취소) 이어서 할 일이 있어 손이 닿을 때까지 남는다. | |
| 61 | + * | |
| 62 | + * **상태를 갖지 않는다.** 언제 뜨고 언제 사라질지, 화면 어디에 놓일지는 전부 호출부가 정한다 | |
| 63 | + * — `onClose`는 "닫아 달라"는 알림이지 이 조각이 스스로 사라지는 것이 아니다. | |
| 64 | + * | |
| 65 | + * ⚠️ 보조기술은 **이미 화면에 있던 live region의 내용이 바뀔 때** 읽는다. 이 요소를 통째로 | |
| 66 | + * 새로 붙이면 조합에 따라 읽히지 않을 수 있다(`FoxToast`와 같은 사정). | |
| 67 | + * | |
| 68 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 69 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 70 | + */ | |
| 71 | +export function FoxSnackbar({ | |
| 72 | + icon, | |
| 73 | + title, | |
| 74 | + message, | |
| 75 | + actionLabel, | |
| 76 | + onAction, | |
| 77 | + onClose, | |
| 78 | + closeLabel = "닫기", | |
| 79 | + urgent = false, | |
| 80 | + hidden = false, | |
| 81 | + id, | |
| 82 | + className, | |
| 83 | + ref, | |
| 84 | +}: FoxSnackbarProps) { | |
| 85 | + if (hidden) { | |
| 86 | + return null; | |
| 87 | + } | |
| 88 | + | |
| 89 | + return ( | |
| 90 | + <div | |
| 91 | + ref={ref} | |
| 92 | + id={id} | |
| 93 | + role={urgent ? "alert" : "status"} | |
| 94 | + className={cx("fox-snackbar", className)} | |
| 95 | + > | |
| 96 | + {icon ? ( | |
| 97 | + <span className="fox-snackbar__icon" aria-hidden="true"> | |
| 98 | + {icon} | |
| 99 | + </span> | |
| 100 | + ) : null} | |
| 101 | + | |
| 102 | + <div className="fox-snackbar__content"> | |
| 103 | + {title === undefined ? null : ( | |
| 104 | + <strong className="fox-snackbar__title">{title}</strong> | |
| 105 | + )} | |
| 106 | + {message === undefined ? null : ( | |
| 107 | + <span className="fox-snackbar__message">{message}</span> | |
| 108 | + )} | |
| 109 | + </div> | |
| 110 | + | |
| 111 | + {actionLabel ? ( | |
| 112 | + <FoxTextButton | |
| 113 | + type="primary" | |
| 114 | + size="md" | |
| 115 | + label={actionLabel} | |
| 116 | + onAction={onAction} | |
| 117 | + className="fox-snackbar__action" | |
| 118 | + /> | |
| 119 | + ) : null} | |
| 120 | + | |
| 121 | + <button | |
| 122 | + type="button" | |
| 123 | + className="fox-snackbar__close" | |
| 124 | + aria-label={closeLabel} | |
| 125 | + onClick={onClose} | |
| 126 | + > | |
| 127 | + <FoxXIcon /> | |
| 128 | + </button> | |
| 129 | + </div> | |
| 130 | + ); | |
| 131 | +} |
+++ @fox/core/components/fox-snackbar/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export { FoxSnackbar, type FoxSnackbarProps } from "./fox-snackbar"; |
+++ @fox/core/components/fox-status-indicator/fox-status-indicator.tsx
... | ... | @@ -0,0 +1,71 @@ |
| 1 | +import type { ReactNode, Ref } from "react"; | |
| 2 | +import { cx } from "../../utils"; | |
| 3 | + | |
| 4 | +/** `FoxAlert`·`FoxHelperText`와 같은 다섯 계열이다 — 시안이 같은 축을 쓴다. */ | |
| 5 | +export type FoxStatusIndicatorType = | |
| 6 | + | "default" | |
| 7 | + | "information" | |
| 8 | + | "success" | |
| 9 | + | "warning" | |
| 10 | + | "danger"; | |
| 11 | + | |
| 12 | +export interface FoxStatusIndicatorProps { | |
| 13 | + /** 점과 글자의 색이 여기서 갈린다. */ | |
| 14 | + type?: FoxStatusIndicatorType; | |
| 15 | + /** 상태를 나타내는 글자. */ | |
| 16 | + label: ReactNode; | |
| 17 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 18 | + hidden?: boolean; | |
| 19 | + id?: string; | |
| 20 | + /** 배치 조정용. 모양이 달라야 하면 여기 말고 `type`을 쓴다. */ | |
| 21 | + className?: string; | |
| 22 | + ref?: Ref<HTMLSpanElement>; | |
| 23 | +} | |
| 24 | + | |
| 25 | +/** `Record`로 고정해 계열을 추가하면 항목 누락이 타입 에러가 되게 한다. */ | |
| 26 | +const TYPE_CLASS: Record<FoxStatusIndicatorType, string> = { | |
| 27 | + default: "fox-status-indicator--default", | |
| 28 | + information: "fox-status-indicator--information", | |
| 29 | + success: "fox-status-indicator--success", | |
| 30 | + warning: "fox-status-indicator--warning", | |
| 31 | + danger: "fox-status-indicator--danger", | |
| 32 | +}; | |
| 33 | + | |
| 34 | +/** | |
| 35 | + * @fox 상태 표시. 작은 점 하나와 글자로 상태를 알린다. 크기 수식어가 없다 — 시안에 하나뿐이다. | |
| 36 | + * | |
| 37 | + * **점은 장식이다**(`aria-hidden`). 상태의 의미는 글자가 전부 담는다 — 색만으로 뜻을 전하면 | |
| 38 | + * 색을 구분하지 못하는 사용자에게는 아무것도 전달되지 않기 때문이고, 그래서 `label`이 | |
| 39 | + * 선택값이 아니라 필수값이다. | |
| 40 | + * | |
| 41 | + * 값이 바뀌는 것을 소리로 알려야 하는 자리(예: 처리 중 → 완료)라면 감싼 쪽이 live region을 | |
| 42 | + * 만든다 — 이 조각은 상태를 갖지 않으므로 스스로 알리지 않는다. | |
| 43 | + * | |
| 44 | + * 상호작용이 없어 `"use client"`가 아니다 — 서버 컴포넌트로 렌더된다. | |
| 45 | + * | |
| 46 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 47 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 48 | + */ | |
| 49 | +export function FoxStatusIndicator({ | |
| 50 | + type = "default", | |
| 51 | + label, | |
| 52 | + hidden = false, | |
| 53 | + id, | |
| 54 | + className, | |
| 55 | + ref, | |
| 56 | +}: FoxStatusIndicatorProps) { | |
| 57 | + if (hidden) { | |
| 58 | + return null; | |
| 59 | + } | |
| 60 | + | |
| 61 | + return ( | |
| 62 | + <span | |
| 63 | + ref={ref} | |
| 64 | + id={id} | |
| 65 | + className={cx("fox-status-indicator", TYPE_CLASS[type], className)} | |
| 66 | + > | |
| 67 | + <span className="fox-status-indicator__dot" aria-hidden="true" /> | |
| 68 | + <span className="fox-status-indicator__label">{label}</span> | |
| 69 | + </span> | |
| 70 | + ); | |
| 71 | +} |
+++ @fox/core/components/fox-status-indicator/index.ts
... | ... | @@ -0,0 +1,5 @@ |
| 1 | +export { | |
| 2 | + FoxStatusIndicator, | |
| 3 | + type FoxStatusIndicatorProps, | |
| 4 | + type FoxStatusIndicatorType, | |
| 5 | +} from "./fox-status-indicator"; |
+++ @fox/core/components/fox-step-indicator-item/fox-step-indicator-item.tsx
... | ... | @@ -0,0 +1,108 @@ |
| 1 | +import type { ReactNode, Ref } from "react"; | |
| 2 | +import { cx } from "../../utils"; | |
| 3 | + | |
| 4 | +/** 시안의 state. 지난 단계 · 지금 단계 · 아직 오지 않은 단계다. */ | |
| 5 | +export type FoxStepIndicatorItemState = "completion" | "ongoing" | "before"; | |
| 6 | + | |
| 7 | +export interface FoxStepIndicatorItemProps { | |
| 8 | + state?: FoxStepIndicatorItemState; | |
| 9 | + /** 단계 번호 줄(작고 흐린 글자). 넘기지 않으면 렌더링하지 않는다. */ | |
| 10 | + step?: ReactNode; | |
| 11 | + /** 단계 제목 줄. 넘기지 않으면 렌더링하지 않는다. */ | |
| 12 | + title?: ReactNode; | |
| 13 | + /** | |
| 14 | + * 동그라미 오른쪽으로 뻗는 선. **마지막 단계에서는 꺼야 한다** — 켜 두면 선이 묶음 밖으로 | |
| 15 | + * 삐져나간다. 선 색은 `state`가 정한다(지난 단계만 진하다). | |
| 16 | + */ | |
| 17 | + line?: boolean; | |
| 18 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 19 | + hidden?: boolean; | |
| 20 | + id?: string; | |
| 21 | + /** 배치 조정용. 시안의 폭(80px)을 바꿔야 할 때도 여기로 준다. */ | |
| 22 | + className?: string; | |
| 23 | + ref?: Ref<HTMLDivElement>; | |
| 24 | +} | |
| 25 | + | |
| 26 | +/** `Record`로 고정해 상태를 추가하면 항목 누락이 타입 에러가 되게 한다. */ | |
| 27 | +const STATE_CLASS: Record<FoxStepIndicatorItemState, string> = { | |
| 28 | + completion: "fox-step-indicator-item--completion", | |
| 29 | + ongoing: "fox-step-indicator-item--ongoing", | |
| 30 | + before: "fox-step-indicator-item--before", | |
| 31 | +}; | |
| 32 | + | |
| 33 | +/** | |
| 34 | + * @fox 단계 하나. 위에 동그라미와 선, 아래에 단계 번호와 제목이 놓인다. | |
| 35 | + * `FoxStepIndicator` 안에 여러 개를 늘어놓아 쓴다. | |
| 36 | + * | |
| 37 | + * 상태를 갖지 않는다 — 어디까지 왔는지는 호출부가 각 단계의 `state`로 정한다. | |
| 38 | + * | |
| 39 | + * **모바일에서는 글자가 사라지고 동그라미와 선만 남는다.** 그 판단은 CSS가 한다(화면 폭) — | |
| 40 | + * 기기를 prop으로 받지 않는다. 조건부 렌더로 하면 창 폭이 바뀔 때마다 DOM이 들락거리고, | |
| 41 | + * React를 쓰지 않는 소비자는 같은 결과를 얻지 못한다(`FoxChipSelectOption`의 확인 버튼과 | |
| 42 | + * 같은 근거). | |
| 43 | + * | |
| 44 | + * ⚠️ 진행 상태를 소리로도 전해야 하면 감싼 쪽이 알린다 — 이 조각의 동그라미·선은 장식이라 | |
| 45 | + * 보조기술에 아무것도 말하지 않고, 읽히는 것은 단계 번호와 제목뿐이다. 모바일에서는 그 | |
| 46 | + * 글자마저 사라지므로, 그 화면에서 순서를 전해야 하면 호출부가 이름을 따로 준다. | |
| 47 | + * | |
| 48 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 49 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 50 | + */ | |
| 51 | +export function FoxStepIndicatorItem({ | |
| 52 | + state = "before", | |
| 53 | + step, | |
| 54 | + title, | |
| 55 | + line = true, | |
| 56 | + hidden = false, | |
| 57 | + id, | |
| 58 | + className, | |
| 59 | + ref, | |
| 60 | +}: FoxStepIndicatorItemProps) { | |
| 61 | + if (hidden) { | |
| 62 | + return null; | |
| 63 | + } | |
| 64 | + | |
| 65 | + return ( | |
| 66 | + <div | |
| 67 | + ref={ref} | |
| 68 | + id={id} | |
| 69 | + className={cx("fox-step-indicator-item", STATE_CLASS[state], className)} | |
| 70 | + > | |
| 71 | + <div className="fox-step-indicator-item__indicator" aria-hidden="true"> | |
| 72 | + <span className="fox-step-indicator-item__mark"> | |
| 73 | + {state === "completion" ? <CheckMark /> : null} | |
| 74 | + {state === "ongoing" ? <span className="fox-step-indicator-item__dot" /> : null} | |
| 75 | + </span> | |
| 76 | + {line ? <span className="fox-step-indicator-item__line" /> : null} | |
| 77 | + </div> | |
| 78 | + | |
| 79 | + {step === undefined && title === undefined ? null : ( | |
| 80 | + <div className="fox-step-indicator-item__text"> | |
| 81 | + {step === undefined ? null : ( | |
| 82 | + <span className="fox-step-indicator-item__step">{step}</span> | |
| 83 | + )} | |
| 84 | + {title === undefined ? null : ( | |
| 85 | + <span className="fox-step-indicator-item__title">{title}</span> | |
| 86 | + )} | |
| 87 | + </div> | |
| 88 | + )} | |
| 89 | + </div> | |
| 90 | + ); | |
| 91 | +} | |
| 92 | + | |
| 93 | +// 지난 단계의 체크 표시. `@fox/core/icons`(Phosphor)를 쓰지 않는다 — 시안이 준 것은 선으로 | |
| 94 | +// 그린 10×8 글리프이고, Phosphor의 Check는 면으로 채운 다른 그림이라 크기·굵기가 맞지 않는다. | |
| 95 | +// `stroke`를 시안의 리터럴(흰색) 대신 `currentColor`로 두면 색을 상태 규칙이 정한다. | |
| 96 | +function CheckMark() { | |
| 97 | + return ( | |
| 98 | + <svg viewBox="0 0 12 10" fill="none" aria-hidden="true"> | |
| 99 | + <path | |
| 100 | + d="M0.75 4.75006L4.96726 8.75006L10.75 0.750061" | |
| 101 | + stroke="currentColor" | |
| 102 | + strokeWidth="1.5" | |
| 103 | + strokeLinecap="round" | |
| 104 | + strokeLinejoin="round" | |
| 105 | + /> | |
| 106 | + </svg> | |
| 107 | + ); | |
| 108 | +} |
+++ @fox/core/components/fox-step-indicator-item/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export { FoxStepIndicatorItem, type FoxStepIndicatorItemProps, type FoxStepIndicatorItemState } from "./fox-step-indicator-item"; |
+++ @fox/core/components/fox-step-indicator/fox-step-indicator.tsx
... | ... | @@ -0,0 +1,56 @@ |
| 1 | +import type { ReactNode, Ref } from "react"; | |
| 2 | +import { cx } from "../../utils"; | |
| 3 | + | |
| 4 | +export interface FoxStepIndicatorProps { | |
| 5 | + /** 묶음을 읽어 줄 이름. 화면에 보이는 제목이 이미 있으면 `labelledBy`를 쓴다. */ | |
| 6 | + label?: string; | |
| 7 | + /** 묶음 이름 역할을 하는 요소의 id. `label`보다 우선한다 — 화면의 글자와 어긋나지 않는다. */ | |
| 8 | + labelledBy?: string; | |
| 9 | + /** `FoxStepIndicatorItem`들. */ | |
| 10 | + children?: ReactNode; | |
| 11 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 12 | + hidden?: boolean; | |
| 13 | + /** 배치 조정용. 시안의 폭(400px = 80px × 5단계)을 바꿔야 할 때도 여기로 준다. */ | |
| 14 | + className?: string; | |
| 15 | + ref?: Ref<HTMLDivElement>; | |
| 16 | +} | |
| 17 | + | |
| 18 | +/** | |
| 19 | + * @fox 단계 묶음. `FoxStepIndicatorItem`들을 가로로 늘어놓는다. 배치만 책임지고 상태를 갖지 않는다 — | |
| 20 | + * 어디까지 왔는지는 각 단계의 `state`가 말한다. | |
| 21 | + * | |
| 22 | + * 자식에게 덮어쓰는 값이 없다(`FoxTagList`·`FoxChipArea`와 다른 점이다). 단계마다 `state`가 | |
| 23 | + * 다른 것이 정상이고, **마지막 단계는 `line={false}`로 선을 꺼야 한다** — 묶음이 자식의 | |
| 24 | + * 순서를 알고 대신 꺼 주지 않는 것은, 자식을 조건부로 렌더하거나 프래그먼트로 감싸는 | |
| 25 | + * 호출부에서 "마지막"의 판단이 어긋나기 때문이다. | |
| 26 | + * | |
| 27 | + * `role="list"`를 쓰지 않는다. 목록이 아니라 하나의 진행 상황이고, 읽히는 것은 각 단계의 | |
| 28 | + * 글자다 — 진행률을 소리로 전해야 하면 호출부가 `label`에 담는다("전체 5단계 중 2단계" 등). | |
| 29 | + * | |
| 30 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 31 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 32 | + */ | |
| 33 | +export function FoxStepIndicator({ | |
| 34 | + label, | |
| 35 | + labelledBy, | |
| 36 | + children, | |
| 37 | + hidden = false, | |
| 38 | + className, | |
| 39 | + ref, | |
| 40 | +}: FoxStepIndicatorProps) { | |
| 41 | + if (hidden) { | |
| 42 | + return null; | |
| 43 | + } | |
| 44 | + | |
| 45 | + return ( | |
| 46 | + <div | |
| 47 | + ref={ref} | |
| 48 | + role="group" | |
| 49 | + aria-label={labelledBy ? undefined : label} | |
| 50 | + aria-labelledby={labelledBy} | |
| 51 | + className={cx("fox-step-indicator", className)} | |
| 52 | + > | |
| 53 | + {children} | |
| 54 | + </div> | |
| 55 | + ); | |
| 56 | +} |
+++ @fox/core/components/fox-step-indicator/index.ts
... | ... | @@ -0,0 +1,4 @@ |
| 1 | +export { | |
| 2 | + FoxStepIndicator, | |
| 3 | + type FoxStepIndicatorProps, | |
| 4 | +} from "./fox-step-indicator"; |
+++ @fox/core/components/fox-toast/fox-toast.tsx
... | ... | @@ -0,0 +1,67 @@ |
| 1 | +import type { ReactNode, Ref } from "react"; | |
| 2 | +import { cx } from "../../utils"; | |
| 3 | + | |
| 4 | +export interface FoxToastProps { | |
| 5 | + /** | |
| 6 | + * 알림 문구. | |
| 7 | + * | |
| 8 | + * 이름이 `label`이 아니다 — 다른 컴포넌트의 `label`은 컨트롤에 붙는 이름이지만, 이쪽은 | |
| 9 | + * 조각 안에 담기는 **내용**이라 성격이 다르다. | |
| 10 | + */ | |
| 11 | + message: ReactNode; | |
| 12 | + /** | |
| 13 | + * 보조기술이 읽는 방식을 정한다. | |
| 14 | + * | |
| 15 | + * - `false`(기본): `role="status"` — 하던 말을 끊지 않고 차례가 오면 읽는다. 저장 완료처럼 | |
| 16 | + * 놓쳐도 되는 알림. | |
| 17 | + * - `true`: `role="alert"` — 읽던 것을 끊고 바로 읽는다. 지금 손을 멈춰야 하는 알림에만 | |
| 18 | + * 쓴다. 남발하면 화면 낭독이 계속 끊긴다. | |
| 19 | + */ | |
| 20 | + urgent?: boolean; | |
| 21 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 22 | + hidden?: boolean; | |
| 23 | + id?: string; | |
| 24 | + /** 배치 조정용. 띄우는 위치(fixed·bottom 등)를 여기로 준다. */ | |
| 25 | + className?: string; | |
| 26 | + ref?: Ref<HTMLDivElement>; | |
| 27 | +} | |
| 28 | + | |
| 29 | +/** | |
| 30 | + * @fox 토스트. 어두운 판 위에 한 줄짜리 알림을 얹는다. 크기 수식어가 없다 — 시안에 하나뿐이다. | |
| 31 | + * | |
| 32 | + * **상태를 갖지 않는다.** 언제 뜨고 언제 사라질지, 화면 어디에 놓일지는 전부 호출부가 정한다 | |
| 33 | + * (사용자 결정) — 이 조각은 조건부로 렌더하고 `className`으로 자리를 잡아 주면 된다. 타이머와 | |
| 34 | + * 위치를 안에 두면 SCSS만 쓰는 소비자가 그 절반을 못 쓰고, 뒤이어 만들 스낵바와 규칙이 갈린다. | |
| 35 | + * | |
| 36 | + * 상호작용이 없어 `"use client"`가 아니다 — 서버 컴포넌트로 렌더된다. | |
| 37 | + * | |
| 38 | + * ⚠️ 보조기술은 **이미 화면에 있던 live region의 내용이 바뀔 때** 읽는다. 이 요소를 통째로 새로 | |
| 39 | + * 붙이면 브라우저·리더 조합에 따라 읽히지 않을 수 있다. 확실히 읽혀야 하는 자리라면 호출부가 | |
| 40 | + * 빈 `<div role="status">`를 미리 두고 그 안에서 토스트를 갈아 끼운다. | |
| 41 | + * | |
| 42 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 43 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 44 | + */ | |
| 45 | +export function FoxToast({ | |
| 46 | + message, | |
| 47 | + urgent = false, | |
| 48 | + hidden = false, | |
| 49 | + id, | |
| 50 | + className, | |
| 51 | + ref, | |
| 52 | +}: FoxToastProps) { | |
| 53 | + if (hidden) { | |
| 54 | + return null; | |
| 55 | + } | |
| 56 | + | |
| 57 | + return ( | |
| 58 | + <div | |
| 59 | + ref={ref} | |
| 60 | + id={id} | |
| 61 | + role={urgent ? "alert" : "status"} | |
| 62 | + className={cx("fox-toast", className)} | |
| 63 | + > | |
| 64 | + {message} | |
| 65 | + </div> | |
| 66 | + ); | |
| 67 | +} |
+++ @fox/core/components/fox-toast/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export { FoxToast, type FoxToastProps } from "./fox-toast"; |
+++ @fox/core/components/fox-tooltip-rich/fox-tooltip-rich.tsx
... | ... | @@ -0,0 +1,118 @@ |
| 1 | +import type { ReactNode, Ref } from "react"; | |
| 2 | +import { cx } from "../../utils"; | |
| 3 | + | |
| 4 | +/** 꼬리가 판의 위에 붙는지 아래에 붙는지. 판이 트리거의 아래/위 어느 쪽에 뜨는지가 따라온다. */ | |
| 5 | +export type FoxTooltipRichDirection = "top" | "bottom"; | |
| 6 | + | |
| 7 | +/** 꼬리가 판의 가로 어디에 붙는지. */ | |
| 8 | +export type FoxTooltipRichArrow = "left" | "center" | "right"; | |
| 9 | + | |
| 10 | +export interface FoxTooltipRichProps { | |
| 11 | + /** | |
| 12 | + * `top`이면 꼬리가 판 위에 붙는다 — 트리거 아래에 뜨는 말풍선이다. | |
| 13 | + * `bottom`이면 꼬리가 판 아래에 붙는다 — 트리거 위에 뜨는 말풍선이다. | |
| 14 | + */ | |
| 15 | + direction?: FoxTooltipRichDirection; | |
| 16 | + /** 꼬리의 가로 위치. 판 가장자리에서 24px 안쪽에 선다(가운데는 예외). */ | |
| 17 | + arrow?: FoxTooltipRichArrow; | |
| 18 | + /** 굵은 제목 줄. 넘기지 않으면 렌더링하지 않는다. */ | |
| 19 | + title?: ReactNode; | |
| 20 | + /** | |
| 21 | + * 본문. **150자 내외의 글자만 담는다**(시안 주석) — 버튼·링크 같은 누를 것을 넣지 않는다. | |
| 22 | + * 누를 것이 필요하면 툴팁이 아니라 `FoxPopover`다. | |
| 23 | + */ | |
| 24 | + message?: ReactNode; | |
| 25 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 26 | + hidden?: boolean; | |
| 27 | + id?: string; | |
| 28 | + /** 배치 조정용. 트리거 옆에 놓는 위치와 시안의 폭(360px)을 여기로 덮는다. */ | |
| 29 | + className?: string; | |
| 30 | + ref?: Ref<HTMLDivElement>; | |
| 31 | +} | |
| 32 | + | |
| 33 | +const DIRECTION_CLASS: Record<FoxTooltipRichDirection, string> = { | |
| 34 | + top: "fox-tooltip-rich--top", | |
| 35 | + bottom: "fox-tooltip-rich--bottom", | |
| 36 | +}; | |
| 37 | + | |
| 38 | +const ARROW_CLASS: Record<FoxTooltipRichArrow, string> = { | |
| 39 | + left: "fox-tooltip-rich--arrow-left", | |
| 40 | + center: "fox-tooltip-rich--arrow-center", | |
| 41 | + right: "fox-tooltip-rich--arrow-right", | |
| 42 | +}; | |
| 43 | + | |
| 44 | +/** | |
| 45 | + * @fox 리치 툴팁. 제목과 본문을 담은 말풍선이다. | |
| 46 | + * | |
| 47 | + * `FoxPopover`와 판·꼬리의 구조가 같지만 **누를 것이 하나도 없다** — 닫기도 액션 버튼도 | |
| 48 | + * 없고, 시안 주석대로 150자 내외의 글자만 담는다. 그래서 여닫는 것은 전적으로 트리거의 | |
| 49 | + * 몫이다(가리키거나 포커스가 닿는 동안 보이는 식). 사용자가 눌러야 할 것이 생기면 그때는 | |
| 50 | + * 툴팁이 아니라 팝오버다 — 잠깐 떴다 사라지는 판 안의 버튼은 누를 수 없다. | |
| 51 | + * | |
| 52 | + * 테두리도 그 차이를 따른다: 팝오버는 진한 선(`border-neutral-strong`)이고 이쪽은 한 단계 | |
| 53 | + * 옅은 선(`border-neutral-default`)이다. | |
| 54 | + * | |
| 55 | + * **상태를 갖지 않고 스스로 자리를 잡지도 않는다.** 트리거 옆 어디에 놓을지는 `className`으로 | |
| 56 | + * 주고, `direction`·`arrow`는 꼬리가 어디에 붙는지만 정한다. | |
| 57 | + * | |
| 58 | + * ⚠️ 이 조각은 보조기술에 아무 역할도 주지 않는다. 툴팁으로 읽히려면 트리거가 | |
| 59 | + * `aria-describedby`로 이 요소의 `id`를 가리켜야 한다 — 그 배선은 트리거 쪽이 맡는다. | |
| 60 | + * | |
| 61 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 62 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 63 | + */ | |
| 64 | +export function FoxTooltipRich({ | |
| 65 | + direction = "top", | |
| 66 | + arrow = "left", | |
| 67 | + title, | |
| 68 | + message, | |
| 69 | + hidden = false, | |
| 70 | + id, | |
| 71 | + className, | |
| 72 | + ref, | |
| 73 | +}: FoxTooltipRichProps) { | |
| 74 | + if (hidden) { | |
| 75 | + return null; | |
| 76 | + } | |
| 77 | + | |
| 78 | + // 꼬리는 CSS가 그린다 — 이 요소는 자리만 잡는다(스타일 파일 참고). | |
| 79 | + const arrowSlot = ( | |
| 80 | + <div className="fox-tooltip-rich__arrow-area" aria-hidden="true"> | |
| 81 | + <span className="fox-tooltip-rich__arrow" /> | |
| 82 | + </div> | |
| 83 | + ); | |
| 84 | + | |
| 85 | + return ( | |
| 86 | + <div | |
| 87 | + ref={ref} | |
| 88 | + id={id} | |
| 89 | + className={cx( | |
| 90 | + "fox-tooltip-rich", | |
| 91 | + DIRECTION_CLASS[direction], | |
| 92 | + ARROW_CLASS[arrow], | |
| 93 | + className | |
| 94 | + )} | |
| 95 | + > | |
| 96 | + {direction === "top" ? arrowSlot : null} | |
| 97 | + | |
| 98 | + <div className="fox-tooltip-rich__content"> | |
| 99 | + {title === undefined && message === undefined ? null : ( | |
| 100 | + <div className="fox-tooltip-rich__body"> | |
| 101 | + {title === undefined ? null : ( | |
| 102 | + <div className="fox-tooltip-rich__title-row"> | |
| 103 | + <strong className="fox-tooltip-rich__title">{title}</strong> | |
| 104 | + </div> | |
| 105 | + )} | |
| 106 | + {message === undefined ? null : ( | |
| 107 | + <div className="fox-tooltip-rich__message-row"> | |
| 108 | + <span className="fox-tooltip-rich__message">{message}</span> | |
| 109 | + </div> | |
| 110 | + )} | |
| 111 | + </div> | |
| 112 | + )} | |
| 113 | + </div> | |
| 114 | + | |
| 115 | + {direction === "bottom" ? arrowSlot : null} | |
| 116 | + </div> | |
| 117 | + ); | |
| 118 | +} |
+++ @fox/core/components/fox-tooltip-rich/index.ts
... | ... | @@ -0,0 +1,6 @@ |
| 1 | +export { | |
| 2 | + FoxTooltipRich, | |
| 3 | + type FoxTooltipRichProps, | |
| 4 | + type FoxTooltipRichDirection, | |
| 5 | + type FoxTooltipRichArrow, | |
| 6 | +} from "./fox-tooltip-rich"; |
+++ @fox/core/components/fox-tooltip/fox-tooltip.tsx
... | ... | @@ -0,0 +1,96 @@ |
| 1 | +import type { ReactNode, Ref } from "react"; | |
| 2 | +import { cx } from "../../utils"; | |
| 3 | + | |
| 4 | +/** 꼬리가 판의 위에 붙는지 아래에 붙는지. 판이 트리거의 아래/위 어느 쪽에 뜨는지가 따라온다. */ | |
| 5 | +export type FoxTooltipDirection = "top" | "bottom"; | |
| 6 | + | |
| 7 | +export interface FoxTooltipProps { | |
| 8 | + /** | |
| 9 | + * `top`이면 꼬리가 판 위에 붙는다 — 트리거 아래에 뜨는 말풍선이다. | |
| 10 | + * `bottom`이면 꼬리가 판 아래에 붙는다 — 트리거 위에 뜨는 말풍선이다. | |
| 11 | + * | |
| 12 | + * 가로 위치를 고르는 값은 없다 — 시안이 꼬리를 언제나 가운데에 둔다(`FoxTooltipRich`와 | |
| 13 | + * 다른 점이다). | |
| 14 | + */ | |
| 15 | + direction?: FoxTooltipDirection; | |
| 16 | + /** 보여 줄 글자. 한 줄짜리 짧은 말이다 — 길어지면 `FoxTooltipRich`를 쓴다. */ | |
| 17 | + message: ReactNode; | |
| 18 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 19 | + hidden?: boolean; | |
| 20 | + id?: string; | |
| 21 | + /** 배치 조정용. 트리거 옆에 놓는 위치를 여기로 준다. */ | |
| 22 | + className?: string; | |
| 23 | + ref?: Ref<HTMLDivElement>; | |
| 24 | +} | |
| 25 | + | |
| 26 | +const DIRECTION_CLASS: Record<FoxTooltipDirection, string> = { | |
| 27 | + top: "fox-tooltip--top", | |
| 28 | + bottom: "fox-tooltip--bottom", | |
| 29 | +}; | |
| 30 | + | |
| 31 | +/** | |
| 32 | + * @fox 툴팁. 어두운 판에 짧은 글 한 줄을 담은 말풍선이다. | |
| 33 | + * | |
| 34 | + * `FoxTooltipRich`의 작은 형제다 — 제목이 없고, 꼬리가 언제나 가운데이며, 판이 글자만큼만 | |
| 35 | + * 넓어진다. 여기도 **누를 것이 하나도 없다**: 잠깐 떴다 사라지는 판 안의 버튼은 누를 수 없다. | |
| 36 | + * | |
| 37 | + * **상태를 갖지 않고 스스로 자리를 잡지도 않는다.** 언제 보일지와 트리거 옆 어디에 놓을지는 | |
| 38 | + * 호출부가 정하고, `direction`은 꼬리가 위아래 중 어디에 붙는지만 정한다. | |
| 39 | + * | |
| 40 | + * ⚠️ 이 조각은 보조기술에 아무 역할도 주지 않는다. 툴팁으로 읽히려면 트리거가 | |
| 41 | + * `aria-describedby`로 이 요소의 `id`를 가리켜야 한다 — 그 배선은 트리거 쪽이 맡는다. | |
| 42 | + * | |
| 43 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 44 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 45 | + */ | |
| 46 | +export function FoxTooltip({ | |
| 47 | + direction = "top", | |
| 48 | + message, | |
| 49 | + hidden = false, | |
| 50 | + id, | |
| 51 | + className, | |
| 52 | + ref, | |
| 53 | +}: FoxTooltipProps) { | |
| 54 | + if (hidden) { | |
| 55 | + return null; | |
| 56 | + } | |
| 57 | + | |
| 58 | + const arrowSlot = ( | |
| 59 | + <div className="fox-tooltip__arrow-area" aria-hidden="true"> | |
| 60 | + <span className="fox-tooltip__arrow"> | |
| 61 | + <ArrowMark /> | |
| 62 | + </span> | |
| 63 | + </div> | |
| 64 | + ); | |
| 65 | + | |
| 66 | + return ( | |
| 67 | + <div | |
| 68 | + ref={ref} | |
| 69 | + id={id} | |
| 70 | + className={cx("fox-tooltip", DIRECTION_CLASS[direction], className)} | |
| 71 | + > | |
| 72 | + {direction === "top" ? arrowSlot : null} | |
| 73 | + <div className="fox-tooltip__content">{message}</div> | |
| 74 | + {direction === "bottom" ? arrowSlot : null} | |
| 75 | + </div> | |
| 76 | + ); | |
| 77 | +} | |
| 78 | + | |
| 79 | +/** | |
| 80 | + * 시안 꼬리. 여기서는 `FoxPopover`·`FoxTooltipRich`처럼 CSS로 그리지 않고 SVG를 쓴다 — | |
| 81 | + * 저쪽은 테두리가 있어 1px 이음새를 맞춰야 했지만 이 판은 테두리 없이 한 색이라 그럴 일이 | |
| 82 | + * 없고, 시안의 꼬리 **끝이 살짝 둥글어** CSS 삼각형으로는 그 모양이 나오지 않는다. | |
| 83 | + * | |
| 84 | + * 색은 리터럴 대신 `currentColor`라 스타일이 판과 같은 값을 준다. 마스크가 없어 문서에서 | |
| 85 | + * id가 겹칠 일도 없다. | |
| 86 | + */ | |
| 87 | +function ArrowMark() { | |
| 88 | + return ( | |
| 89 | + <svg viewBox="0 0 12 6" fill="none" aria-hidden="true"> | |
| 90 | + <path | |
| 91 | + d="M12 0H0L4.93934 5.50991C5.52513 6.16336 6.47487 6.16336 7.06066 5.50991L12 0Z" | |
| 92 | + fill="currentColor" | |
| 93 | + /> | |
| 94 | + </svg> | |
| 95 | + ); | |
| 96 | +} |
+++ @fox/core/components/fox-tooltip/index.ts
... | ... | @@ -0,0 +1,5 @@ |
| 1 | +export { | |
| 2 | + FoxTooltip, | |
| 3 | + type FoxTooltipProps, | |
| 4 | + type FoxTooltipDirection, | |
| 5 | +} from "./fox-tooltip"; |
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 |
export * from "./fox-accordion"; |
| 2 | 2 |
export * from "./fox-address"; |
| 3 |
+export * from "./fox-alert"; |
|
| 3 | 4 |
export * from "./fox-badge"; |
| 4 | 5 |
export * from "./fox-badge-dot"; |
| 5 | 6 |
export * from "./fox-badge-group"; |
... | ... | @@ -21,10 +22,13 @@ |
| 21 | 22 |
export * from "./fox-chip-area"; |
| 22 | 23 |
export * from "./fox-chip-select-option"; |
| 23 | 24 |
export * from "./fox-conditional-option"; |
| 25 |
+export * from "./fox-contextual-trigger"; |
|
| 24 | 26 |
export * from "./fox-disclosure"; |
| 25 | 27 |
export * from "./fox-email"; |
| 26 | 28 |
export * from "./fox-file-upload"; |
| 27 | 29 |
export * from "./fox-form-label"; |
| 30 |
+export * from "./fox-heading-group"; |
|
| 31 |
+export * from "./fox-helper-text"; |
|
| 28 | 32 |
export * from "./fox-icon-button"; |
| 29 | 33 |
export * from "./fox-input"; |
| 30 | 34 |
export * from "./fox-link-button"; |
... | ... | @@ -32,6 +36,7 @@ |
| 32 | 36 |
export * from "./fox-page-header"; |
| 33 | 37 |
export * from "./fox-pagination"; |
| 34 | 38 |
export * from "./fox-phone-number"; |
| 39 |
+export * from "./fox-popover"; |
|
| 35 | 40 |
export * from "./fox-radio"; |
| 36 | 41 |
export * from "./fox-radio-group"; |
| 37 | 42 |
export * from "./fox-radio-icon"; |
... | ... | @@ -41,7 +46,12 @@ |
| 41 | 46 |
export * from "./fox-select-option"; |
| 42 | 47 |
export * from "./fox-select-option-item"; |
| 43 | 48 |
export * from "./fox-select-text"; |
| 49 |
+export * from "./fox-snackbar"; |
|
| 50 |
+export * from "./fox-snackbar-list"; |
|
| 44 | 51 |
export * from "./fox-spinner"; |
| 52 |
+export * from "./fox-status-indicator"; |
|
| 53 |
+export * from "./fox-step-indicator-item"; |
|
| 54 |
+export * from "./fox-step-indicator"; |
|
| 45 | 55 |
export * from "./fox-tab"; |
| 46 | 56 |
export * from "./fox-tab-item"; |
| 47 | 57 |
export * from "./fox-table"; |
... | ... | @@ -51,6 +61,9 @@ |
| 51 | 61 |
export * from "./fox-tag-list"; |
| 52 | 62 |
export * from "./fox-text-area"; |
| 53 | 63 |
export * from "./fox-text-button"; |
| 64 |
+export * from "./fox-toast"; |
|
| 65 |
+export * from "./fox-tooltip"; |
|
| 66 |
+export * from "./fox-tooltip-rich"; |
|
| 54 | 67 |
export * from "./fox-toggle-quantity"; |
| 55 | 68 |
export * from "./fox-toggle-switch"; |
| 56 | 69 |
export * from "./fox-list-search"; |
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
... | ... | @@ -1,8 +1,9 @@ |
| 1 | 1 |
"use client"; |
| 2 | 2 |
|
| 3 | 3 |
import { FoxInput, FoxInputState, FoxSelect, FoxSelectItem, FoxSelectOption, FoxSelectOptionItem } from "@/@fox/core/components";
|
| 4 |
-import { useState, type CSSProperties, type ReactNode } from "react";
|
|
| 4 |
+import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
|
|
| 5 | 5 |
import { FoxAddress, type FoxAddressSearchResult, type FoxAddressValue } from "../core/components/fox-address";
|
| 6 |
+import { FoxAlert } from "../core/components/fox-alert";
|
|
| 6 | 7 |
import { FoxBreadcrumb } from "../core/components/fox-breadcrumb";
|
| 7 | 8 |
import { FoxButton } from "../core/components/fox-button";
|
| 8 | 9 |
import { FoxButtonGroup } from "../core/components/fox-button-group";
|
... | ... | @@ -15,18 +16,22 @@ |
| 15 | 16 |
import { FoxCheckbox } from "../core/components/fox-checkbox";
|
| 16 | 17 |
import { FoxCheckboxGroup } from "../core/components/fox-checkbox-group";
|
| 17 | 18 |
import { FoxConditionalOption } from "../core/components/fox-conditional-option";
|
| 19 |
+import { FoxContextualTrigger } from "../core/components/fox-contextual-trigger";
|
|
| 18 | 20 |
import { FoxEmail } from "../core/components/fox-email";
|
| 19 | 21 |
import { FoxFileUpload, type FoxFileItem } from "../core/components/fox-file-upload";
|
| 20 | 22 |
import { FoxFormLabel } from "../core/components/fox-form-label";
|
| 23 |
+import { FoxHelperText } from "../core/components/fox-helper-text";
|
|
| 21 | 24 |
import { FoxIconButton } from "../core/components/fox-icon-button";
|
| 22 | 25 |
import { FoxLinkButton } from "../core/components/fox-link-button";
|
| 23 | 26 |
import { FoxPagination } from "../core/components/fox-pagination";
|
| 24 | 27 |
import { FoxPhoneNumber, type FoxPhoneNumberState } from "../core/components/fox-phone-number";
|
| 28 |
+import { FoxPopover } from "../core/components/fox-popover";
|
|
| 25 | 29 |
import { FoxRadio } from "../core/components/fox-radio";
|
| 26 | 30 |
import { FoxRadioGroup } from "../core/components/fox-radio-group";
|
| 27 | 31 |
import { FoxRadioIcon } from "../core/components/fox-radio-icon";
|
| 28 | 32 |
import { FoxTag } from "../core/components/fox-tag";
|
| 29 | 33 |
import { FoxTagList } from "../core/components/fox-tag-list";
|
| 34 |
+import { FoxToast } from "../core/components/fox-toast";
|
|
| 30 | 35 |
import { FoxTextArea, type FoxTextAreaState } from "../core/components/fox-text-area";
|
| 31 | 36 |
import { FoxTextButton } from "../core/components/fox-text-button";
|
| 32 | 37 |
import { FoxToggleSwitch } from "../core/components/fox-toggle-switch";
|
... | ... | @@ -50,11 +55,19 @@ |
| 50 | 55 |
import { FoxTab } from "../core/components/fox-tab";
|
| 51 | 56 |
import { FoxTabItem } from "../core/components/fox-tab-item";
|
| 52 | 57 |
import { FoxSpinner } from "../core/components/fox-spinner";
|
| 58 |
+import { FoxCheckCircleIcon } from "../core/icons";
|
|
| 59 |
+import { FoxSnackbar } from "../core/components/fox-snackbar";
|
|
| 60 |
+import { FoxSnackbarList } from "../core/components/fox-snackbar-list";
|
|
| 61 |
+import { FoxStatusIndicator } from "../core/components/fox-status-indicator";
|
|
| 62 |
+import { FoxStepIndicatorItem } from "../core/components/fox-step-indicator-item";
|
|
| 63 |
+import { FoxStepIndicator } from "../core/components/fox-step-indicator";
|
|
| 53 | 64 |
import { FoxChip, type FoxChipItem } from "../core/components/fox-chip";
|
| 54 | 65 |
import { FoxChipArea } from "../core/components/fox-chip-area";
|
| 55 | 66 |
import { FoxSelectText } from "../core/components/fox-select-text";
|
| 56 | 67 |
import { FoxSegmentedControl } from "../core/components/fox-segmented-control";
|
| 57 | 68 |
import { FoxSegmentedControlItem } from "../core/components/fox-segmented-control-item";
|
| 69 |
+import { FoxTooltip } from "../core/components/fox-tooltip";
|
|
| 70 |
+import { FoxTooltipRich } from "../core/components/fox-tooltip-rich";
|
|
| 58 | 71 |
import { FoxToggleQuantity } from "../core/components/fox-toggle-quantity";
|
| 59 | 72 |
import { FoxPageHeader } from "../core/components/fox-page-header";
|
| 60 | 73 |
import { FoxModal } from "../core/components/fox-modal";
|
... | ... | @@ -601,7 +614,6 @@ |
| 601 | 614 |
); |
| 602 | 615 |
} |
| 603 | 616 |
|
| 604 |
-/** 모달은 열어 봐야 보이므로 여는 버튼과 함께 낸다. */ |
|
| 605 | 617 |
function ModalDemo({
|
| 606 | 618 |
size, |
| 607 | 619 |
title, |
... | ... | @@ -654,11 +666,6 @@ |
| 654 | 666 |
); |
| 655 | 667 |
} |
| 656 | 668 |
|
| 657 |
-/** |
|
| 658 |
- * 그림 자리 확인용 도형. 실제 사진 대신 쓴다 — `@fox`는 앱의 이미지 경로를 참조하지 않고 |
|
| 659 |
- * (다른 프로젝트로 통째로 복사되어야 한다), 카탈로그가 외부 파일에 기대지도 않게 한다. |
|
| 660 |
- * 토큰 색을 그대로 쓰므로 라이트·다크에서 함께 바뀐다. |
|
| 661 |
- */ |
|
| 662 | 669 |
function DemoThumb() {
|
| 663 | 670 |
return ( |
| 664 | 671 |
<svg viewBox="0 0 320 180" preserveAspectRatio="xMidYMid slice" aria-hidden="true"> |
... | ... | @@ -670,6 +677,151 @@ |
| 670 | 677 |
opacity="0.45" |
| 671 | 678 |
/> |
| 672 | 679 |
</svg> |
| 680 |
+ ); |
|
| 681 |
+} |
|
| 682 |
+ |
|
| 683 |
+function ToastDemo() {
|
|
| 684 |
+ const [open, setOpen] = useState(false); |
|
| 685 |
+ |
|
| 686 |
+ useEffect(() => {
|
|
| 687 |
+ if (!open) return; |
|
| 688 |
+ const timer = window.setTimeout(() => setOpen(false), 3000); |
|
| 689 |
+ return () => window.clearTimeout(timer); |
|
| 690 |
+ }, [open]); |
|
| 691 |
+ |
|
| 692 |
+ return ( |
|
| 693 |
+ <> |
|
| 694 |
+ <FoxButton |
|
| 695 |
+ size="md" |
|
| 696 |
+ type="primary" |
|
| 697 |
+ label="토스트 띄우기" |
|
| 698 |
+ onAction={() => setOpen(true)}
|
|
| 699 |
+ disabled={open}
|
|
| 700 |
+ /> |
|
| 701 |
+ {open ? (
|
|
| 702 |
+ <div |
|
| 703 |
+ style={{
|
|
| 704 |
+ position: "fixed", |
|
| 705 |
+ insetInlineStart: "50%", |
|
| 706 |
+ insetBlockEnd: "4rem", |
|
| 707 |
+ transform: "translateX(-50%)", |
|
| 708 |
+ zIndex: 100, |
|
| 709 |
+ }} |
|
| 710 |
+ > |
|
| 711 |
+ <FoxToast message="저장되었습니다." /> |
|
| 712 |
+ </div> |
|
| 713 |
+ ) : null} |
|
| 714 |
+ </> |
|
| 715 |
+ ); |
|
| 716 |
+} |
|
| 717 |
+ |
|
| 718 |
+/** |
|
| 719 |
+ * 트리거와 말풍선을 잇는 예제. 트리거는 상태를 갖지 않으므로 무엇을 언제 보여 줄지와 |
|
| 720 |
+ * 보조기술 배선(`aria-describedby` / `aria-controls`+`aria-expanded`)은 호출부 몫이다. |
|
| 721 |
+ */ |
|
| 722 |
+function ContextualTriggerDemo() {
|
|
| 723 |
+ const [tipOpen, setTipOpen] = useState(false); |
|
| 724 |
+ const [popOpen, setPopOpen] = useState(false); |
|
| 725 |
+ const [richOpen, setRichOpen] = useState(false); |
|
| 726 |
+ |
|
| 727 |
+ return ( |
|
| 728 |
+ <> |
|
| 729 |
+ {/* 툴팁: 가리키거나 포커스가 닿는 동안 보인다. */}
|
|
| 730 |
+ <span style={{ position: "relative", display: "inline-flex" }}>
|
|
| 731 |
+ <FoxContextualTrigger |
|
| 732 |
+ type="help" |
|
| 733 |
+ ariaLabel="배송비 도움말" |
|
| 734 |
+ describedBy="demo-tooltip" |
|
| 735 |
+ onHoverChange={setTipOpen}
|
|
| 736 |
+ onFocus={() => setTipOpen(true)}
|
|
| 737 |
+ onBlur={() => setTipOpen(false)}
|
|
| 738 |
+ /> |
|
| 739 |
+ {/* 꼬리가 가운데라 트리거 가운데에 맞춰 세운다. */}
|
|
| 740 |
+ {tipOpen ? (
|
|
| 741 |
+ <span |
|
| 742 |
+ style={{
|
|
| 743 |
+ position: "absolute", |
|
| 744 |
+ insetBlockStart: "100%", |
|
| 745 |
+ insetInlineStart: "50%", |
|
| 746 |
+ transform: "translateX(-50%)", |
|
| 747 |
+ zIndex: 10, |
|
| 748 |
+ }} |
|
| 749 |
+ > |
|
| 750 |
+ <FoxTooltip |
|
| 751 |
+ id="demo-tooltip" |
|
| 752 |
+ direction="top" |
|
| 753 |
+ message="3만원 이상 무료" |
|
| 754 |
+ /> |
|
| 755 |
+ </span> |
|
| 756 |
+ ) : null} |
|
| 757 |
+ </span> |
|
| 758 |
+ |
|
| 759 |
+ {/* 팝오버: 눌러서 연다. */}
|
|
| 760 |
+ <span style={{ position: "relative", display: "inline-flex" }}>
|
|
| 761 |
+ <FoxContextualTrigger |
|
| 762 |
+ type="information" |
|
| 763 |
+ ariaLabel="정산 기준 설명" |
|
| 764 |
+ controls="demo-popover" |
|
| 765 |
+ expanded={popOpen}
|
|
| 766 |
+ onAction={() => setPopOpen((open) => !open)}
|
|
| 767 |
+ /> |
|
| 768 |
+ {/* 꼬리가 왼쪽 24px 자리에 서므로(꼬리 가운데 35px) 그만큼 당긴다. */}
|
|
| 769 |
+ {popOpen ? (
|
|
| 770 |
+ <span |
|
| 771 |
+ style={{
|
|
| 772 |
+ position: "absolute", |
|
| 773 |
+ insetBlockStart: "100%", |
|
| 774 |
+ insetInlineStart: "50%", |
|
| 775 |
+ marginInlineStart: "-3.5rem", |
|
| 776 |
+ zIndex: 10, |
|
| 777 |
+ }} |
|
| 778 |
+ > |
|
| 779 |
+ <FoxPopover |
|
| 780 |
+ id="demo-popover" |
|
| 781 |
+ direction="top" |
|
| 782 |
+ arrow="left" |
|
| 783 |
+ title="정산 기준" |
|
| 784 |
+ message="매월 말일 마감하여 다음 달 10일에 지급합니다." |
|
| 785 |
+ actionLabel="바로가기" |
|
| 786 |
+ onAction={() => {}}
|
|
| 787 |
+ onClose={() => setPopOpen(false)}
|
|
| 788 |
+ /> |
|
| 789 |
+ </span> |
|
| 790 |
+ ) : null} |
|
| 791 |
+ </span> |
|
| 792 |
+ |
|
| 793 |
+ {/* 리치 툴팁: 툴팁과 같이 가리키는 동안 보이지만, 제목과 긴 본문을 담는다. */}
|
|
| 794 |
+ <span style={{ position: "relative", display: "inline-flex" }}>
|
|
| 795 |
+ <FoxContextualTrigger |
|
| 796 |
+ type="information" |
|
| 797 |
+ ariaLabel="정산 주기 안내" |
|
| 798 |
+ describedBy="demo-tooltip-rich" |
|
| 799 |
+ onHoverChange={setRichOpen}
|
|
| 800 |
+ onFocus={() => setRichOpen(true)}
|
|
| 801 |
+ onBlur={() => setRichOpen(false)}
|
|
| 802 |
+ /> |
|
| 803 |
+ {/* 꼬리가 왼쪽 24px 자리에 서므로(꼬리 가운데 35px) 그만큼 당긴다. */}
|
|
| 804 |
+ {richOpen ? (
|
|
| 805 |
+ <span |
|
| 806 |
+ style={{
|
|
| 807 |
+ position: "absolute", |
|
| 808 |
+ insetBlockStart: "100%", |
|
| 809 |
+ insetInlineStart: "50%", |
|
| 810 |
+ marginInlineStart: "-3.5rem", |
|
| 811 |
+ zIndex: 10, |
|
| 812 |
+ }} |
|
| 813 |
+ > |
|
| 814 |
+ <FoxTooltipRich |
|
| 815 |
+ id="demo-tooltip-rich" |
|
| 816 |
+ direction="top" |
|
| 817 |
+ arrow="left" |
|
| 818 |
+ title="정산 주기" |
|
| 819 |
+ message="매월 말일에 마감하고 다음 달 10일에 지급합니다. 공휴일이 겹치면 다음 영업일로 밀립니다." |
|
| 820 |
+ /> |
|
| 821 |
+ </span> |
|
| 822 |
+ ) : null} |
|
| 823 |
+ </span> |
|
| 824 |
+ </> |
|
| 673 | 825 |
); |
| 674 | 826 |
} |
| 675 | 827 |
|
... | ... | @@ -3555,4 +3707,482 @@ |
| 3555 | 3707 |
}, |
| 3556 | 3708 |
], |
| 3557 | 3709 |
}, |
| 3710 |
+ {
|
|
| 3711 |
+ id: "fox-toast", |
|
| 3712 |
+ name: "FoxToast", |
|
| 3713 |
+ description: |
|
| 3714 |
+ "Figma 시안(toast 441:5471) 토스트메세지 — 상태를 갖지 않습니다: 언제 뜨고 언제 사라질지, 화면 어디에 놓일지는 전부 호출부가 정합니다. 타이머와 위치를 안에 두면 SCSS만 쓰는 소비자가 그 절반을 못 쓰고, 뒤이어 만들 스낵바와 규칙이 갈립니다. 배경·글자색이 *-inverse 토큰이라 다크 모드에서는 둘이 함께 뒤집혀 밝은 판 + 어두운 글자가 됩니다.", |
|
| 3715 |
+ variants: [ |
|
| 3716 |
+ {
|
|
| 3717 |
+ label: "기본", |
|
| 3718 |
+ node: <FoxToast message="저장되었습니다." />, |
|
| 3719 |
+ }, |
|
| 3720 |
+ {
|
|
| 3721 |
+ label: "문구가 길면 폭이 내용만큼 늘어납니다 (시안에 상한이 없어 넣지 않았습니다)", |
|
| 3722 |
+ node: <FoxToast message="입력하신 내용을 저장했습니다. 목록에서 확인할 수 있습니다." />, |
|
| 3723 |
+ }, |
|
| 3724 |
+ {
|
|
| 3725 |
+ label: "urgent — 모양은 같고 role만 status에서 alert로 바뀝니다 (읽던 것을 끊고 바로 읽음)", |
|
| 3726 |
+ node: <FoxToast message="네트워크 연결이 끊겼습니다." urgent />, |
|
| 3727 |
+ }, |
|
| 3728 |
+ {
|
|
| 3729 |
+ label: "띄우고 지우는 것은 호출부 몫입니다 — 눌러 보세요 (3초 뒤 사라짐, 화면 하단 고정)", |
|
| 3730 |
+ node: <ToastDemo />, |
|
| 3731 |
+ }, |
|
| 3732 |
+ ], |
|
| 3733 |
+ }, |
|
| 3734 |
+ {
|
|
| 3735 |
+ id: "fox-alert", |
|
| 3736 |
+ name: "FoxAlert", |
|
| 3737 |
+ description: |
|
| 3738 |
+ "Figma 시안(alert 258:433) 폭 720px은 시안 값 그대로지만 좁은 자리에서 부모를 뚫지 않게 max-inline-size: 100%를 함께 걸었습니다. 계열은 배경·테두리·제목 글자색을 바꿉니다 — 테두리만 -inverse 토큰이라 연한 배경(5) 위에 한 단계 진한 선(10)이 얹힙니다. 본문 글자색과 아이콘 상자의 흰 배경은 계열과 무관합니다.", |
|
| 3739 |
+ variants: [ |
|
| 3740 |
+ {
|
|
| 3741 |
+ label: "default", |
|
| 3742 |
+ node: ( |
|
| 3743 |
+ <FoxAlert |
|
| 3744 |
+ title="알림 제목이 들어갑니다" |
|
| 3745 |
+ message="콘텐츠 내 알럿은 최대 2줄 이내로 작성합니다 콘텐츠 내 알럿은 최대 2줄 이내로 작성합니다 콘텐츠 내 알럿은 최대 2줄 이내로 작성합니다" |
|
| 3746 |
+ /> |
|
| 3747 |
+ ), |
|
| 3748 |
+ }, |
|
| 3749 |
+ {
|
|
| 3750 |
+ label: "제목만 / 본문만 — 넘기지 않은 쪽은 렌더하지 않습니다", |
|
| 3751 |
+ node: ( |
|
| 3752 |
+ <> |
|
| 3753 |
+ <FoxAlert title="제목만 있는 알림" /> |
|
| 3754 |
+ <FoxAlert message="본문만 있는 알림입니다." /> |
|
| 3755 |
+ </> |
|
| 3756 |
+ ), |
|
| 3757 |
+ }, |
|
| 3758 |
+ {
|
|
| 3759 |
+ label: "type 5종", |
|
| 3760 |
+ node: ( |
|
| 3761 |
+ <> |
|
| 3762 |
+ <FoxAlert type="default" title="default" message="기본" /> |
|
| 3763 |
+ <FoxAlert type="information" title="information" message="안내" /> |
|
| 3764 |
+ <FoxAlert type="success" title="success" message="완료" /> |
|
| 3765 |
+ <FoxAlert type="warning" title="warning" message="주의" /> |
|
| 3766 |
+ <FoxAlert type="danger" title="danger" message="위험" /> |
|
| 3767 |
+ </> |
|
| 3768 |
+ ), |
|
| 3769 |
+ }, |
|
| 3770 |
+ {
|
|
| 3771 |
+ label: "좁은 자리(360px)에 넣으면 폭이 부모에 맞춰 줄어듭니다", |
|
| 3772 |
+ node: ( |
|
| 3773 |
+ <div style={{ inlineSize: "36rem", outline: "1px dashed currentColor" }}>
|
|
| 3774 |
+ <FoxAlert |
|
| 3775 |
+ title="좁은 단 안의 알림" |
|
| 3776 |
+ message="720px이 아니라 부모 폭까지만 늘어납니다." |
|
| 3777 |
+ /> |
|
| 3778 |
+ </div> |
|
| 3779 |
+ ), |
|
| 3780 |
+ }, |
|
| 3781 |
+ ], |
|
| 3782 |
+ }, |
|
| 3783 |
+ {
|
|
| 3784 |
+ id: "fox-helper-text", |
|
| 3785 |
+ name: "FoxHelperText", |
|
| 3786 |
+ description: |
|
| 3787 |
+ "Figma 시안(helper-txt) 입력 아래 붙는 안내·오류 한 줄입니다. 계열은 FoxAlert와 같은 다섯 개이고 글리프도 같은 것을 쓰되 굵기만 regular입니다(알럿은 duotone). 계열이 붙으면 글자는 font-system-*, 아이콘은 한 단계 진한 icon-system-*-strong입니다 — default만 글자·아이콘이 같은 회색입니다. 모양 값을 새로 적지 않았습니다 — 시안 값이 _form-field.scss의 message 믹스인과 한 자도 다르지 않아(gap 4px · 아이콘 padding-top 2px · body/sm · 150% · -0.025em) 그대로 부릅니다. 차이는 display 하나뿐이라(시안 inline-flex, 폼 안 flex) 믹스인에 인자로 넘깁니다. ⚠️ 폼 컴포넌트 안의 헬퍼는 아직 이 클래스를 쓰지 않습니다 — FoxInput·FoxTextArea·FoxEmail·FoxPhoneNumber·FoxAddress는 각자 __message를, FoxSelect는 __hint를 그리고, 그쪽 색은 상자의 포커스·오류를 :has()로 따라갑니다(auto-states 믹스인). 모으는 것은 별도 작업입니다.", |
|
| 3788 |
+ variants: [ |
|
| 3789 |
+ {
|
|
| 3790 |
+ label: "계열 5종 — 아이콘이 글자보다 한 단계 진합니다 (icon-system-*-strong)", |
|
| 3791 |
+ node: ( |
|
| 3792 |
+ <> |
|
| 3793 |
+ <FoxHelperText message="기본 안내 문구입니다." /> |
|
| 3794 |
+ <FoxHelperText type="information" message="입력 형식을 확인해 주세요." /> |
|
| 3795 |
+ <FoxHelperText type="success" message="사용할 수 있는 아이디입니다." /> |
|
| 3796 |
+ <FoxHelperText type="warning" message="곧 만료되는 항목입니다." /> |
|
| 3797 |
+ <FoxHelperText type="danger" message="필수 항목입니다." /> |
|
| 3798 |
+ </> |
|
| 3799 |
+ ), |
|
| 3800 |
+ }, |
|
| 3801 |
+ {
|
|
| 3802 |
+ label: "showIcon={false} — 글자만 (폼 헬퍼가 아이콘 없이 쓰이는 자리를 위해)",
|
|
| 3803 |
+ node: ( |
|
| 3804 |
+ <> |
|
| 3805 |
+ <FoxHelperText showIcon={false} message="2자 이상 입력해 주세요." />
|
|
| 3806 |
+ <FoxHelperText type="danger" showIcon={false} message="필수 항목입니다." />
|
|
| 3807 |
+ </> |
|
| 3808 |
+ ), |
|
| 3809 |
+ }, |
|
| 3810 |
+ {
|
|
| 3811 |
+ label: "inline-flex라 폭이 글자만큼입니다 (점선은 자리 확인용)", |
|
| 3812 |
+ node: ( |
|
| 3813 |
+ <div style={{ outline: "1px dashed currentColor" }}>
|
|
| 3814 |
+ <FoxHelperText type="information" message="내용만큼만 차지합니다." /> |
|
| 3815 |
+ </div> |
|
| 3816 |
+ ), |
|
| 3817 |
+ }, |
|
| 3818 |
+ {
|
|
| 3819 |
+ label: "여러 줄 — 아이콘이 첫 줄에 맞춰 고정됩니다", |
|
| 3820 |
+ node: ( |
|
| 3821 |
+ <div style={{ inlineSize: "24rem" }}>
|
|
| 3822 |
+ <FoxHelperText |
|
| 3823 |
+ type="warning" |
|
| 3824 |
+ message="문구가 길어져 두 줄이 되어도 아이콘은 위로 붙어 첫 글줄과 나란히 남습니다." |
|
| 3825 |
+ /> |
|
| 3826 |
+ </div> |
|
| 3827 |
+ ), |
|
| 3828 |
+ }, |
|
| 3829 |
+ ], |
|
| 3830 |
+ }, |
|
| 3831 |
+ {
|
|
| 3832 |
+ id: "fox-status-indicator", |
|
| 3833 |
+ name: "FoxStatusIndicator", |
|
| 3834 |
+ description: |
|
| 3835 |
+ "Figma 시안(status-indicator 404:6328) 글자로 상태를 알립니다. 점은 장식이라 aria-hidden이고 뜻은 글자가 전부 담습니다 — 그래서 label이 선택값이 아니라 필수값입니다. 색만으로 상태를 전하면 색을 구분하지 못하는 사용자에게는 아무것도 전달되지 않습니다.", |
|
| 3836 |
+ variants: [ |
|
| 3837 |
+ {
|
|
| 3838 |
+ label: "계열 5종", |
|
| 3839 |
+ node: ( |
|
| 3840 |
+ <> |
|
| 3841 |
+ <FoxStatusIndicator label="시작전, 중립적" /> |
|
| 3842 |
+ <FoxStatusIndicator type="danger" label="중단, 실패, 위험" /> |
|
| 3843 |
+ <FoxStatusIndicator type="information" label="진행 중" /> |
|
| 3844 |
+ <FoxStatusIndicator type="warning" label="잠재적 위험, 주의" /> |
|
| 3845 |
+ <FoxStatusIndicator type="success" label="진행중, 처리중간단계" /> |
|
| 3846 |
+ </> |
|
| 3847 |
+ ), |
|
| 3848 |
+ }, |
|
| 3849 |
+ {
|
|
| 3850 |
+ label: "inline-flex라 글줄 안에 그대로 놓입니다", |
|
| 3851 |
+ node: ( |
|
| 3852 |
+ <p style={{ margin: 0 }}>
|
|
| 3853 |
+ 현재 상태는 <FoxStatusIndicator type="success" label="정상" /> 입니다. |
|
| 3854 |
+ </p> |
|
| 3855 |
+ ), |
|
| 3856 |
+ }, |
|
| 3857 |
+ ], |
|
| 3858 |
+ }, |
|
| 3859 |
+ {
|
|
| 3860 |
+ id: "fox-snackbar", |
|
| 3861 |
+ name: "FoxSnackbar", |
|
| 3862 |
+ description: |
|
| 3863 |
+ "Figma 시안(snackbar 441:5880) 흰 판 위에 굵은 한 줄과 글자 버튼이 서고 오른쪽 끝에 닫기가 붙습니다. FoxToast와 형제지만 누를 것이 있어서, 읽고 지나가는 토스트와 달리 손이 닿을 때까지 남습니다. 상태를 갖지 않습니다 — onClose는 '닫아 달라'는 알림이지 스스로 사라지는 것이 아니고, 위치도 호출부가 정합니다. 글자 버튼은 FoxTextButton의 primary·md, 닫기는 오른쪽에 붙습니다 — 판의 오른쪽 여백 64px은 글자가 그 아래로 들어가지 않게 비워 두는 자리입니다.", |
|
| 3864 |
+ variants: [ |
|
| 3865 |
+ {
|
|
| 3866 |
+ label: "기본 — 제목 + 본문 + 액션 + 닫기", |
|
| 3867 |
+ node: ( |
|
| 3868 |
+ <FoxSnackbar |
|
| 3869 |
+ title="메세지 타이틀" |
|
| 3870 |
+ message="스낵바는 화면 하단에 제공되는 안내 메시지입니다." |
|
| 3871 |
+ actionLabel="텍스트 버튼" |
|
| 3872 |
+ onAction={() => {}}
|
|
| 3873 |
+ onClose={() => {}}
|
|
| 3874 |
+ /> |
|
| 3875 |
+ ), |
|
| 3876 |
+ }, |
|
| 3877 |
+ {
|
|
| 3878 |
+ label: "아이콘 — 호출부가 @fox/core/icons에서 골라 넘깁니다 (ico/CheckCircle, weight=fill / 색은 icon-theme-primary)", |
|
| 3879 |
+ node: ( |
|
| 3880 |
+ <FoxSnackbar |
|
| 3881 |
+ icon={<FoxCheckCircleIcon weight="fill" />}
|
|
| 3882 |
+ title="변경 사항을 저장했습니다" |
|
| 3883 |
+ message="3분 안에는 되돌릴 수 있습니다." |
|
| 3884 |
+ actionLabel="실행 취소" |
|
| 3885 |
+ onAction={() => {}}
|
|
| 3886 |
+ onClose={() => {}}
|
|
| 3887 |
+ /> |
|
| 3888 |
+ ), |
|
| 3889 |
+ }, |
|
| 3890 |
+ {
|
|
| 3891 |
+ label: "제목 없이 / 액션 없이 — 넘기지 않은 쪽은 그리지 않습니다 (닫기는 항상 있습니다)", |
|
| 3892 |
+ node: ( |
|
| 3893 |
+ <> |
|
| 3894 |
+ <FoxSnackbar title="제목만 있는 스낵바" actionLabel="보기" onAction={() => {}} onClose={() => {}} />
|
|
| 3895 |
+ <FoxSnackbar message="제목 없이 본문만 있는 스낵바입니다." actionLabel="보기" onAction={() => {}} onClose={() => {}} />
|
|
| 3896 |
+ <FoxSnackbar title="액션이 없는 스낵바" message="닫기만 있습니다." onClose={() => {}} />
|
|
| 3897 |
+ </> |
|
| 3898 |
+ ), |
|
| 3899 |
+ }, |
|
| 3900 |
+ {
|
|
| 3901 |
+ label: "제목이 길면 줄바꿈되고 버튼은 오른쪽에 남습니다", |
|
| 3902 |
+ node: ( |
|
| 3903 |
+ <FoxSnackbar |
|
| 3904 |
+ title="제목이 길어져 두 줄이 되어도" |
|
| 3905 |
+ message="글자 버튼과 닫기는 자리를 지킵니다. 판은 640px에서 시작하고 오른쪽 64px은 닫기 자리로 비워 둡니다." |
|
| 3906 |
+ actionLabel="자세히" |
|
| 3907 |
+ onAction={() => {}}
|
|
| 3908 |
+ onClose={() => {}}
|
|
| 3909 |
+ /> |
|
| 3910 |
+ ), |
|
| 3911 |
+ }, |
|
| 3912 |
+ ], |
|
| 3913 |
+ }, |
|
| 3914 |
+ {
|
|
| 3915 |
+ id: "fox-snackbar-list", |
|
| 3916 |
+ name: "FoxSnackbarList", |
|
| 3917 |
+ description: |
|
| 3918 |
+ "Figma 시안(snackbar-list 467:5958) 스낵바가 여러 개일 때 세로로 쌓는 묶음입니다. — 무엇이 몇 개 떠 있는지, 어떤 순서로 쌓을지는 호출부가 소유합니다. 자식에게 덮어쓰는 값이 없다는 점이 FoxTagList·FoxChipArea와 다릅니다.", |
|
| 3919 |
+ variants: [ |
|
| 3920 |
+ {
|
|
| 3921 |
+ label: "세 개가 쌓인 모습 (간격 8px)", |
|
| 3922 |
+ node: ( |
|
| 3923 |
+ <FoxSnackbarList label="알림"> |
|
| 3924 |
+ <FoxSnackbar |
|
| 3925 |
+ icon={<FoxCheckCircleIcon weight="fill" />}
|
|
| 3926 |
+ title="변경 사항을 저장했습니다" |
|
| 3927 |
+ message="3분 안에는 되돌릴 수 있습니다." |
|
| 3928 |
+ actionLabel="실행 취소" |
|
| 3929 |
+ onAction={() => {}}
|
|
| 3930 |
+ onClose={() => {}}
|
|
| 3931 |
+ /> |
|
| 3932 |
+ <FoxSnackbar |
|
| 3933 |
+ title="파일 3개를 올렸습니다" |
|
| 3934 |
+ actionLabel="보기" |
|
| 3935 |
+ onAction={() => {}}
|
|
| 3936 |
+ onClose={() => {}}
|
|
| 3937 |
+ /> |
|
| 3938 |
+ <FoxSnackbar message="제목 없이 본문만 있는 스낵바입니다." onClose={() => {}} />
|
|
| 3939 |
+ </FoxSnackbarList> |
|
| 3940 |
+ ), |
|
| 3941 |
+ }, |
|
| 3942 |
+ {
|
|
| 3943 |
+ label: "하나만 있어도 그대로 씁니다", |
|
| 3944 |
+ node: ( |
|
| 3945 |
+ <FoxSnackbarList label="알림"> |
|
| 3946 |
+ <FoxSnackbar title="하나만 떠 있는 경우" onClose={() => {}} />
|
|
| 3947 |
+ </FoxSnackbarList> |
|
| 3948 |
+ ), |
|
| 3949 |
+ }, |
|
| 3950 |
+ ], |
|
| 3951 |
+ }, |
|
| 3952 |
+ {
|
|
| 3953 |
+ id: "fox-step-indicator-item", |
|
| 3954 |
+ name: "FoxStepIndicatorItem", |
|
| 3955 |
+ description: |
|
| 3956 |
+ "Figma 시안(step 251:1539) 단계 하나입니다. 위에 동그라미와 선, 아래에 단계 번호(body/xsm)와 제목(body/sm)이 놓입니다. state 세 가지가 동그라미 안과 선 색을 정합니다: completion은 회색으로 채우고 체크를 얹으며 선도 진합니다(여기까지 왔다는 표시), ongoing은 테두리만 강조색이고 가운데 점이 자리를 채우며 선은 흐립니다, before는 흐린 테두리에 연한 면이고 안이 비었습니다. 마지막 단계는 line={false}로 선을 꺼야 합니다 — 켜 두면 묶음 밖으로 삐져나갑니다. 동그라미와 선은 aria-hidden 장식이고 읽히는 것은 글자뿐입니다. 제목 굵기는 시안이 700인데 토큰 세트에 700이 없어(400 regular · 600 medium · 800 bold) 시스템의 bold(800)로 올렸습니다 — Pretendard 기준 Bold보다 한 단계 굵은 ExtraBold입니다. 너무 굵어 보이면 medium으로 내립니다.",
|
|
| 3957 |
+ variants: [ |
|
| 3958 |
+ {
|
|
| 3959 |
+ label: "상태 3종", |
|
| 3960 |
+ node: ( |
|
| 3961 |
+ <> |
|
| 3962 |
+ <FoxStepIndicatorItem state="completion" step="STEP 01" title="약관 동의" /> |
|
| 3963 |
+ <FoxStepIndicatorItem state="ongoing" step="STEP 02" title="정보 입력" /> |
|
| 3964 |
+ <FoxStepIndicatorItem state="before" step="STEP 03" title="가입 완료" /> |
|
| 3965 |
+ </> |
|
| 3966 |
+ ), |
|
| 3967 |
+ }, |
|
| 3968 |
+ {
|
|
| 3969 |
+ label: "글자 없이 — 동그라미와 선만 (모바일에서 자동으로 이 모양이 됩니다)", |
|
| 3970 |
+ node: ( |
|
| 3971 |
+ <> |
|
| 3972 |
+ <FoxStepIndicatorItem state="completion" /> |
|
| 3973 |
+ <FoxStepIndicatorItem state="ongoing" /> |
|
| 3974 |
+ <FoxStepIndicatorItem state="before" /> |
|
| 3975 |
+ </> |
|
| 3976 |
+ ), |
|
| 3977 |
+ }, |
|
| 3978 |
+ ], |
|
| 3979 |
+ }, |
|
| 3980 |
+ {
|
|
| 3981 |
+ id: "fox-step-indicator", |
|
| 3982 |
+ name: "FoxStepIndicator", |
|
| 3983 |
+ description: |
|
| 3984 |
+ "Figma 시안(step-indicator 708:1623) FoxStepIndicatorItem들을 가로로 늘어놓는 묶음입니다. 배치만 갖고 상태는 없습니다 — 어디까지 왔는지는 각 단계의 state가 말합니다. 자식에게 덮어쓰는 값이 없어서 마지막 단계의 line={false}는 호출부가 줍니다: 묶음이 '마지막'을 대신 판단하면 자식을 조건부로 렌더하거나 프래그먼트로 감싼 호출부에서 어긋납니다. role=list가 아니라 role=group입니다 — 목록이 아니라 하나의 진행 상황이고, 진행률을 소리로 전해야 하면 label에 담습니다. ⚠️ 폭 400px은 80px × 5단계라 단계가 다섯 개일 때의 값입니다. 개수가 다르면 className으로 덮습니다. 화면을 좁히면 글자가 사라지고 동그라미 줄만 남습니다.",
|
|
| 3985 |
+ variants: [ |
|
| 3986 |
+ {
|
|
| 3987 |
+ label: "5단계 — 시안 폭(400px)에 맞는 개수", |
|
| 3988 |
+ node: ( |
|
| 3989 |
+ <FoxStepIndicator label="가입 절차 (전체 5단계 중 3단계)"> |
|
| 3990 |
+ <FoxStepIndicatorItem state="completion" step="STEP 01" title="약관 동의" /> |
|
| 3991 |
+ <FoxStepIndicatorItem state="completion" step="STEP 02" title="본인 인증" /> |
|
| 3992 |
+ <FoxStepIndicatorItem state="ongoing" step="STEP 03" title="정보 입력" /> |
|
| 3993 |
+ <FoxStepIndicatorItem state="before" step="STEP 04" title="결제 수단" /> |
|
| 3994 |
+ <FoxStepIndicatorItem state="before" step="STEP 05" title="가입 완료" line={false} />
|
|
| 3995 |
+ </FoxStepIndicator> |
|
| 3996 |
+ ), |
|
| 3997 |
+ }, |
|
| 3998 |
+ {
|
|
| 3999 |
+ label: "3단계 — 폭이 고정이라 오른쪽이 남습니다 (className으로 덮는 자리)", |
|
| 4000 |
+ node: ( |
|
| 4001 |
+ <FoxStepIndicator label="결제 절차"> |
|
| 4002 |
+ <FoxStepIndicatorItem state="completion" step="STEP 01" title="장바구니" /> |
|
| 4003 |
+ <FoxStepIndicatorItem state="ongoing" step="STEP 02" title="결제" /> |
|
| 4004 |
+ <FoxStepIndicatorItem state="before" step="STEP 03" title="완료" line={false} />
|
|
| 4005 |
+ </FoxStepIndicator> |
|
| 4006 |
+ ), |
|
| 4007 |
+ }, |
|
| 4008 |
+ ], |
|
| 4009 |
+ }, |
|
| 4010 |
+ {
|
|
| 4011 |
+ id: "fox-popover", |
|
| 4012 |
+ name: "FoxPopover", |
|
| 4013 |
+ description: |
|
| 4014 |
+ "Figma 시안(popover 251:1813) 꼬리가 달린 말풍선 판입니다. direction이 꼬리가 판의 위(top)에 붙는지 아래(bottom)에 붙는지, arrow가 가로 어디(left/center/right)에 붙는지 정합니다 — top이면 트리거 아래에 뜨는 말풍선입니다. 자리를 스스로 잡지 않습니다: 트리거 옆 어디에 놓을지는 className으로 주고, 자리가 모자랄 때 위아래를 뒤집는 자동 배치도 없습니다(시안이 방향을 값으로 주므로 그 판단은 호출부 몫입니다). 액션은 FoxTextButton의 secondary·sm에 오른쪽 아이콘입니다. 꼬리의 가로 위치는 루트의 align-items가 정합니다(시안 지정) — 꼬리 영역이 좌우 24px 여백을 달고 다녀서 왼쪽·오른쪽에서는 그만큼 안쪽에 서고, 가운데에서는 여백이 대칭이라 정확히 가운데에 섭니다. 판은 align-self: stretch라 이 값에 영향받지 않고 언제나 폭을 꽉 채웁니다.", |
|
| 4015 |
+ variants: [ |
|
| 4016 |
+ {
|
|
| 4017 |
+ label: "direction=bottom — 꼬리가 아래 (트리거 위에 뜨는 말풍선)", |
|
| 4018 |
+ node: ( |
|
| 4019 |
+ <> |
|
| 4020 |
+ <FoxPopover |
|
| 4021 |
+ direction="bottom" |
|
| 4022 |
+ title="팝오버 제목" |
|
| 4023 |
+ message="컴포넌트 주변에 배치되어 해당 컴포넌트의 상태나 관련된 상세 정보를 제공하는 컴포넌트이다." |
|
| 4024 |
+ actionLabel="바로가기" |
|
| 4025 |
+ onAction={() => {}}
|
|
| 4026 |
+ onClose={() => {}}
|
|
| 4027 |
+ /> |
|
| 4028 |
+ <FoxPopover |
|
| 4029 |
+ direction="bottom" |
|
| 4030 |
+ arrow="center" |
|
| 4031 |
+ title="팝오버 제목" |
|
| 4032 |
+ message="컴포넌트 주변에 배치되어 해당 컴포넌트의 상태나 관련된 상세 정보를 제공하는 컴포넌트이다." |
|
| 4033 |
+ actionLabel="바로가기" |
|
| 4034 |
+ onAction={() => {}}
|
|
| 4035 |
+ onClose={() => {}}
|
|
| 4036 |
+ /> |
|
| 4037 |
+ <FoxPopover |
|
| 4038 |
+ direction="bottom" |
|
| 4039 |
+ arrow="right" |
|
| 4040 |
+ title="팝오버 제목" |
|
| 4041 |
+ message="컴포넌트 주변에 배치되어 해당 컴포넌트의 상태나 관련된 상세 정보를 제공하는 컴포넌트이다." |
|
| 4042 |
+ actionLabel="바로가기" |
|
| 4043 |
+ onAction={() => {}}
|
|
| 4044 |
+ onClose={() => {}}
|
|
| 4045 |
+ /> |
|
| 4046 |
+ </> |
|
| 4047 |
+ |
|
| 4048 |
+ ), |
|
| 4049 |
+ }, |
|
| 4050 |
+ {
|
|
| 4051 |
+ label: "direction=top — 꼬리가 위 (트리거 아래에 뜨는 말풍선)", |
|
| 4052 |
+ node: ( |
|
| 4053 |
+ <> |
|
| 4054 |
+ <FoxPopover |
|
| 4055 |
+ direction="top" |
|
| 4056 |
+ arrow="left" |
|
| 4057 |
+ title="팝오버 제목" |
|
| 4058 |
+ message="컴포넌트 주변에 배치되어 해당 컴포넌트의 상태나 관련된 상세 정보를 제공하는 컴포넌트이다." |
|
| 4059 |
+ actionLabel="바로가기" |
|
| 4060 |
+ onAction={() => {}}
|
|
| 4061 |
+ onClose={() => {}}
|
|
| 4062 |
+ /> |
|
| 4063 |
+ <FoxPopover |
|
| 4064 |
+ direction="top" |
|
| 4065 |
+ arrow="center" |
|
| 4066 |
+ title="팝오버 제목" |
|
| 4067 |
+ message="컴포넌트 주변에 배치되어 해당 컴포넌트의 상태나 관련된 상세 정보를 제공하는 컴포넌트이다." |
|
| 4068 |
+ actionLabel="바로가기" |
|
| 4069 |
+ onAction={() => {}}
|
|
| 4070 |
+ onClose={() => {}}
|
|
| 4071 |
+ /> |
|
| 4072 |
+ <FoxPopover |
|
| 4073 |
+ direction="top" |
|
| 4074 |
+ arrow="right" |
|
| 4075 |
+ title="팝오버 제목" |
|
| 4076 |
+ message="컴포넌트 주변에 배치되어 해당 컴포넌트의 상태나 관련된 상세 정보를 제공하는 컴포넌트이다." |
|
| 4077 |
+ actionLabel="바로가기" |
|
| 4078 |
+ onAction={() => {}}
|
|
| 4079 |
+ onClose={() => {}}
|
|
| 4080 |
+ /> |
|
| 4081 |
+ </> |
|
| 4082 |
+ ), |
|
| 4083 |
+ }, |
|
| 4084 |
+ ], |
|
| 4085 |
+ }, |
|
| 4086 |
+ {
|
|
| 4087 |
+ id: "fox-tooltip-rich", |
|
| 4088 |
+ name: "FoxTooltipRich", |
|
| 4089 |
+ description: |
|
| 4090 |
+ "Figma 시안(tooltip-rich 256:233) 제목과 본문을 담은 말풍선입니다. 150자 내외의 글자만 담습니다. 그래서 여닫는 것은 전적으로 트리거의 몫입니다(가리키거나 포커스가 닿는 동안 보이는 식). 팝오버와 다른 점은 셋뿐입니다: 테두리가 한 단계 옅고(border-neutral-default), 제목과 본문 사이가 좁으며(8px ↔ 16px), 그림자가 없습니다. 꼬리는 팝오버와 같은 그림이라 _bubble.scss의 arrow() 믹스인을 함께 씁니다 — 회전한 정사각형의 절반을 판에 걸쳐 이음새를 없애는 방식입니다. ⚠️ 이 조각은 보조기술에 아무 역할도 주지 않습니다: 툴팁으로 읽히려면 트리거가 aria-describedby로 id를 가리켜야 하고, 그 배선은 트리거 쪽 몫입니다.", |
|
| 4091 |
+ variants: [ |
|
| 4092 |
+ {
|
|
| 4093 |
+ label: "direction=bottom — 꼬리가 아래 (트리거 위에 뜨는 말풍선)", |
|
| 4094 |
+ node: ( |
|
| 4095 |
+ <> |
|
| 4096 |
+ <FoxTooltipRich |
|
| 4097 |
+ direction="bottom" |
|
| 4098 |
+ title="툴팁제목" |
|
| 4099 |
+ message="툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.본문을 가리지 않도록 주의합니다." |
|
| 4100 |
+ /> |
|
| 4101 |
+ <FoxTooltipRich |
|
| 4102 |
+ direction="bottom" |
|
| 4103 |
+ arrow="center" |
|
| 4104 |
+ title="툴팁제목" |
|
| 4105 |
+ message="툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.본문을 가리지 않도록 주의합니다." |
|
| 4106 |
+ /> |
|
| 4107 |
+ <FoxTooltipRich |
|
| 4108 |
+ direction="bottom" |
|
| 4109 |
+ arrow="right" |
|
| 4110 |
+ title="툴팁제목" |
|
| 4111 |
+ message="툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.본문을 가리지 않도록 주의합니다." |
|
| 4112 |
+ /> |
|
| 4113 |
+ </> |
|
| 4114 |
+ ), |
|
| 4115 |
+ }, |
|
| 4116 |
+ {
|
|
| 4117 |
+ label: "direction=top — 꼬리가 위 (트리거 아래에 뜨는 말풍선)", |
|
| 4118 |
+ node: ( |
|
| 4119 |
+ <> |
|
| 4120 |
+ <FoxTooltipRich |
|
| 4121 |
+ direction="top" |
|
| 4122 |
+ title="툴팁제목" |
|
| 4123 |
+ message="툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.본문을 가리지 않도록 주의합니다." |
|
| 4124 |
+ /> |
|
| 4125 |
+ <FoxTooltipRich |
|
| 4126 |
+ direction="top" |
|
| 4127 |
+ arrow="center" |
|
| 4128 |
+ title="툴팁제목" |
|
| 4129 |
+ message="툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.본문을 가리지 않도록 주의합니다." |
|
| 4130 |
+ /> |
|
| 4131 |
+ <FoxTooltipRich |
|
| 4132 |
+ direction="top" |
|
| 4133 |
+ arrow="right" |
|
| 4134 |
+ title="툴팁제목" |
|
| 4135 |
+ message="툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.본문을 가리지 않도록 주의합니다." |
|
| 4136 |
+ /> |
|
| 4137 |
+ </> |
|
| 4138 |
+ ), |
|
| 4139 |
+ }, |
|
| 4140 |
+ ], |
|
| 4141 |
+ }, |
|
| 4142 |
+ {
|
|
| 4143 |
+ id: "fox-tooltip", |
|
| 4144 |
+ name: "FoxTooltip", |
|
| 4145 |
+ description: |
|
| 4146 |
+ "Figma 시안(tooltip) 어두운 판에 짧은 글 한 줄을 담은 말풍선입니다. FoxTooltipRich의 작은 형제로 제목이 없고, 꼬리가 언제나 가운데이며(가로 위치를 고르는 값이 없습니다), 판이 글자만큼만 넓어집니다. 여기도 누를 것이 하나도 없습니다 — 잠깐 떴다 사라지는 판 안의 버튼은 누를 수 없습니다. 꼬리는 팝오버·리치 툴팁과 달리 시안 SVG를 그대로 씁니다: 저쪽은 테두리 있는 판의 1px 이음새를 지우려고 CSS로 그렸지만 이 판은 테두리 없이 한 색이라 그럴 일이 없고, 시안 꼬리가 12×6에 끝이 둥근 다른 그림이라 CSS 삼각형으로는 모양이 나오지 않습니다. 같은 그림을 180° 돌려 두 방향을 만듭니다. ⚠️ 판이 글자만큼 좁아져도 꼬리 영역의 좌우 24px 여백 때문에 가장 좁을 때가 60px입니다(시안 값). ⚠️ 이 조각도 보조기술에 역할을 주지 않습니다 — 트리거가 aria-describedby로 id를 가리켜야 합니다.", |
|
| 4147 |
+ variants: [ |
|
| 4148 |
+ {
|
|
| 4149 |
+ label: "direction=top — 꼬리가 위 (트리거 아래에 뜨는 말풍선)", |
|
| 4150 |
+ node: <FoxTooltip direction="top" message="툴팁 내용" />, |
|
| 4151 |
+ }, |
|
| 4152 |
+ {
|
|
| 4153 |
+ label: "direction=bottom — 꼬리가 아래 (트리거 위에 뜨는 말풍선)", |
|
| 4154 |
+ node: <FoxTooltip direction="bottom" message="툴팁 내용" />, |
|
| 4155 |
+ }, |
|
| 4156 |
+ {
|
|
| 4157 |
+ label: "글자 길이에 따라 판이 넓어집니다 (가장 좁을 때 60px)", |
|
| 4158 |
+ node: ( |
|
| 4159 |
+ <> |
|
| 4160 |
+ <FoxTooltip message="짧게" /> |
|
| 4161 |
+ <FoxTooltip message="조금 더 긴 안내 문구입니다" /> |
|
| 4162 |
+ </> |
|
| 4163 |
+ ), |
|
| 4164 |
+ }, |
|
| 4165 |
+ ], |
|
| 4166 |
+ }, |
|
| 4167 |
+ {
|
|
| 4168 |
+ id: "fox-contextual-trigger", |
|
| 4169 |
+ name: "FoxContextualTrigger", |
|
| 4170 |
+ description: |
|
| 4171 |
+ "Figma 시안(contextual-trigger) 툴팁이나 팝오버를 여는 24px짜리 아이콘 단추입니다. 계열이 아이콘을 정하고(help는 ico/Question, information은 ico/Info, 둘 다 weight=regular) 상태는 배경만 바꿉니다 — 기본이 action-secondary(투명)라 아래 배경이 그대로 비치고 hover·pressed에서만 연한 판이 깔립니다(FoxTag와 같은 규칙). FoxIconButton과는 다른 조각입니다: 저쪽은 폼 컨트롤과 높이를 맞추느라 가장 작은 xsm도 32px인데 이쪽은 글줄 옆에 붙는 24px 고정이고 모서리도 4px입니다. 상태를 갖지 않으므로 무엇을 언제 보여 줄지는 호출부가 정하고, 이 단추는 배선을 걸 자리(describedBy / controls·expanded)와 손이 닿는 자리를 줍니다. 아이콘만 있는 단추라 aria-label이 유일한 이름입니다 — 한 화면에 여럿이면 '배송비 도움말'처럼 무엇에 대한 것인지 적어 줍니다. ⚠️ 비활성은 시안에 없어 색을 만들지 않고 hover·pressed만 막았습니다.", |
|
| 4172 |
+ variants: [ |
|
| 4173 |
+ {
|
|
| 4174 |
+ label: "계열 2종 — hover·pressed는 마우스를 올리고 눌러서 확인하세요", |
|
| 4175 |
+ node: ( |
|
| 4176 |
+ <> |
|
| 4177 |
+ <FoxContextualTrigger type="help" /> |
|
| 4178 |
+ <FoxContextualTrigger type="information" /> |
|
| 4179 |
+ </> |
|
| 4180 |
+ ), |
|
| 4181 |
+ }, |
|
| 4182 |
+ {
|
|
| 4183 |
+ label: "실제 배선 — 가리키면 툴팁 / 누르면 팝오버 / 가리키면 리치 툴팁", |
|
| 4184 |
+ node: <ContextualTriggerDemo />, |
|
| 4185 |
+ }, |
|
| 4186 |
+ ], |
|
| 4187 |
+ }, |
|
| 3558 | 4188 |
]; |
+++ @fox/styles/_bubble.scss
... | ... | @@ -0,0 +1,127 @@ |
| 1 | +// 말풍선 공용 조각. | |
| 2 | +// | |
| 3 | +// 꼬리가 달린 판(`FoxPopover` · `FoxTooltipRich`)이 같은 그림을 쓴다. 그 되풀이를 여기로 모은다. | |
| 4 | +// 믹스인만 있고 규칙이 없어 @use 해도 CSS가 늘지 않는다 — 부르는 자리에서만 나온다. | |
| 5 | +// | |
| 6 | +// ── 꼬리를 CSS로 그리는 이유 ─────────────────────────────────────────────────── | |
| 7 | +// | |
| 8 | +// 시안은 꼬리를 22×12 SVG로 준다. 그 그림에는 평평한 끝을 덮는 1px 흰 띠가 들어 있는데, | |
| 9 | +// 꼬리를 판 위로 1px 겹쳐 올려 그 띠로 판의 1px 테두리를 지우는 구조다. Figma에서는 맞지만 | |
| 10 | +// 브라우저에서는 **1px 두 개를 정확히 겹쳐야 하는 방식**이라, 소수 배율 화면(윈도우 125% 등)에서 | |
| 11 | +// 둘이 서로 다른 물리 픽셀에 떨어지면 이음새에 실선이 비친다. 실제로 그렇게 보였다. | |
| 12 | +// | |
| 13 | +// 그래서 CSS로 그린다. **회전한 정사각형의 절반을 판 위에 걸치는** 방식이다: | |
| 14 | +// | |
| 15 | +// 1. 한 변이 22 ÷ √2(≈15.56px)인 정사각형을 45° 돌린다 → 밑변 22px, 높이 11px의 마름모 | |
| 16 | +// 2. 그 마름모의 **가운데를 판과 맞닿는 변 위에** 둔다 | |
| 17 | +// 3. 바깥을 향하는 두 변에만 1px 테두리를 준다 (안쪽 두 변은 테두리 없음) | |
| 18 | +// | |
| 19 | +// 그러면 안쪽 절반(11px)이 판의 테두리 선을 자기 배경으로 덮는다 — 1px끼리 맞추는 것이 아니라 | |
| 20 | +// 11px짜리 면이 1px 선을 덮으므로 어떤 배율에서도 이음새가 남지 않는다. 바깥 두 변의 테두리는 | |
| 21 | +// 마름모의 좌우 꼭짓점, 즉 판의 테두리와 같은 높이에서 끝나므로 선이 자연스럽게 이어진다. | |
| 22 | +// | |
| 23 | +// 45°로 돌리면 밑변과 높이의 비가 2:1이라 22×11이 된다. 시안은 22×12이므로 돌린 뒤 세로로 | |
| 24 | +// 12/11만큼 늘인다(CSS 변환은 오른쪽부터 적용되므로 `scaleY(...) rotate(45deg)` 순서다). | |
| 25 | +// 빗변 테두리가 그만큼 두꺼워지지만 1.09배라 눈에 띄지 않는다. | |
| 26 | +// | |
| 27 | +// SVG를 쓰지 않으니 마스크 id가 문서에서 겹칠 일도 없다(시안 SVG를 그대로 넣으면 말풍선이 | |
| 28 | +// 둘 이상 뜰 때 같은 id를 서로 참조한다). | |
| 29 | +// | |
| 30 | +// 토큰이 아닌 값: | |
| 31 | +// - ⚠️ 꼬리가 차지하는 자리 22×12px: 시안이 준 그림의 크기이고 토큰 스케일에 없다. | |
| 32 | +// - ⚠️ 정사각형의 한 변 `1.5556rem`: 밑변이 22px이 되는 값(22 ÷ √2)이다. 밑변을 바꾸려면 | |
| 33 | +// 이 값을 √2로 나눠 다시 구한다. | |
| 34 | +// - ⚠️ `scaleY(12 / 11)`: 45° 마름모의 높이(11px)를 시안의 12px로 맞추는 보정이다. | |
| 35 | +// - 시안의 `gap: -1px`은 옮기지 않았다 — 정사각형의 안쪽 절반이 이미 판 위에 걸쳐 테두리를 | |
| 36 | +// 덮으므로 따로 당길 필요가 없다. | |
| 37 | + | |
| 38 | +@use "@fox/styles/abstracts" as fox; | |
| 39 | + | |
| 40 | +/// 말풍선 바깥 틀. 꼬리와 판이 세로로 서고, 꼬리의 가로 위치를 이 정렬이 정한다. | |
| 41 | +@mixin root($inline-size: 36rem) { | |
| 42 | + box-sizing: border-box; | |
| 43 | + display: flex; | |
| 44 | + flex-direction: column; | |
| 45 | + inline-size: $inline-size; | |
| 46 | +} | |
| 47 | + | |
| 48 | +/// 꼬리와 방향·위치 규칙 한 벌. | |
| 49 | +/// | |
| 50 | +/// `$block`은 BEM 블록 이름(`"fox-popover"` 등)이고, `$border`·`$fill`은 판과 **같은 값**을 | |
| 51 | +/// 넘긴다 — 꼬리는 판이 뾰족하게 튀어나온 부분이라 색이 갈리면 안 된다. | |
| 52 | +/// | |
| 53 | +/// 부르는 쪽은 다음 마크업을 갖는다: | |
| 54 | +/// <div class="…__arrow-area"><span class="…__arrow"></span></div> | |
| 55 | +@mixin arrow($block, $border, $fill) { | |
| 56 | + $root: ".#{$block}"; | |
| 57 | + | |
| 58 | + // 좌우 여백이 꼬리를 판 가장자리에서 띄운다. **늘리지 않는다** — 폭이 내용(22px + 여백 | |
| 59 | + // 48px)만큼이라야 루트의 `align-items`가 이 덩어리를 왼쪽·가운데·오른쪽으로 옮길 수 있다. | |
| 60 | + // | |
| 61 | + // 판보다 위에 칠해진다. 마름모의 안쪽 절반이 판의 테두리를 덮어야 하는데, `--top`에서는 | |
| 62 | + // 꼬리가 DOM에서 먼저 와 판이 나중에 덧칠되기 때문이다. | |
| 63 | + #{$root}__arrow-area { | |
| 64 | + position: relative; | |
| 65 | + z-index: 1; | |
| 66 | + box-sizing: border-box; | |
| 67 | + display: flex; | |
| 68 | + flex-direction: column; | |
| 69 | + align-items: flex-start; | |
| 70 | + padding-inline: fox.padding(8); | |
| 71 | + } | |
| 72 | + | |
| 73 | + // 자리만 잡는다 — 그림은 아래 `::before`가 그린다(파일 상단 주석 참고). | |
| 74 | + #{$root}__arrow { | |
| 75 | + position: relative; | |
| 76 | + display: block; | |
| 77 | + inline-size: 2.2rem; | |
| 78 | + block-size: 1.2rem; | |
| 79 | + } | |
| 80 | + | |
| 81 | + #{$root}__arrow::before { | |
| 82 | + content: ""; | |
| 83 | + position: absolute; | |
| 84 | + inset-inline-start: 50%; | |
| 85 | + // 밑변이 22px이 되는 정사각형의 한 변: 22 ÷ √2. | |
| 86 | + inline-size: 1.5556rem; | |
| 87 | + block-size: 1.5556rem; | |
| 88 | + border-style: solid; | |
| 89 | + border-color: $border; | |
| 90 | + border-width: 0; | |
| 91 | + background: $fill; | |
| 92 | + } | |
| 93 | + | |
| 94 | + // 정사각형의 가운데를 판과 맞닿는 변에 두고, 바깥을 향하는 두 변에만 테두리를 준다. | |
| 95 | + // 45° 돌리면 원래의 위·왼쪽 변이 위쪽 두 빗변이 되고, 아래·오른쪽 변이 아래쪽 두 빗변이 된다. | |
| 96 | + #{$root}--top #{$root}__arrow::before { | |
| 97 | + inset-block-start: 100%; | |
| 98 | + border-block-start-width: fox.border(1); | |
| 99 | + border-inline-start-width: fox.border(1); | |
| 100 | + transform: translate(-50%, -50%) scaleY(calc(12 / 11)) rotate(45deg); | |
| 101 | + } | |
| 102 | + | |
| 103 | + #{$root}--bottom { | |
| 104 | + justify-content: flex-end; | |
| 105 | + } | |
| 106 | + | |
| 107 | + #{$root}--bottom #{$root}__arrow::before { | |
| 108 | + inset-block-start: 0; | |
| 109 | + border-block-end-width: fox.border(1); | |
| 110 | + border-inline-end-width: fox.border(1); | |
| 111 | + transform: translate(-50%, -50%) scaleY(calc(12 / 11)) rotate(45deg); | |
| 112 | + } | |
| 113 | + | |
| 114 | + // 꼬리의 가로 위치는 루트의 정렬이 정한다(시안 지정). 판은 `align-self: stretch`라 이 값에 | |
| 115 | + // 영향받지 않고 언제나 폭을 꽉 채운다 — 움직이는 것은 꼬리뿐이다. | |
| 116 | + #{$root}--arrow-left { | |
| 117 | + align-items: flex-start; | |
| 118 | + } | |
| 119 | + | |
| 120 | + #{$root}--arrow-center { | |
| 121 | + align-items: center; | |
| 122 | + } | |
| 123 | + | |
| 124 | + #{$root}--arrow-right { | |
| 125 | + align-items: flex-end; | |
| 126 | + } | |
| 127 | +} |
--- @fox/styles/_form-field.scss
+++ @fox/styles/_form-field.scss
... | ... | @@ -27,8 +27,14 @@ |
| 27 | 27 |
} |
| 28 | 28 |
|
| 29 | 29 |
/// 시안 helper-message. 오류·포커스 색은 상태 규칙이 덮는다. |
| 30 |
-@mixin message {
|
|
| 31 |
- display: flex; |
|
| 30 |
+/// |
|
| 31 |
+/// `$display`는 폼 안에서 `flex`이고, 단독으로 쓰는 `FoxHelperText`에서만 |
|
| 32 |
+/// `inline-flex`다 — 그쪽 시안이 내용만큼만 차지하게 준다. |
|
| 33 |
+/// |
|
| 34 |
+/// TODO(폼 통합, 적용 후 이 문단 삭제): 이 믹스인을 부르는 폼 다섯 곳은 앞으로 |
|
| 35 |
+/// `FoxHelperText`(`.fox-helper-text`)로 옮긴다 — 절차는 `_fox-helper-text.scss` 상단 참고. |
|
| 36 |
+@mixin message($display: flex) {
|
|
| 37 |
+ display: $display; |
|
| 32 | 38 |
gap: fox.number(3); |
| 33 | 39 |
align-items: flex-start; |
| 34 | 40 |
margin: 0; |
+++ @fox/styles/_fox-alert.scss
... | ... | @@ -0,0 +1,166 @@ |
| 1 | +// FoxAlert — 시안: 통합관리자페이지 디자인시스템 Figma alert | |
| 2 | +// | |
| 3 | +// 화면 안에 놓이는 띠 모양 알림이다. 아이콘 상자 · 제목 · 본문이 한 줄로 선다. | |
| 4 | +// **모달이 아니다** — 화면을 덮지 않고 내용 흐름 안에 그대로 놓인다. | |
| 5 | +// | |
| 6 | +// 마크업 계약 (React 밖 소비자용): | |
| 7 | +// <div class="fox-alert fox-alert--default" role="status"> | |
| 8 | +// <span class="fox-alert__icon">…아이콘…</span> | |
| 9 | +// <div class="fox-alert__content"> | |
| 10 | +// <strong class="fox-alert__title">제목</strong> | |
| 11 | +// <span class="fox-alert__message">본문</span> | |
| 12 | +// </div> | |
| 13 | +// </div> | |
| 14 | +// | |
| 15 | +// 제목·본문은 각각 없을 수 있다. 아이콘 상자는 아이콘이 없어도 그린다 — 시안이 흰 판을 | |
| 16 | +// 배치의 일부로 주고, 있고 없고에 따라 글자 시작점이 흔들리면 안 된다. | |
| 17 | +// | |
| 18 | +// 제목이 `heading/xsm`(title 패밀리·bold)이고 본문이 `body/md`(body 패밀리·regular)다. | |
| 19 | +// 글자 크기는 둘 다 15px이라 굵기와 패밀리로만 갈린다 — 시안이 그렇게 준다. | |
| 20 | +// | |
| 21 | +// 토큰이 아닌 값: | |
| 22 | +// - ⚠️ `inline-size: 72rem`: **시안의 폭(720px)을 일단 그대로 적용해 둔 것이다.** Figma가 | |
| 23 | +// 변수로 export하지 않았고 토큰 스케일에도 720이 없다(modal 계열의 가장 큰 값이 760px). | |
| 24 | +// 토큰이 생기면 `fox.*()`로 바꾸고, 폭을 고정하지 않는 쪽으로 정해지면 여기서 뺀다. | |
| 25 | +// 그때까지 폭을 바꿔야 하는 자리는 호출부가 `className`으로 덮는다 | |
| 26 | +// (`fox-toggle-quantity`와 같은 처리). | |
| 27 | +// - `max-inline-size: 100%`: 시안에 없는 구조 규칙이다. 720px보다 좁은 자리에 놓였을 때 | |
| 28 | +// 띠가 부모를 뚫고 나가지 않게 막는다 — 고정 폭만 두면 표 안이나 좁은 단에서 넘친다. | |
| 29 | +// - line-height / letter-spacing: 시안 텍스트 스타일 heading/xsm·body/md의 값이나 Figma가 | |
| 30 | +// 변수로 export하지 않는다. 시안의 -0.375px은 15px 기준이라 `-0.025em`으로 적는다 | |
| 31 | +// (다른 컴포넌트와 같은 근거). | |
| 32 | +// - box-sizing / display / flex / aspect-ratio: 구조 규칙. | |
| 33 | +// | |
| 34 | +// 아이콘은 컴포넌트의 `TYPE_ICON`이 계열마다 하나씩 쥐고 있다(Prohibit · Warning · | |
| 35 | +// CheckCircle · Info · ChatDots, 전부 `weight="duotone"`). 색은 **제목 글자와 같은 계열색**을 | |
| 36 | +// 쓴다(default는 `font-neutral-strong`) — 시안 확인 완료. | |
| 37 | +// | |
| 38 | +// duotone은 연한 면과 진한 선을 겹쳐 그리고 **둘 다 `currentColor`를 쓴다** — 연한 쪽은 | |
| 39 | +// 글리프 자체가 `opacity: 0.2`를 물고 있어 색을 하나만 줘도 두 톤이 나온다. | |
| 40 | +// | |
| 41 | +// duotone은 연한 면과 진한 선을 겹쳐 그리고 **둘 다 `currentColor`를 쓴다** — 연한 쪽은 | |
| 42 | +// 글리프 자체가 `opacity: 0.2`를 물고 있어 색을 하나만 줘도 두 톤이 나온다. | |
| 43 | +// | |
| 44 | + | |
| 45 | +@use "@fox/styles/abstracts" as fox; | |
| 46 | + | |
| 47 | +.fox-alert { | |
| 48 | + // 안쪽 여백이 바깥 폭을 늘리지 않게 한다 — 720px이 720px로 그려진다. | |
| 49 | + box-sizing: border-box; | |
| 50 | + display: flex; | |
| 51 | + align-items: center; | |
| 52 | + gap: fox.gap(4); | |
| 53 | + inline-size: 72rem; | |
| 54 | + max-inline-size: 100%; | |
| 55 | + padding: fox.padding(5) fox.padding(6); | |
| 56 | + border: fox.border(1) solid fox.color(border-neutral-subtler); | |
| 57 | + border-radius: fox.radius(4); | |
| 58 | + background: fox.color(surface-neutral-gray); | |
| 59 | + | |
| 60 | + // 정사각 흰 판. 글자가 길어져 띠가 늘어나도 크기가 그대로여야 해서 줄어들지 않게 막는다. | |
| 61 | + &__icon { | |
| 62 | + display: flex; | |
| 63 | + flex-shrink: 0; | |
| 64 | + justify-content: center; | |
| 65 | + align-items: center; | |
| 66 | + inline-size: fox.number(13); | |
| 67 | + block-size: fox.number(13); | |
| 68 | + aspect-ratio: 1 / 1; | |
| 69 | + border-radius: fox.radius(4); | |
| 70 | + background: fox.color(element-neutral-inverse); | |
| 71 | + // 글리프가 `fill: currentColor`라 이 색을 그대로 입는다. 계열이 있으면 아래에서 덮는다. | |
| 72 | + color: fox.color(font-neutral-strong); | |
| 73 | + } | |
| 74 | + | |
| 75 | + // 글리프는 흰 판(36px)보다 작은 24px이다 — 판이 글리프를 감싸는 여백을 갖는다. | |
| 76 | + // 상자와 마찬가지로 줄어들지 않게 막고 정사각을 못박는다(시안이 둘 다 명시한다). | |
| 77 | + &__icon > * { | |
| 78 | + flex-shrink: 0; | |
| 79 | + inline-size: fox.icon(5); | |
| 80 | + block-size: fox.icon(5); | |
| 81 | + aspect-ratio: 1 / 1; | |
| 82 | + } | |
| 83 | + | |
| 84 | + // 남는 폭을 전부 가져간다. `min-inline-size: 0`이 없으면 긴 글이 줄바꿈되지 않고 띠를 | |
| 85 | + // 밀어 넓힌다(flex 자식의 기본 최소 크기가 내용 크기라서다). | |
| 86 | + &__content { | |
| 87 | + display: flex; | |
| 88 | + flex: 1 0 0; | |
| 89 | + flex-direction: column; | |
| 90 | + align-items: flex-start; | |
| 91 | + gap: fox.gap(1); | |
| 92 | + min-inline-size: 0; | |
| 93 | + } | |
| 94 | + | |
| 95 | + &__title { | |
| 96 | + align-self: stretch; | |
| 97 | + color: fox.color(font-neutral-strong); | |
| 98 | + font-family: fox.font-family(title); | |
| 99 | + font-size: fox.font-size(heading-xsm); | |
| 100 | + font-weight: fox.font-weight(bold); | |
| 101 | + line-height: 1.4; | |
| 102 | + letter-spacing: -0.025em; | |
| 103 | + } | |
| 104 | + | |
| 105 | + &__message { | |
| 106 | + align-self: stretch; | |
| 107 | + color: fox.color(font-neutral-strong); | |
| 108 | + font-family: fox.font-family(body); | |
| 109 | + font-size: fox.font-size(body-md); | |
| 110 | + font-weight: fox.font-weight(regular); | |
| 111 | + line-height: 1.5; | |
| 112 | + letter-spacing: -0.025em; | |
| 113 | + } | |
| 114 | + | |
| 115 | + // ── 계열 ──────────────────────────────────────────────────────────────────── | |
| 116 | + // 계열이 바꾸는 것은 **배경 · 테두리 · 제목 글자색** 셋뿐이다. 본문 글자색과 아이콘 상자의 | |
| 117 | + // 흰 배경은 계열과 무관하게 블록 규칙을 그대로 쓴다(시안 확인). | |
| 118 | + // | |
| 119 | + // 테두리만 `-inverse`다 — 같은 계열의 5(연한 배경)와 10(그보다 한 단계 진한 선)을 짝지어 | |
| 120 | + // 쓰라고 토큰이 그렇게 갈라져 있다. 다크 모드에서는 세 값이 함께 뒤집힌다(95 / 90 / 20). | |
| 121 | + // | |
| 122 | + // `default`는 블록 규칙이 곧 그 계열의 모양이라 따로 덮을 것이 없다. | |
| 123 | + &--default { | |
| 124 | + // 시안 그대로 — 블록에 있는 값이 전부다. | |
| 125 | + } | |
| 126 | + | |
| 127 | + &--information { | |
| 128 | + border-color: fox.color(border-system-information-inverse); | |
| 129 | + background: fox.color(surface-system-information); | |
| 130 | + } | |
| 131 | + | |
| 132 | + &--information &__title, | |
| 133 | + &--information &__icon { | |
| 134 | + color: fox.color(font-system-information); | |
| 135 | + } | |
| 136 | + | |
| 137 | + &--success { | |
| 138 | + border-color: fox.color(border-system-success-inverse); | |
| 139 | + background: fox.color(surface-system-success); | |
| 140 | + } | |
| 141 | + | |
| 142 | + &--success &__title, | |
| 143 | + &--success &__icon { | |
| 144 | + color: fox.color(font-system-success); | |
| 145 | + } | |
| 146 | + | |
| 147 | + &--warning { | |
| 148 | + border-color: fox.color(border-system-warning-inverse); | |
| 149 | + background: fox.color(surface-system-warning); | |
| 150 | + } | |
| 151 | + | |
| 152 | + &--warning &__title, | |
| 153 | + &--warning &__icon { | |
| 154 | + color: fox.color(font-system-warning); | |
| 155 | + } | |
| 156 | + | |
| 157 | + &--danger { | |
| 158 | + border-color: fox.color(border-system-danger-inverse); | |
| 159 | + background: fox.color(surface-system-danger); | |
| 160 | + } | |
| 161 | + | |
| 162 | + &--danger &__title, | |
| 163 | + &--danger &__icon { | |
| 164 | + color: fox.color(font-system-danger); | |
| 165 | + } | |
| 166 | +} |
+++ @fox/styles/_fox-contextual-trigger.scss
... | ... | @@ -0,0 +1,75 @@ |
| 1 | +// FoxContextualTrigger — 시안: 통합관리자페이지 디자인시스템 Figma contextual-trigger | |
| 2 | +// | |
| 3 | +// 툴팁이나 팝오버를 여는 24px짜리 아이콘 단추다. 계열이 아이콘을 정하고(help는 물음표, | |
| 4 | +// information은 느낌표), 상태는 배경만 바꾼다. | |
| 5 | +// | |
| 6 | +// 마크업 계약 (React 밖 소비자용): | |
| 7 | +// <button type="button" class="fox-contextual-trigger fox-contextual-trigger--help" | |
| 8 | +// aria-label="도움말"> | |
| 9 | +// <span class="fox-contextual-trigger__icon">…아이콘 svg…</span> | |
| 10 | +// </button> | |
| 11 | +// | |
| 12 | +// 루트가 `<button>`이라 hover·pressed·포커스·키보드가 전부 네이티브다. 시안의 pressed는 | |
| 13 | +// `:active`가 받는다. | |
| 14 | +// | |
| 15 | +// `fox-icon-button`과 다른 조각이다. 저쪽은 폼 컨트롤과 높이를 맞추는 단추라 크기가 | |
| 16 | +// `form(height-*)`을 따르는데(가장 작은 xsm도 32px), 이쪽은 글줄 옆에 붙는 24px 고정이고 | |
| 17 | +// 모서리도 `radius-2`(4px)다. | |
| 18 | +// | |
| 19 | +// 배경은 세 상태 모두 `action-secondary` 계열이다 — 기본이 투명이라 아래 배경이 그대로 | |
| 20 | +// 비치고, hover·pressed에서만 연한 판이 깔린다(`fox-tag`와 같은 규칙). | |
| 21 | +// | |
| 22 | +// ⚠️ 비활성은 시안에 없다. 색을 임의로 만들지 않고 hover·pressed만 막는다 — 네이티브 | |
| 23 | +// `disabled`를 주면 커서와 클릭만 죽고 모양은 기본과 같다(`fox-tag`와 같은 처리). | |
| 24 | +// | |
| 25 | +// 토큰이 아닌 값: | |
| 26 | +// - box-sizing / display / flex / aspect-ratio / cursor: 구조·상호작용 규칙. | |
| 27 | + | |
| 28 | +@use "@fox/styles/abstracts" as fox; | |
| 29 | + | |
| 30 | +.fox-contextual-trigger { | |
| 31 | + box-sizing: border-box; | |
| 32 | + display: flex; | |
| 33 | + align-items: center; | |
| 34 | + justify-content: center; | |
| 35 | + inline-size: fox.number(10); | |
| 36 | + block-size: fox.number(10); | |
| 37 | + aspect-ratio: 1 / 1; | |
| 38 | + padding: 0; | |
| 39 | + border: none; | |
| 40 | + border-radius: fox.radius(2); | |
| 41 | + background: fox.color(action-secondary); | |
| 42 | + color: fox.color(icon-neutral-default); | |
| 43 | + cursor: pointer; | |
| 44 | + | |
| 45 | + &:disabled { | |
| 46 | + cursor: default; | |
| 47 | + } | |
| 48 | + | |
| 49 | + // 아이콘은 판(24px)보다 작은 20px이다 — 판이 아이콘을 감싸는 여백을 갖는다. | |
| 50 | + &__icon { | |
| 51 | + display: inline-flex; | |
| 52 | + flex-shrink: 0; | |
| 53 | + align-items: center; | |
| 54 | + justify-content: center; | |
| 55 | + inline-size: fox.icon(4); | |
| 56 | + block-size: fox.icon(4); | |
| 57 | + aspect-ratio: 1 / 1; | |
| 58 | + } | |
| 59 | + | |
| 60 | + // 넘긴 SVG가 슬롯을 꽉 채운다 — 크기는 슬롯이 정하고 SVG는 따라온다. | |
| 61 | + &__icon > * { | |
| 62 | + inline-size: 100%; | |
| 63 | + block-size: 100%; | |
| 64 | + } | |
| 65 | + | |
| 66 | + // ── 상태 ──────────────────────────────────────────────────────────────────── | |
| 67 | + // 계열과 무관하게 배경만 바뀐다. | |
| 68 | + &:hover:not(:disabled) { | |
| 69 | + background: fox.color(action-secondary-hover); | |
| 70 | + } | |
| 71 | + | |
| 72 | + &:active:not(:disabled) { | |
| 73 | + background: fox.color(action-secondary-pressed); | |
| 74 | + } | |
| 75 | +} |
--- @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-heading-group.scss
... | ... | @@ -0,0 +1,70 @@ |
| 1 | +// FoxHeadingGroup — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) heading-group (3002:7177) | |
| 2 | +// | |
| 3 | +// 마크업 계약 (React 밖 소비자용): | |
| 4 | +// <div class="fox-heading-group"> | |
| 5 | +// <div class="fox-heading-group__text"> | |
| 6 | +// <div class="fox-heading-group__heading"><h2 class="fox-heading-group__title">제목</h2></div> | |
| 7 | +// <p class="fox-heading-group__description">설명</p> <!-- 선택 --> | |
| 8 | +// </div> | |
| 9 | +// <div class="fox-heading-group__actions">…</div> <!-- 선택 --> | |
| 10 | +// </div> | |
| 11 | +// | |
| 12 | +// 제목 줄이 따로 있는 이유는 시안이 그 안에 아이콘·배지를 함께 세울 자리를 두기 때문이다 | |
| 13 | +// (지금은 제목 하나뿐이지만 8px 간격이 이미 잡혀 있다). | |
| 14 | +// | |
| 15 | +// 토큰이 아닌 값: | |
| 16 | +// - line-height / letter-spacing: 시안 텍스트 스타일 display/sm의 값이나 Figma가 변수로 | |
| 17 | +// export하지 않는다. -0.65px은 26px 기준이라 `-0.025em`으로 적는다(다른 컴포넌트와 같은 근거). | |
| 18 | +// - margin 0: 제목 태그의 브라우저 기본값 지우기. | |
| 19 | + | |
| 20 | +@use "abstracts" as fox; | |
| 21 | + | |
| 22 | +.fox-heading-group { | |
| 23 | + display: flex; | |
| 24 | + inline-size: 100%; | |
| 25 | + align-items: center; | |
| 26 | + gap: fox.gap(5); | |
| 27 | + // 뒤따르는 도구 줄·표가 간격을 따로 두지 않게 이 조각이 아래 여백을 갖는다. | |
| 28 | + padding-block-end: fox.spacing(bottom-md); | |
| 29 | + | |
| 30 | + &__text { | |
| 31 | + display: flex; | |
| 32 | + flex: 1 0 0; | |
| 33 | + min-inline-size: 0; | |
| 34 | + flex-direction: column; | |
| 35 | + } | |
| 36 | + | |
| 37 | + &__heading { | |
| 38 | + display: flex; | |
| 39 | + inline-size: 100%; | |
| 40 | + align-items: center; | |
| 41 | + gap: fox.gap(3); | |
| 42 | + } | |
| 43 | + | |
| 44 | + &__title { | |
| 45 | + margin: 0; | |
| 46 | + color: fox.color(font-neutral-strong); | |
| 47 | + font-family: fox.font-family(title); | |
| 48 | + font-size: fox.font-size(display-sm); | |
| 49 | + font-weight: fox.font-weight(bold); | |
| 50 | + line-height: 1.5; | |
| 51 | + letter-spacing: -0.025em; | |
| 52 | + } | |
| 53 | + | |
| 54 | + &__description { | |
| 55 | + margin: 0; | |
| 56 | + color: fox.color(font-neutral-subtle); | |
| 57 | + font-family: fox.font-family(body); | |
| 58 | + font-size: fox.font-size(body-md); | |
| 59 | + font-weight: fox.font-weight(regular); | |
| 60 | + line-height: 1.5; | |
| 61 | + letter-spacing: -0.025em; | |
| 62 | + } | |
| 63 | + | |
| 64 | + &__actions { | |
| 65 | + display: flex; | |
| 66 | + flex-shrink: 0; | |
| 67 | + align-items: center; | |
| 68 | + gap: fox.gap(3); | |
| 69 | + } | |
| 70 | +} |
+++ @fox/styles/_fox-helper-text.scss
... | ... | @@ -0,0 +1,105 @@ |
| 1 | +// FoxHelperText — 시안: 통합관리자페이지 디자인시스템 Figma helper-txt | |
| 2 | +// | |
| 3 | +// 입력 아래 한 줄로 붙는 안내·오류 문구다. 아이콘 하나와 글자 한 덩어리로 끝난다. | |
| 4 | +// | |
| 5 | +// 마크업 계약 (React 밖 소비자용): | |
| 6 | +// <p class="fox-helper-text fox-helper-text--danger"> | |
| 7 | +// <span class="fox-helper-text__icon">…아이콘 svg…</span> | |
| 8 | +// <span class="fox-helper-text__message">필수 항목입니다.</span> | |
| 9 | +// </p> | |
| 10 | +// | |
| 11 | +// 아이콘은 없어도 된다(글자만 있는 자리가 많다). 계열은 `FoxAlert`와 같은 다섯 개이고, | |
| 12 | +// 글리프도 같은 것을 쓰되 **굵기만 regular**다(알럿은 duotone). | |
| 13 | +// | |
| 14 | +// **모양 값을 여기서 새로 적지 않는다.** 시안 값이 `_form-field.scss`의 `message`· | |
| 15 | +// `message-icon` 믹스인과 한 자도 다르지 않아(gap 4px · 아이콘 padding-top 2px · body/sm · | |
| 16 | +// line-height 150% · letter-spacing -0.025em) 그대로 부른다. 폼 컴포넌트의 `__message`가 | |
| 17 | +// 같은 믹스인을 쓰므로, 두 경로가 갈라질 수 없다. | |
| 18 | +// | |
| 19 | +// 유일한 차이가 `display`다 — 시안이 `inline-flex`를 준다(폼 안에서는 `flex`). 문구가 | |
| 20 | +// 글줄 안에 놓일 수 있어서 폭을 내용만큼만 갖는 쪽이 맞다. | |
| 21 | +// | |
| 22 | +// 계열은 **글자와 아이콘의 색만** 바꾼다. `default`는 믹스인이 정한 회색 | |
| 23 | +// (`font-neutral-subtle`)이고, 나머지 넷은 알럿 제목과 같은 `font-system-*`이다. 시안 확인 완료. | |
| 24 | +// | |
| 25 | +// 아이콘 크기도 믹스인이 정한 16px(`icon-3`)이 시안 값이다 — 폼 안 헬퍼와 같다(시안 확인 완료). | |
| 26 | +// | |
| 27 | +// TODO(폼 통합, 적용 후 이 주석 삭제): 폼 컴포넌트 여섯 곳의 헬퍼를 이 클래스로 모은다. | |
| 28 | +// 지금은 각자 그린다 — FoxInput · FoxTextArea · FoxEmail · FoxPhoneNumber · FoxAddress가 | |
| 29 | +// `__message`, FoxSelect가 `__hint`다. 모양은 `_form-field.scss`의 `message` 믹스인 한 | |
| 30 | +// 곳에서 나와 어긋나지는 않지만 마크업이 여섯 벌이다. | |
| 31 | +// | |
| 32 | +// 옮길 때 할 일: | |
| 33 | +// 1) `FoxHelperText`에 `icon` 오버라이드 prop을 더한다 — 폼은 호출부가 준 아이콘을 쓰고 | |
| 34 | +// 계열 글리프를 쓰지 않는다. | |
| 35 | +// 2) tsx의 `<p class="fox-X__message">`를 `<FoxHelperText className="fox-X__message" …>`로 | |
| 36 | +// 바꾼다. **클래스를 남기는 것이 요점이다** — 그래야 상태 색 규칙(`auto-states`의 | |
| 37 | +// `:focus-within`·`:has([aria-invalid])`)이 선택자를 고치지 않고 그대로 먹는다. | |
| 38 | +// 3) 각 컴포넌트 scss에서 `&__message { @include field.message; }`와 | |
| 39 | +// `&__message-icon { … }` 블록을 지운다 — `.fox-helper-text`가 대신 준다. | |
| 40 | +// 4) 아이콘은 `showIcon={Boolean(messageIcon)}`으로 넘겨 기존 동작(아이콘을 안 주면 안 | |
| 41 | +// 그린다)을 유지한다. | |
| 42 | +// | |
| 43 | +// ⚠️ 옮기면 **FoxSelect만 모양이 달라진다.** 진행 전에 확인받을 것: | |
| 44 | +// - 힌트 간격이 `gap 8px` · `align-items: center`에서 `4px` · `flex-start`로 바뀐다 | |
| 45 | +// (helper-txt 시안 값이 이쪽이다). | |
| 46 | +// - 오류일 때 아이콘 색이 `icon-system-danger`(danger-50)에서, 글자색을 상속한 | |
| 47 | +// `font-system-danger`(danger-60)로 한 단계 진해진다. | |
| 48 | +// | |
| 49 | +// 나머지 다섯은 값이 같아 화면이 바뀌지 않는다. 앱·다른 컴포넌트가 `__message`·`__hint` | |
| 50 | +// 클래스를 직접 참조하는 곳은 없다(확인 완료). | |
| 51 | + | |
| 52 | +@use "@fox/styles/abstracts" as fox; | |
| 53 | +@use "@fox/styles/form-field" as field; | |
| 54 | + | |
| 55 | +.fox-helper-text { | |
| 56 | + // 시안이 inline-flex다(폼 안에서는 flex) — 믹스인에 인자로 넘겨 한 번만 적는다. | |
| 57 | + @include field.message($display: inline-flex); | |
| 58 | + | |
| 59 | + &__icon { | |
| 60 | + @include field.message-icon; | |
| 61 | + } | |
| 62 | + | |
| 63 | + // ── 계열 ──────────────────────────────────────────────────────────────────── | |
| 64 | + // 글자와 아이콘의 색이 **다르다** — 글자는 `font-system-*`, 아이콘은 한 단계 진한 | |
| 65 | + // `icon-system-*-strong`이다(시안 확인). 아이콘 글리프가 `fill: currentColor`라 슬롯에 | |
| 66 | + // 색을 주면 그대로 입는다. | |
| 67 | + // | |
| 68 | + // `default`만 예외다 — 글자도 아이콘도 믹스인이 정한 회색(`font-neutral-subtle`)을 함께 | |
| 69 | + // 쓰므로 따로 덮을 것이 없다. | |
| 70 | + &--default { | |
| 71 | + // 시안 그대로 — 믹스인에 있는 값이 전부다. | |
| 72 | + } | |
| 73 | + | |
| 74 | + &--information { | |
| 75 | + color: fox.color(font-system-information); | |
| 76 | + } | |
| 77 | + | |
| 78 | + &--information &__icon { | |
| 79 | + color: fox.color(icon-system-information-strong); | |
| 80 | + } | |
| 81 | + | |
| 82 | + &--success { | |
| 83 | + color: fox.color(font-system-success); | |
| 84 | + } | |
| 85 | + | |
| 86 | + &--success &__icon { | |
| 87 | + color: fox.color(icon-system-success-strong); | |
| 88 | + } | |
| 89 | + | |
| 90 | + &--warning { | |
| 91 | + color: fox.color(font-system-warning); | |
| 92 | + } | |
| 93 | + | |
| 94 | + &--warning &__icon { | |
| 95 | + color: fox.color(icon-system-warning-strong); | |
| 96 | + } | |
| 97 | + | |
| 98 | + &--danger { | |
| 99 | + color: fox.color(font-system-danger); | |
| 100 | + } | |
| 101 | + | |
| 102 | + &--danger &__icon { | |
| 103 | + color: fox.color(icon-system-danger-strong); | |
| 104 | + } | |
| 105 | +} |
--- @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-search.scss
+++ @fox/styles/_fox-list-search.scss
... | ... | @@ -64,12 +64,22 @@ |
| 64 | 64 |
block-size: 3.2rem; |
| 65 | 65 |
padding: 0; |
| 66 | 66 |
border: none; |
| 67 |
- border-radius: fox.form(radius-xsm); |
|
| 68 |
- background: none; |
|
| 69 |
- color: fox.color(icon-neutral-default); |
|
| 67 |
+ // 시안은 secondary 계열 버튼이다 — 옆의 "엑셀 다운로드"와 같은 색을 쓴다. |
|
| 68 |
+ border-radius: fox.form(radius-md); |
|
| 69 |
+ background: fox.color(button-secondary-surface); |
|
| 70 |
+ color: fox.color(button-secondary-font); |
|
| 70 | 71 |
cursor: pointer; |
| 71 | 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 |
+ |
|
| 72 | 81 |
&:disabled {
|
| 82 |
+ background: fox.color(button-disabled-surface); |
|
| 73 | 83 |
color: fox.color(font-neutral-disabled-strong); |
| 74 | 84 |
cursor: default; |
| 75 | 85 |
} |
+++ @fox/styles/_fox-popover.scss
... | ... | @@ -0,0 +1,195 @@ |
| 1 | +// FoxPopover — 시안: 통합관리자페이지 디자인시스템 Figma popover | |
| 2 | +// | |
| 3 | +// 꼬리가 달린 말풍선 판이다. 제목·본문·글자 버튼을 담고 오른쪽 위에 닫기가 붙는다. | |
| 4 | +// | |
| 5 | +// 마크업 계약 (React 밖 소비자용): | |
| 6 | +// <div class="fox-popover fox-popover--top fox-popover--arrow-left"> | |
| 7 | +// <div class="fox-popover__arrow-area"> <!-- top이면 판 위, bottom이면 아래 --> | |
| 8 | +// <span class="fox-popover__arrow"></span> <!-- 비어 있다. 그림은 CSS가 그린다 --> | |
| 9 | +// </div> | |
| 10 | +// <div class="fox-popover__content"> | |
| 11 | +// <div class="fox-popover__body"> | |
| 12 | +// <div class="fox-popover__title-row"> | |
| 13 | +// <strong class="fox-popover__title">제목</strong> | |
| 14 | +// </div> | |
| 15 | +// <div class="fox-popover__message-row"> | |
| 16 | +// <span class="fox-popover__message">본문</span> | |
| 17 | +// </div> | |
| 18 | +// </div> | |
| 19 | +// <button class="fox-text-button … fox-popover__action">바로가기</button> <!-- 선택 --> | |
| 20 | +// <button class="fox-popover__close" aria-label="닫기">…X svg…</button> | |
| 21 | +// </div> | |
| 22 | +// </div> | |
| 23 | +// | |
| 24 | +// `--top`은 꼬리가 판 **위**에 붙고(트리거 아래에 뜨는 말풍선), `--bottom`은 아래에 붙는다. | |
| 25 | +// 마크업 순서가 그것을 정한다 — `order`나 `column-reverse`를 쓰지 않는다. | |
| 26 | +// | |
| 27 | +// **자리를 스스로 잡지 않는다.** 트리거 옆 어디에 놓을지는 호출부가 `className`으로 준다. | |
| 28 | +// 자리가 모자랄 때 위아래를 뒤집는 자동 배치도 없다 — 시안이 방향을 값으로 준다. | |
| 29 | +// | |
| 30 | +// ⚠️ 띄우는 방식이 `fox-select-option`과 같다. 감싼 요소 기준의 일반 흐름이라 | |
| 31 | +// `overflow: hidden` 조상 안에서는 잘린다. HTML `popover` 속성으로 top layer에 올리는 방법이 | |
| 32 | +// 있지만 위치를 잡아 주는 CSS Anchor Positioning이 아직 크롬 계열에만 있어 미룬다 — 옮기게 | |
| 33 | +// 되면 셀렉트·칩·툴팁까지 한 번에 옮긴다. | |
| 34 | +// | |
| 35 | +// 꼬리는 **CSS가 그린다**(SVG를 쓰지 않는다). 그림과 방향·위치 규칙은 `_bubble.scss`의 | |
| 36 | +// `arrow()` 믹스인이 갖는다 — `FoxTooltipRich`와 같은 그림이라 한곳에 둔다. 왜 시안 SVG를 | |
| 37 | +// 쓰지 않는지도 그 파일 상단에 적혀 있다. | |
| 38 | +// | |
| 39 | +// 토큰이 아닌 값: | |
| 40 | +// - ⚠️ `inline-size: 36rem`(360px): 시안 값 그대로다. 토큰 스케일에 360이 없다. 폭을 바꿔야 | |
| 41 | +// 하는 자리는 호출부가 `className`으로 덮는다(`fox-snackbar`·`fox-alert`와 같은 처리). | |
| 42 | +// - box-sizing / display / flex / position: 구조 규칙. | |
| 43 | +// | |
| 44 | +// 꼬리의 가로 위치는 **루트의 `align-items`**가 정한다(시안 지정). 꼬리 영역이 좌우 24px | |
| 45 | +// 여백을 달고 다니므로, 왼쪽·오른쪽에서는 그만큼 안쪽에 서고 가운데에서는 여백이 대칭이라 | |
| 46 | +// 정확히 가운데에 선다. | |
| 47 | + | |
| 48 | +@use "@fox/styles/abstracts" as fox; | |
| 49 | +@use "@fox/styles/bubble" as bubble; | |
| 50 | + | |
| 51 | +.fox-popover { | |
| 52 | + @include bubble.root; | |
| 53 | + | |
| 54 | + // 시안의 neutral/lv2 — 가까운 그림자와 먼 그림자 두 겹이다. | |
| 55 | + // | |
| 56 | + // ⚠️ `box-shadow`가 아니라 `drop-shadow`다. box-shadow는 요소의 **사각형 박스**를 따라 | |
| 57 | + // 그려지므로, 꼬리 옆 빈 자리까지 포함한 네모 그림자가 나온다(시안은 Figma 프레임이 곧 | |
| 58 | + // 모양이라 그 차이가 없다). drop-shadow는 **실제로 칠해진 모양**을 따라가므로 판과 꼬리가 | |
| 59 | + // 하나의 말풍선으로 그림자를 진다. | |
| 60 | + // | |
| 61 | + // 겹쳐 쓰면 뒤의 그림자가 앞 그림자까지 다시 흐리게 하지만, 두 겹 다 10% 알파라 눈에 | |
| 62 | + // 띄는 차이는 없다. | |
| 63 | + filter: | |
| 64 | + drop-shadow( | |
| 65 | + #{fox.shadow(location1)} #{fox.shadow(location1)} #{fox.shadow(blur2)} | |
| 66 | + #{fox.primitive(alpha-black10)} | |
| 67 | + ) | |
| 68 | + drop-shadow( | |
| 69 | + #{fox.shadow(location3)} #{fox.shadow(location3)} #{fox.shadow(blur3)} | |
| 70 | + #{fox.primitive(alpha-black10)} | |
| 71 | + ); | |
| 72 | + | |
| 73 | + | |
| 74 | + // ── 판 ────────────────────────────────────────────────────────────────────── | |
| 75 | + &__content { | |
| 76 | + // 닫기 버튼의 위치 기준이다. | |
| 77 | + position: relative; | |
| 78 | + box-sizing: border-box; | |
| 79 | + display: flex; | |
| 80 | + flex-direction: column; | |
| 81 | + align-items: flex-start; | |
| 82 | + gap: fox.gap(4); | |
| 83 | + align-self: stretch; | |
| 84 | + padding: fox.padding(8); | |
| 85 | + border: fox.border(1) solid fox.color(border-neutral-strong); | |
| 86 | + border-radius: fox.radius(5); | |
| 87 | + background: fox.color(surface-neutral-default); | |
| 88 | + } | |
| 89 | + | |
| 90 | + // ── 꼬리 ──────────────────────────────────────────────────────────────────── | |
| 91 | + // 좌우 여백이 꼬리를 판 가장자리에서 띄운다. **늘리지 않는다** — 폭이 내용(22px + 여백 | |
| 92 | + // 48px)만큼이라야 루트의 `align-items`가 이 덩어리를 왼쪽·가운데·오른쪽으로 옮길 수 있다. | |
| 93 | + // | |
| 94 | + // 판보다 **위에 칠해진다**(시안 구조). 꼬리가 판 쪽으로 1px 파고들고, 꼬리 안의 흰 띠가 그 | |
| 95 | + // 자리의 판 테두리를 지워 말풍선 안팎이 하나로 이어진다. 쌓임 순서를 올리지 않으면 | |
| 96 | + // `--top`에서 판이 나중에 칠해져 그 선이 되살아난다. | |
| 97 | + &__arrow-area { | |
| 98 | + position: relative; | |
| 99 | + z-index: 1; | |
| 100 | + box-sizing: border-box; | |
| 101 | + display: flex; | |
| 102 | + flex-direction: column; | |
| 103 | + align-items: flex-start; | |
| 104 | + padding-inline: fox.padding(8); | |
| 105 | + } | |
| 106 | + | |
| 107 | + // ── 글자 ──────────────────────────────────────────────────────────────────── | |
| 108 | + &__body { | |
| 109 | + display: flex; | |
| 110 | + flex-direction: column; | |
| 111 | + align-items: flex-start; | |
| 112 | + align-self: stretch; | |
| 113 | + } | |
| 114 | + | |
| 115 | + // 오른쪽 여백은 닫기 버튼 자리다 — 제목이 그 밑으로 들어가지 않게 비워 둔다. | |
| 116 | + &__title-row { | |
| 117 | + display: flex; | |
| 118 | + align-items: center; | |
| 119 | + gap: fox.gap(5); | |
| 120 | + align-self: stretch; | |
| 121 | + padding-inline-end: fox.padding(9); | |
| 122 | + } | |
| 123 | + | |
| 124 | + &__title { | |
| 125 | + flex: 1 0 0; | |
| 126 | + min-inline-size: 0; | |
| 127 | + color: fox.color(font-neutral-strong); | |
| 128 | + font-family: fox.font-family(title); | |
| 129 | + font-size: fox.font-size(heading-xsm); | |
| 130 | + font-weight: fox.font-weight(bold); | |
| 131 | + line-height: 1.4; | |
| 132 | + letter-spacing: -0.025em; | |
| 133 | + } | |
| 134 | + | |
| 135 | + // 제목과 본문 사이 간격을 본문의 윗여백이 맡는다(시안이 그렇게 준다) — 제목만 있을 때 | |
| 136 | + // 아래로 빈자리가 남지 않는다. | |
| 137 | + &__message-row { | |
| 138 | + display: flex; | |
| 139 | + align-items: flex-start; | |
| 140 | + align-self: stretch; | |
| 141 | + padding-block-start: fox.padding(6); | |
| 142 | + } | |
| 143 | + | |
| 144 | + &__message { | |
| 145 | + flex: 1 0 0; | |
| 146 | + min-inline-size: 0; | |
| 147 | + color: fox.color(font-neutral-default); | |
| 148 | + font-family: fox.font-family(body); | |
| 149 | + font-size: fox.font-size(body-sm); | |
| 150 | + font-weight: fox.font-weight(regular); | |
| 151 | + line-height: 1.5; | |
| 152 | + letter-spacing: -0.025em; | |
| 153 | + } | |
| 154 | + | |
| 155 | + // 글자 버튼은 자기 모양을 그대로 갖는다 — 여기서는 줄어들지만 않게 막는다. | |
| 156 | + &__action { | |
| 157 | + flex-shrink: 0; | |
| 158 | + } | |
| 159 | + | |
| 160 | + // ── 닫기 ──────────────────────────────────────────────────────────────────── | |
| 161 | + // 판 오른쪽 위 모서리에 붙는다. 누르는 자리는 40px 정사각이고 아이콘은 16px이다. | |
| 162 | + // | |
| 163 | + // ⚠️ 시안이 `align-items: flex-end`를 준다 — 가로는 시작(왼쪽), 세로는 끝(아래)이라 | |
| 164 | + // 아이콘이 40px 칸의 왼쪽 아래로 치우친다(`fox-snackbar`의 닫기와 같다). | |
| 165 | + &__close { | |
| 166 | + position: absolute; | |
| 167 | + inset-block-start: 0; | |
| 168 | + inset-inline-end: 0; | |
| 169 | + box-sizing: border-box; | |
| 170 | + display: flex; | |
| 171 | + align-items: flex-end; | |
| 172 | + inline-size: fox.number(14); | |
| 173 | + block-size: fox.number(14); | |
| 174 | + aspect-ratio: 1 / 1; | |
| 175 | + padding: fox.padding(2); | |
| 176 | + border: none; | |
| 177 | + background: none; | |
| 178 | + color: fox.color(icon-neutral-default); | |
| 179 | + cursor: pointer; | |
| 180 | + } | |
| 181 | + | |
| 182 | + &__close > * { | |
| 183 | + flex-shrink: 0; | |
| 184 | + inline-size: fox.icon(3); | |
| 185 | + block-size: fox.icon(3); | |
| 186 | + aspect-ratio: 1 / 1; | |
| 187 | + } | |
| 188 | +} | |
| 189 | + | |
| 190 | +// 꼬리. 판과 같은 색을 넘긴다 — 꼬리는 판이 뾰족하게 튀어나온 부분이다. | |
| 191 | +@include bubble.arrow( | |
| 192 | + "fox-popover", | |
| 193 | + $border: fox.color(border-neutral-strong), | |
| 194 | + $fill: fox.color(surface-neutral-default) | |
| 195 | +); |
+++ @fox/styles/_fox-snackbar-list.scss
... | ... | @@ -0,0 +1,38 @@ |
| 1 | +// FoxSnackbarList — 시안: 통합관리자페이지 디자인시스템 Figma snackbar-list | |
| 2 | +// | |
| 3 | +// 스낵바가 여러 개일 때 세로로 쌓는 묶음이다. 배치만 갖는다 — 무엇이 몇 개 떠 있는지는 | |
| 4 | +// 호출부가 소유한다. | |
| 5 | +// | |
| 6 | +// 마크업 계약 (React 밖 소비자용): | |
| 7 | +// <div class="fox-snackbar-list" role="group"> | |
| 8 | +// …fox-snackbar… | |
| 9 | +// …fox-snackbar… | |
| 10 | +// </div> | |
| 11 | +// | |
| 12 | +// 자식 폭과 묶음 폭이 같은 640px이다 — 묶음이 자식을 늘이거나 줄이지 않고, `align-items`가 | |
| 13 | +// `flex-start`라 자식이 자기 폭을 그대로 갖는다(시안 지정). | |
| 14 | +// | |
| 15 | +// 토큰이 아닌 값: | |
| 16 | +// - ⚠️ `inline-size: 64rem`: **시안의 폭(640px)을 그대로 적용해 둔 것이다.** 자식 | |
| 17 | +// `fox-snackbar`와 같은 값이고 근거도 같다 — 토큰 스케일에 640이 없다. 폭을 바꿔야 하는 | |
| 18 | +// 자리는 호출부가 `className`으로 덮는다. | |
| 19 | +// - display / flex-direction / align-items: 구조 규칙. | |
| 20 | +// | |
| 21 | +// ⚠️ 모바일에서는 자식과 같은 이유로 고정 폭을 푼다 — 640px을 그대로 두면 화면 밖으로 넘쳐 | |
| 22 | +// 잘린다(`fox-snackbar` 상단 주석 참고). | |
| 23 | + | |
| 24 | +@use "@fox/styles/abstracts" as fox; | |
| 25 | + | |
| 26 | +.fox-snackbar-list { | |
| 27 | + box-sizing: border-box; | |
| 28 | + display: flex; | |
| 29 | + flex-direction: column; | |
| 30 | + align-items: flex-start; | |
| 31 | + gap: fox.gap(3); | |
| 32 | + inline-size: 64rem; | |
| 33 | + | |
| 34 | + // 모바일에서는 부모 폭을 채운다. 자식(`fox-snackbar`)도 같은 규칙을 스스로 갖는다. | |
| 35 | + @include fox.mobile { | |
| 36 | + inline-size: 100%; | |
| 37 | + } | |
| 38 | +} |
+++ @fox/styles/_fox-snackbar.scss
... | ... | @@ -0,0 +1,181 @@ |
| 1 | +// FoxSnackbar — 시안: 통합관리자페이지 디자인시스템 Figma snackbar | |
| 2 | +// | |
| 3 | +// 흰 판 위에 글자(제목·본문)와 글자 버튼이 서고, 오른쪽 위에 닫기가 붙는다. 제목과 본문은 | |
| 4 | +// 각각 없을 수 있다 — 한쪽만 있으면 그 줄만 그린다. `FoxToast`와 형제지만 | |
| 5 | +// **누를 것이 있다** — 그래서 스스로 사라지지 않고 손이 닿을 때까지 남는다. | |
| 6 | +// | |
| 7 | +// 마크업 계약 (React 밖 소비자용): | |
| 8 | +// <div class="fox-snackbar" role="status"> | |
| 9 | +// <span class="fox-snackbar__icon">…아이콘 svg…</span> <!-- 선택 --> | |
| 10 | +// <div class="fox-snackbar__content"> | |
| 11 | +// <strong class="fox-snackbar__title">저장했습니다</strong> <!-- 선택 --> | |
| 12 | +// <span class="fox-snackbar__message">3분 안에 되돌릴 수 있습니다.</span> <!-- 선택 --> | |
| 13 | +// </div> | |
| 14 | +// <button class="fox-text-button fox-text-button--primary fox-text-button--md | |
| 15 | +// fox-snackbar__action">실행 취소</button> <!-- 선택 --> | |
| 16 | +// <button class="fox-snackbar__close" aria-label="닫기">…X svg…</button> <!-- 선택 --> | |
| 17 | +// </div> | |
| 18 | +// | |
| 19 | +// 글자 버튼은 `FoxTextButton`의 primary·md를 그대로 쓴다 — 스낵바가 버튼 모양을 다시 정하지 | |
| 20 | +// 않고 `fox-snackbar__action`은 배치만 맡는다. | |
| 21 | +// | |
| 22 | +// 토큰이 아닌 값: | |
| 23 | +// - ⚠️ `inline-size` / `min-inline-size: 64rem`: **시안의 폭(640px)을 그대로 적용해 둔 | |
| 24 | +// 것이다.** Figma가 변수로 export하지 않았고 토큰 스케일에도 640이 없다. 폭을 바꿔야 하는 | |
| 25 | +// 자리는 호출부가 `className`으로 덮는다(`fox-toggle-quantity`·`fox-alert`와 같은 처리). | |
| 26 | +// - ⚠️ `padding-inline-end`가 64px이라 토큰이 없다. 닫기가 `right: 0`으로 오른쪽 끝에 | |
| 27 | +// 붙으므로(시안 지정) 이 여백은 **글자·버튼이 닫기 밑으로 들어가지 않게 비워 두는 | |
| 28 | +// 자리**다. 20px + 아이콘 24px + 20px = 64px로 떨어져서 그 조합으로 적는다 | |
| 29 | +// (`padding(7)` + `icon(5)` + `padding(7)`). | |
| 30 | +// - line-height / letter-spacing: 시안 텍스트 스타일 heading/xsm의 값이나 Figma가 변수로 | |
| 31 | +// export하지 않는다. -0.375px은 15px 기준이라 `-0.025em`으로 적는다(다른 컴포넌트와 같은 | |
| 32 | +// 근거). | |
| 33 | +// - box-sizing / position / display / flex: 구조 규칙. | |
| 34 | +// | |
| 35 | +// ⚠️ 그림자 색: 시안이 두 겹을 각각 `dark/alpha/black10`·`alpha/black10`으로 물려 놓았는데 | |
| 36 | +// 값이 같고(rgba(0,0,0,0.10)) 앞쪽 이름의 토큰은 export에 없다. 두 겹 모두 `alpha-black10`을 | |
| 37 | +// 쓴다 — `fox-toast`·`fox-select-option`과 같은 선택이다. | |
| 38 | +// | |
| 39 | +// ⚠️ 폭 세 값(640 / 640 / `grid(wrap-sm)`)은 **PC 기준이다.** 모바일에서 `grid(wrap-sm)`은 | |
| 40 | +// 320px인데 최소 폭 640px이 그것을 이겨 화면 밖으로 넘친다 — 실제로 잘렸다. 그래서 모바일에서는 | |
| 41 | +// 최소 폭을 풀고 판이 부모 폭을 채우게 둔다(아래 `fox.mobile` 블록). | |
| 42 | +// | |
| 43 | +// 시안이 모바일 스낵바의 폭을 따로 주면 그 값으로 바꾼다. 지금은 "화면을 넘지 않는다"만 | |
| 44 | +// 보장한다. | |
| 45 | + | |
| 46 | +@use "@fox/styles/abstracts" as fox; | |
| 47 | + | |
| 48 | +.fox-snackbar { | |
| 49 | + // 안쪽 여백이 바깥 폭을 늘리지 않게 한다 — 640px이 640px로 그려진다. | |
| 50 | + box-sizing: border-box; | |
| 51 | + // 닫기 버튼의 위치 기준이 된다. | |
| 52 | + position: relative; | |
| 53 | + display: flex; | |
| 54 | + align-items: center; | |
| 55 | + gap: fox.gap(5); | |
| 56 | + inline-size: 64rem; | |
| 57 | + min-inline-size: 64rem; | |
| 58 | + max-inline-size: fox.grid(wrap-sm); | |
| 59 | + padding-block: fox.padding(6); | |
| 60 | + padding-inline-start: fox.padding(7); | |
| 61 | + // 닫기 버튼이 앉을 자리(위 주석 참고). | |
| 62 | + padding-inline-end: calc(#{fox.padding(7)} + #{fox.icon(5)} + #{fox.padding(7)}); | |
| 63 | + border: fox.border(1) solid fox.color(border-neutral-subtle); | |
| 64 | + border-radius: fox.radius(5); | |
| 65 | + background: fox.color(surface-neutral-default); | |
| 66 | + | |
| 67 | + // 시안의 neutral/lv2 — 가까운 그림자와 먼 그림자 두 겹이다. | |
| 68 | + box-shadow: | |
| 69 | + fox.shadow(location1) fox.shadow(location1) fox.shadow(blur2) 0 | |
| 70 | + fox.primitive(alpha-black10), | |
| 71 | + fox.shadow(location3) fox.shadow(location3) fox.shadow(blur3) 0 | |
| 72 | + fox.primitive(alpha-black10); | |
| 73 | + | |
| 74 | + // 모바일에서는 640px 고정을 풀고 부모 폭을 채운다(위 주석 참고). `min-inline-size`를 함께 | |
| 75 | + // 풀지 않으면 640px이 그대로 남아 화면 밖으로 밀려 잘린다. | |
| 76 | + @include fox.mobile { | |
| 77 | + inline-size: 100%; | |
| 78 | + min-inline-size: 0; | |
| 79 | + max-inline-size: 100%; | |
| 80 | + } | |
| 81 | + | |
| 82 | + // 글자 앞 아이콘. 글자가 길어 판이 높아져도 크기가 그대로여야 해서 줄어들지 않게 막는다. | |
| 83 | + // | |
| 84 | + // 색은 `icon-theme-primary`다(시안 확인). 글리프가 `fill: currentColor`라 슬롯에 색만 | |
| 85 | + // 주면 그대로 입는다 — 굵기는 호출부가 넘기는 글리프가 정하고 시안은 `fill`이다. | |
| 86 | + // | |
| 87 | + // 크기는 시안 값 그대로 24px(`icon(5)`)이고 닫기 아이콘과 같다. 세로 정렬은 시안에 없어 | |
| 88 | + // 판 규칙(가운데)을 그대로 따른다. | |
| 89 | + &__icon { | |
| 90 | + display: inline-flex; | |
| 91 | + flex-shrink: 0; | |
| 92 | + align-items: center; | |
| 93 | + justify-content: center; | |
| 94 | + inline-size: fox.icon(5); | |
| 95 | + block-size: fox.icon(5); | |
| 96 | + aspect-ratio: 1 / 1; | |
| 97 | + color: fox.color(icon-theme-primary); | |
| 98 | + } | |
| 99 | + | |
| 100 | + // 넘긴 SVG가 슬롯을 꽉 채운다 — 크기는 슬롯이 정하고 SVG는 따라온다. | |
| 101 | + &__icon > * { | |
| 102 | + inline-size: 100%; | |
| 103 | + block-size: 100%; | |
| 104 | + } | |
| 105 | + | |
| 106 | + // 남는 폭을 전부 가져가 글자 버튼을 오른쪽 끝으로 민다. `min-inline-size: 0`이 없으면 | |
| 107 | + // 긴 글이 줄바꿈되지 않고 판을 밀어 넓힌다(flex 자식의 기본 최소 크기가 내용 크기라서다). | |
| 108 | + // | |
| 109 | + // `flex: 1 0 0`으로 남는 폭을 전부 가져가 글자 버튼을 오른쪽 끝으로 민다. 제목과 본문 | |
| 110 | + // 사이는 `gap(1)`(2px)이고 둘 다 왼쪽에 붙는다 — 시안 확인 완료(`FoxAlert`의 같은 자리와 | |
| 111 | + // 같은 값이다). | |
| 112 | + &__content { | |
| 113 | + display: flex; | |
| 114 | + flex: 1 0 0; | |
| 115 | + flex-direction: column; | |
| 116 | + align-items: flex-start; | |
| 117 | + gap: fox.gap(1); | |
| 118 | + min-inline-size: 0; | |
| 119 | + } | |
| 120 | + | |
| 121 | + &__title { | |
| 122 | + align-self: stretch; | |
| 123 | + color: fox.color(font-neutral-strong); | |
| 124 | + font-family: fox.font-family(title); | |
| 125 | + font-size: fox.font-size(heading-xsm); | |
| 126 | + font-weight: fox.font-weight(bold); | |
| 127 | + line-height: 1.4; | |
| 128 | + letter-spacing: -0.025em; | |
| 129 | + } | |
| 130 | + | |
| 131 | + &__message { | |
| 132 | + align-self: stretch; | |
| 133 | + color: fox.color(font-neutral-strong); | |
| 134 | + font-family: fox.font-family(body); | |
| 135 | + font-size: fox.font-size(body-md); | |
| 136 | + font-weight: fox.font-weight(regular); | |
| 137 | + line-height: 1.5; | |
| 138 | + letter-spacing: -0.025em; | |
| 139 | + } | |
| 140 | + | |
| 141 | + // 글자 버튼은 자기 모양을 그대로 갖는다 — 여기서는 줄어들지만 않게 막는다. | |
| 142 | + &__action { | |
| 143 | + flex-shrink: 0; | |
| 144 | + } | |
| 145 | + | |
| 146 | + // 오른쪽 끝에 붙는다(시안 지정 `right: 0`). 흐름에서 빠져 있어 글자·버튼 배치에 끼어들지 | |
| 147 | + // 않고, 판의 오른쪽 여백(64px)이 그 아래로 글자가 들어가지 않게 막아 준다. | |
| 148 | + // | |
| 149 | + // 누르는 자리는 48px 정사각이고 아이콘은 24px이다 — 손가락이 닿는 넓이를 아이콘보다 크게 | |
| 150 | + // 잡은 것이라, 여백을 줄이면 모바일에서 누르기 어려워진다. | |
| 151 | + // | |
| 152 | + // ⚠️ 시안이 이 칸에 `align-items: flex-end`를 준다. 가로로는 시작(왼쪽), 세로로는 끝(아래)에 | |
| 153 | + // 붙어서 **아이콘이 48px 칸의 왼쪽 아래로 치우친다** — 오른쪽 끝에서 16px, 아래에서 8px이다. | |
| 154 | + // 시안 값을 그대로 옮긴 결과이니, 화면에서 가운데로 보여야 하면 `center`로 바꾼다. | |
| 155 | + // | |
| 156 | + // 세로는 판 위쪽에 붙는다(시안 지정 `top: 0`). 가운데 정렬이 아니라서, 본문이 늘어 판이 | |
| 157 | + // 높아져도 닫기는 첫 줄 옆에 그대로 남는다. | |
| 158 | + &__close { | |
| 159 | + position: absolute; | |
| 160 | + inset-inline-end: 0; | |
| 161 | + inset-block-start: 0; | |
| 162 | + display: flex; | |
| 163 | + align-items: flex-end; | |
| 164 | + inline-size: fox.number(16); | |
| 165 | + block-size: fox.number(16); | |
| 166 | + aspect-ratio: 1 / 1; | |
| 167 | + padding: fox.padding(3); | |
| 168 | + border: none; | |
| 169 | + background: none; | |
| 170 | + color: fox.color(icon-neutral-default); | |
| 171 | + cursor: pointer; | |
| 172 | + } | |
| 173 | + | |
| 174 | + // 아이콘은 24px 정사각으로 못박는다 — 칸이 48px이라 늘어나게 두면 안 된다. | |
| 175 | + &__close > * { | |
| 176 | + flex-shrink: 0; | |
| 177 | + inline-size: fox.icon(5); | |
| 178 | + block-size: fox.icon(5); | |
| 179 | + aspect-ratio: 1 / 1; | |
| 180 | + } | |
| 181 | +} |
+++ @fox/styles/_fox-status-indicator.scss
... | ... | @@ -0,0 +1,88 @@ |
| 1 | +// FoxStatusIndicator — 시안: 통합관리자페이지 디자인시스템 Figma status-indicator | |
| 2 | +// | |
| 3 | +// 작은 점 하나와 글자로 상태를 알린다. 크기 수식어가 없다 — 시안에 하나뿐이다. | |
| 4 | +// | |
| 5 | +// 마크업 계약 (React 밖 소비자용): | |
| 6 | +// <span class="fox-status-indicator fox-status-indicator--success"> | |
| 7 | +// <span class="fox-status-indicator__dot"></span> | |
| 8 | +// <span class="fox-status-indicator__label">완료</span> | |
| 9 | +// </span> | |
| 10 | +// | |
| 11 | +// 점은 비어 있는 요소다 — 배경색만으로 그린다. 뜻은 글자가 담고 점은 장식이라 | |
| 12 | +// 마크업에서 `aria-hidden`이 붙는다. | |
| 13 | +// | |
| 14 | +// 계열은 **점의 배경과 글자색을 각각 다른 토큰으로** 바꾼다. 점은 `element-*`(칠하는 면), | |
| 15 | +// 글자는 `font-*`(읽는 글자)다 — 같은 계열이라도 명도가 달라 시안이 둘을 나눠 준다. | |
| 16 | +// 기본은 점이 `element-neutral-default`(회색 면), 글자가 `font-neutral-default`다. | |
| 17 | +// | |
| 18 | +// 토큰이 아닌 값: | |
| 19 | +// - line-height / letter-spacing: 시안 텍스트 스타일 label/md의 값이나 Figma가 변수로 | |
| 20 | +// export하지 않는다. 시안의 -0.375px은 15px 기준이라 `-0.025em`으로 적는다 | |
| 21 | +// (다른 컴포넌트와 같은 근거). line-height는 시안이 100%라 `1`이다 — 점과 글자가 한 줄에 | |
| 22 | +// 서는 조각이라 줄 간격을 두지 않는다. | |
| 23 | +// - display / align-items / aspect-ratio / flex-shrink: 구조 규칙. | |
| 24 | + | |
| 25 | +@use "@fox/styles/abstracts" as fox; | |
| 26 | + | |
| 27 | +.fox-status-indicator { | |
| 28 | + display: inline-flex; | |
| 29 | + align-items: center; | |
| 30 | + gap: fox.gap(2); | |
| 31 | + | |
| 32 | + color: fox.color(font-neutral-default); | |
| 33 | + font-family: fox.font-family(body); | |
| 34 | + font-size: fox.font-size(label-md); | |
| 35 | + font-weight: fox.font-weight(regular); | |
| 36 | + line-height: 1; | |
| 37 | + letter-spacing: -0.025em; | |
| 38 | + | |
| 39 | + // 6px 정원. 글자가 길어 줄이 좁아져도 찌그러지지 않게 크기를 못박는다. | |
| 40 | + &__dot { | |
| 41 | + flex-shrink: 0; | |
| 42 | + inline-size: fox.number(4); | |
| 43 | + block-size: fox.number(4); | |
| 44 | + aspect-ratio: 1 / 1; | |
| 45 | + border-radius: fox.radius(max); | |
| 46 | + background: fox.color(element-neutral-default); | |
| 47 | + } | |
| 48 | + | |
| 49 | + // ── 계열 ──────────────────────────────────────────────────────────────────── | |
| 50 | + // 글자색은 블록에서 상속되므로 루트에 주고, 점은 자기 배경을 따로 받는다. | |
| 51 | + // | |
| 52 | + // `default`는 블록 규칙이 곧 그 계열의 모양이라 따로 덮을 것이 없다. | |
| 53 | + &--default { | |
| 54 | + // 시안 그대로 — 블록에 있는 값이 전부다. | |
| 55 | + } | |
| 56 | + | |
| 57 | + &--information { | |
| 58 | + color: fox.color(font-system-information); | |
| 59 | + } | |
| 60 | + | |
| 61 | + &--information &__dot { | |
| 62 | + background: fox.color(element-system-information-strong); | |
| 63 | + } | |
| 64 | + | |
| 65 | + &--success { | |
| 66 | + color: fox.color(font-system-success); | |
| 67 | + } | |
| 68 | + | |
| 69 | + &--success &__dot { | |
| 70 | + background: fox.color(element-system-success-strong); | |
| 71 | + } | |
| 72 | + | |
| 73 | + &--warning { | |
| 74 | + color: fox.color(font-system-warning); | |
| 75 | + } | |
| 76 | + | |
| 77 | + &--warning &__dot { | |
| 78 | + background: fox.color(element-system-warning-strong); | |
| 79 | + } | |
| 80 | + | |
| 81 | + &--danger { | |
| 82 | + color: fox.color(font-system-danger); | |
| 83 | + } | |
| 84 | + | |
| 85 | + &--danger &__dot { | |
| 86 | + background: fox.color(element-system-danger-strong); | |
| 87 | + } | |
| 88 | +} |
+++ @fox/styles/_fox-step-indicator-item.scss
... | ... | @@ -0,0 +1,168 @@ |
| 1 | +// FoxStepIndicatorItem — 시안: 통합관리자페이지 디자인시스템 Figma step | |
| 2 | +// | |
| 3 | +// 단계 하나. 위에 동그라미와 선(인디케이터), 아래에 단계 번호와 제목이 놓인다. | |
| 4 | +// | |
| 5 | +// 마크업 계약 (React 밖 소비자용): | |
| 6 | +// <div class="fox-step-indicator-item fox-step-indicator-item--ongoing"> | |
| 7 | +// <div class="fox-step-indicator-item__indicator" aria-hidden="true"> | |
| 8 | +// <span class="fox-step-indicator-item__mark"><span class="fox-step-indicator-item__dot"></span></span> | |
| 9 | +// <span class="fox-step-indicator-item__line"></span> <!-- 마지막 단계는 뺀다 --> | |
| 10 | +// </div> | |
| 11 | +// <div class="fox-step-indicator-item__text"> <!-- 선택 --> | |
| 12 | +// <span class="fox-step-indicator-item__step">STEP 01</span> | |
| 13 | +// <span class="fox-step-indicator-item__title">약관 동의</span> | |
| 14 | +// </div> | |
| 15 | +// </div> | |
| 16 | +// | |
| 17 | +// 상태별로 동그라미 안이 다르다: completion은 체크 글리프, ongoing은 `__dot`, before는 비었다. | |
| 18 | +// 상태 클래스가 색·테두리를 정하므로 마크업은 그대로 두고 클래스만 바꾸면 된다. | |
| 19 | +// | |
| 20 | +// **모바일에서는 `__text`가 사라진다.** 시안의 device 변형을 CSS 브레이크포인트로 옮긴 | |
| 21 | +// 것이다 — 조건부 렌더로 하면 창 폭이 바뀔 때마다 DOM이 들락거리고, React를 쓰지 않는 | |
| 22 | +// 소비자는 같은 결과를 얻지 못한다(`fox-chip-select-option`의 확인 버튼과 같은 근거). | |
| 23 | +// | |
| 24 | +// 동그라미 안쪽 여백을 시안대로 적지 않는다. 시안은 `padding: 6px 5px`로 20px 안에 10×8 | |
| 25 | +// 글리프를 밀어 넣는데, 그 값은 **가운데 정렬을 여백으로 계산한 것**이라(20-5-5=10, | |
| 26 | +// 20-6-6=8) `justify-content`·`align-items`로 바꿔 적는다. 결과는 같고 5px·6px이라는 | |
| 27 | +// 토큰 없는 값이 사라진다. | |
| 28 | +// | |
| 29 | +// 토큰이 아닌 값: | |
| 30 | +// - ⚠️ `inline-size: 6.1rem`(선 61px): 시안 값 그대로다. 토큰 스케일에 61이 없고, 동그라미 | |
| 31 | +// (20px)와 합치면 81px이라 단계 폭(80px)보다 1px 길다 — 다음 동그라미까지 틈 없이 | |
| 32 | +// 닿게 하려고 겹치는 값으로 보인다. 단계 폭을 바꾸면 이 값도 함께 봐야 한다. | |
| 33 | +// - ⚠️ `block-size: 0.15rem`(선 굵기 1.5px) / `border-width: 0.15rem`(ongoing 테두리): | |
| 34 | +// 시안 값이나 border 토큰은 1px·2px·4px뿐이라 1.5px이 없다. 토큰이 생기면 바꾼다. | |
| 35 | +// - ⚠️ 체크 글리프의 `10px × 8px`: 시안이 준 비율(5:4)이 다른 아이콘과 달라 `icon()` 스케일에 | |
| 36 | +// 없다. `number()`의 같은 값(10px·8px)으로 지목한다. | |
| 37 | +// - box-sizing / display / flex / aspect-ratio: 구조 규칙. | |
| 38 | +// | |
| 39 | +// ⚠️ 단계 제목의 굵기: 시안 CSS가 `var(--font-weight-regular, 700)`으로 이름과 값이 | |
| 40 | +// 어긋나는데, **값(굵게)이 맞다**(사용자 확인). 700은 이 토큰 세트에 없어서 | |
| 41 | +// (400 regular · 600 medium · 800 bold) 시스템의 `bold`를 쓴다 — Pretendard 기준으로 시안이 | |
| 42 | +// Bold(700)라면 한 단계 더 굵은 ExtraBold(800)로 그려진다. 너무 굵어 보이면 `medium`으로 | |
| 43 | +// 내린다. | |
| 44 | + | |
| 45 | +@use "@fox/styles/abstracts" as fox; | |
| 46 | + | |
| 47 | +.fox-step-indicator-item { | |
| 48 | + box-sizing: border-box; | |
| 49 | + display: flex; | |
| 50 | + flex-direction: column; | |
| 51 | + align-items: flex-start; | |
| 52 | + gap: fox.gap(3); | |
| 53 | + inline-size: fox.number(20); | |
| 54 | + | |
| 55 | + // ── 인디케이터(동그라미 + 선) ─────────────────────────────────────────────── | |
| 56 | + &__indicator { | |
| 57 | + display: flex; | |
| 58 | + align-items: center; | |
| 59 | + } | |
| 60 | + | |
| 61 | + // 20px 정원. 안에 든 것(체크·점)은 상태가 정하고, 여기서는 자리만 만든다. | |
| 62 | + &__mark { | |
| 63 | + box-sizing: border-box; | |
| 64 | + display: flex; | |
| 65 | + flex-shrink: 0; | |
| 66 | + align-items: center; | |
| 67 | + justify-content: center; | |
| 68 | + inline-size: fox.number(9); | |
| 69 | + block-size: fox.number(9); | |
| 70 | + aspect-ratio: 1 / 1; | |
| 71 | + border-radius: fox.radius(max); | |
| 72 | + } | |
| 73 | + | |
| 74 | + // 체크 글리프. 크기는 슬롯이 정하고 SVG는 따라온다. | |
| 75 | + &__mark > svg { | |
| 76 | + inline-size: fox.number(6); | |
| 77 | + block-size: fox.number(5); | |
| 78 | + } | |
| 79 | + | |
| 80 | + // ongoing의 가운데 점. | |
| 81 | + &__dot { | |
| 82 | + inline-size: fox.number(7); | |
| 83 | + block-size: fox.number(7); | |
| 84 | + flex-shrink: 0; | |
| 85 | + aspect-ratio: 1 / 1; | |
| 86 | + border-radius: fox.radius(max); | |
| 87 | + background: fox.color(element-theme-primary); | |
| 88 | + } | |
| 89 | + | |
| 90 | + &__line { | |
| 91 | + flex-shrink: 0; | |
| 92 | + inline-size: 6.1rem; | |
| 93 | + block-size: 0.15rem; | |
| 94 | + } | |
| 95 | + | |
| 96 | + // ── 글자 ──────────────────────────────────────────────────────────────────── | |
| 97 | + &__text { | |
| 98 | + display: flex; | |
| 99 | + flex-direction: column; | |
| 100 | + align-items: flex-start; | |
| 101 | + align-self: stretch; | |
| 102 | + padding-inline-end: fox.padding(3); | |
| 103 | + | |
| 104 | + // 모바일에서는 동그라미와 선만 남는다(파일 상단 주석 참고). | |
| 105 | + @include fox.mobile { | |
| 106 | + display: none; | |
| 107 | + } | |
| 108 | + } | |
| 109 | + | |
| 110 | + &__step { | |
| 111 | + align-self: stretch; | |
| 112 | + color: fox.color(font-neutral-subtle); | |
| 113 | + font-family: fox.font-family(body); | |
| 114 | + font-size: fox.font-size(body-xsm); | |
| 115 | + font-weight: fox.font-weight(regular); | |
| 116 | + line-height: 1.5; | |
| 117 | + letter-spacing: -0.025em; | |
| 118 | + } | |
| 119 | + | |
| 120 | + &__title { | |
| 121 | + align-self: stretch; | |
| 122 | + color: fox.color(font-neutral-default); | |
| 123 | + font-family: fox.font-family(body); | |
| 124 | + font-size: fox.font-size(body-sm); | |
| 125 | + // 시안의 700은 토큰에 없어 시스템의 bold(800)로 올린다 — 파일 상단 주석 참고. | |
| 126 | + font-weight: fox.font-weight(bold); | |
| 127 | + line-height: 1.5; | |
| 128 | + letter-spacing: -0.025em; | |
| 129 | + } | |
| 130 | + | |
| 131 | + // ── 상태 ──────────────────────────────────────────────────────────────────── | |
| 132 | + // 지난 단계. 동그라미가 회색으로 채워지고 체크가 반전색으로 얹힌다. 선도 함께 진하다 — | |
| 133 | + // 여기까지 왔다는 표시다. | |
| 134 | + &--completion { | |
| 135 | + .fox-step-indicator-item__mark { | |
| 136 | + background: fox.color(element-neutral-default); | |
| 137 | + color: fox.color(element-neutral-inverse-static); | |
| 138 | + } | |
| 139 | + | |
| 140 | + .fox-step-indicator-item__line { | |
| 141 | + background: fox.color(element-neutral-default); | |
| 142 | + } | |
| 143 | + } | |
| 144 | + | |
| 145 | + // 지금 단계. 테두리만 강조색이고 안은 비어 있으며, 가운데 점이 그 자리를 채운다. | |
| 146 | + // 선은 앞으로 갈 길이라 흐리다. | |
| 147 | + &--ongoing { | |
| 148 | + .fox-step-indicator-item__mark { | |
| 149 | + border: 0.15rem solid fox.color(element-theme-primary); | |
| 150 | + } | |
| 151 | + | |
| 152 | + .fox-step-indicator-item__line { | |
| 153 | + background: fox.color(element-neutral-disabled); | |
| 154 | + } | |
| 155 | + } | |
| 156 | + | |
| 157 | + // 아직 오지 않은 단계. 흐린 테두리에 연한 면이고 안은 비었다. | |
| 158 | + &--before { | |
| 159 | + .fox-step-indicator-item__mark { | |
| 160 | + border: fox.border(1) solid fox.color(element-neutral-disabled); | |
| 161 | + background: fox.color(element-neutral-subtle); | |
| 162 | + } | |
| 163 | + | |
| 164 | + .fox-step-indicator-item__line { | |
| 165 | + background: fox.color(element-neutral-disabled); | |
| 166 | + } | |
| 167 | + } | |
| 168 | +} |
+++ @fox/styles/_fox-step-indicator.scss
... | ... | @@ -0,0 +1,34 @@ |
| 1 | +// FoxStepIndicator — 시안: 통합관리자페이지 디자인시스템 Figma step-indicator | |
| 2 | +// | |
| 3 | +// `fox-step-indicator-item`들을 가로로 늘어놓는 묶음이다. 배치만 갖는다 — 어디까지 왔는지는 각 단계가 말한다. | |
| 4 | +// | |
| 5 | +// 마크업 계약 (React 밖 소비자용): | |
| 6 | +// <div class="fox-step-indicator" role="group" aria-label="가입 절차"> | |
| 7 | +// …fox-step-indicator-item (completion)… | |
| 8 | +// …fox-step-indicator-item (ongoing)… | |
| 9 | +// …fox-step-indicator-item (before, 마지막이면 선 없이)… | |
| 10 | +// </div> | |
| 11 | +// | |
| 12 | +// 토큰이 아닌 값: | |
| 13 | +// - ⚠️ `inline-size: 40rem`(400px): 시안 값 그대로다. 80px × 5단계라 **단계가 다섯 개일 때의 | |
| 14 | +// 폭**이고, 개수가 다르면 맞지 않는다. 토큰 스케일에도 400이 없다. 단계 수가 다른 화면은 | |
| 15 | +// 호출부가 `className`으로 덮는다 — 시안에서 폭 규칙을 받으면 그 값으로 바꾼다. | |
| 16 | +// - display / align-items: 구조 규칙. | |
| 17 | +// | |
| 18 | +// 세로 정렬이 `center`라 글자가 있는 화면과 없는 화면(모바일)에서 동그라미 줄이 같은 높이에 | |
| 19 | +// 놓인다. | |
| 20 | + | |
| 21 | +@use "@fox/styles/abstracts" as fox; | |
| 22 | + | |
| 23 | +.fox-step-indicator { | |
| 24 | + box-sizing: border-box; | |
| 25 | + display: flex; | |
| 26 | + align-items: center; | |
| 27 | + inline-size: 40rem; | |
| 28 | + | |
| 29 | + // 모바일에서는 글자가 사라져 폭이 남는다 — 고정 폭을 풀어 화면에 맞춘다 | |
| 30 | + // (`fox-step-indicator-item`의 `__text`가 숨는 것과 짝이다). | |
| 31 | + @include fox.mobile { | |
| 32 | + inline-size: 100%; | |
| 33 | + } | |
| 34 | +} |
--- @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-toast.scss
... | ... | @@ -0,0 +1,53 @@ |
| 1 | +// FoxToast — 시안: 통합관리자페이지 디자인시스템 Figma toast | |
| 2 | +// | |
| 3 | +// 어두운 판 위에 한 줄짜리 알림을 얹는다. 크기 수식어가 없다 — 시안에 하나뿐이다. | |
| 4 | +// | |
| 5 | +// 마크업 계약 (React 밖 소비자용): | |
| 6 | +// <div class="fox-toast" role="status">저장되었습니다.</div> | |
| 7 | +// | |
| 8 | +// 글자를 감싸는 엘리먼트를 두지 않는다 — 안에 들어가는 것이 글자 하나뿐이라 따로 규칙을 | |
| 9 | +// 걸 대상이 없다. 아이콘이나 버튼이 붙는 날이 오면 그때 `__label`을 만든다. | |
| 10 | +// | |
| 11 | +// **띄우는 위치는 여기서 정하지 않는다.** 시안이 조각의 모양만 주고, 화면 어디에 어떻게 | |
| 12 | +// 나타나는지는 쓰는 화면이 정한다(사용자 결정). 호출부가 `className`으로 `position: fixed` | |
| 13 | +// 같은 배치를 준다. | |
| 14 | +// | |
| 15 | +// 배경·글자색이 `*-inverse` 토큰이다 — 라이트에서 어두운 판 + 흰 글자이고, 다크 모드에서는 | |
| 16 | +// 둘이 함께 뒤집혀 밝은 판 + 어두운 글자가 된다. 두 값을 한 쌍으로 골라야 대비가 유지된다. | |
| 17 | +// | |
| 18 | +// 토큰이 아닌 값(FoxButton·FoxTag와 동일한 근거로 승인된 항목): | |
| 19 | +// - line-height / letter-spacing: 시안 텍스트 스타일 body/md의 값이나 Figma가 변수로 | |
| 20 | +// export하지 않는다. 시안의 -0.375px은 15px 기준이라 `-0.025em`으로 적는다 — px으로 | |
| 21 | +// 박으면 글자 크기가 모바일에서 13px으로 줄 때 비율이 어긋난다. | |
| 22 | +// - box-sizing / display / align-items: 구조 규칙. | |
| 23 | +// | |
| 24 | +// ⚠️ 그림자 색: 시안이 두 겹을 각각 `dark/alpha/black10`·`alpha/black10`으로 물려 놓았는데 | |
| 25 | +// 값이 같고(rgba(0,0,0,0.10)) 앞쪽 이름의 토큰은 export에 없다. 그래서 두 겹 모두 | |
| 26 | +// `alpha-black10`을 쓴다 — `fox-select-option`과 같은 선택이다(다크 모드에서도 검은 그림자). | |
| 27 | + | |
| 28 | +@use "@fox/styles/abstracts" as fox; | |
| 29 | + | |
| 30 | +.fox-toast { | |
| 31 | + // 안쪽 여백이 바깥 크기를 늘리지 않게 한다. | |
| 32 | + box-sizing: border-box; | |
| 33 | + display: inline-flex; | |
| 34 | + // 글자 하나뿐이라 지금은 티가 나지 않지만, 시안이 세로 가운데 정렬을 명시한다. | |
| 35 | + align-items: center; | |
| 36 | + padding: fox.padding(5) fox.padding(7); | |
| 37 | + border-radius: fox.radius(4); | |
| 38 | + background: fox.color(surface-neutral-default-inverse); | |
| 39 | + | |
| 40 | + // 시안의 neutral/lv3 — 가까운 그림자와 먼 그림자 두 겹이다. | |
| 41 | + box-shadow: | |
| 42 | + fox.shadow(location2) fox.shadow(location2) fox.shadow(blur3) 0 | |
| 43 | + fox.primitive(alpha-black10), | |
| 44 | + fox.shadow(location4) fox.shadow(location4) fox.shadow(blur4) 0 | |
| 45 | + fox.primitive(alpha-black10); | |
| 46 | + | |
| 47 | + color: fox.color(font-neutral-strong-inverse); | |
| 48 | + font-family: fox.font-family(body); | |
| 49 | + font-size: fox.font-size(body-md); | |
| 50 | + font-weight: fox.font-weight(regular); | |
| 51 | + line-height: 1.5; | |
| 52 | + letter-spacing: -0.025em; | |
| 53 | +} |
+++ @fox/styles/_fox-tooltip-rich.scss
... | ... | @@ -0,0 +1,120 @@ |
| 1 | +// FoxTooltipRich — 시안: 통합관리자페이지 디자인시스템 Figma tooltip-rich | |
| 2 | +// | |
| 3 | +// 제목과 본문을 담은 말풍선이다. `fox-popover`와 판·꼬리의 구조가 같고 **누를 것이 없다** — | |
| 4 | +// 닫기도 액션 버튼도 없다. | |
| 5 | +// | |
| 6 | +// 마크업 계약 (React 밖 소비자용): | |
| 7 | +// <div class="fox-tooltip-rich fox-tooltip-rich--top fox-tooltip-rich--arrow-left"> | |
| 8 | +// <div class="fox-tooltip-rich__arrow-area"> <!-- top이면 판 위, bottom이면 아래 --> | |
| 9 | +// <span class="fox-tooltip-rich__arrow"></span> <!-- 비어 있다. 그림은 CSS가 그린다 --> | |
| 10 | +// </div> | |
| 11 | +// <div class="fox-tooltip-rich__content"> | |
| 12 | +// <div class="fox-tooltip-rich__body"> | |
| 13 | +// <div class="fox-tooltip-rich__title-row"> | |
| 14 | +// <strong class="fox-tooltip-rich__title">제목</strong> | |
| 15 | +// </div> | |
| 16 | +// <div class="fox-tooltip-rich__message-row"> | |
| 17 | +// <span class="fox-tooltip-rich__message">본문</span> | |
| 18 | +// </div> | |
| 19 | +// </div> | |
| 20 | +// </div> | |
| 21 | +// </div> | |
| 22 | +// | |
| 23 | +// `--top`은 꼬리가 판 **위**에 붙고(트리거 아래에 뜨는 말풍선), `--bottom`은 아래에 붙는다. | |
| 24 | +// 마크업 순서가 그것을 정한다 — `order`나 `column-reverse`를 쓰지 않는다. | |
| 25 | +// | |
| 26 | +// 꼬리는 **CSS가 그린다**(SVG를 쓰지 않는다). 그림과 방향·위치 규칙은 `_bubble.scss`의 | |
| 27 | +// `arrow()` 믹스인이 갖는다 — `fox-popover`와 같은 그림이라 한곳에 둔다. 왜 시안 SVG를 | |
| 28 | +// 쓰지 않는지도 그 파일 상단에 적혀 있다. | |
| 29 | +// | |
| 30 | +// `fox-popover`와 다른 점은 셋뿐이다: | |
| 31 | +// 1. 테두리가 한 단계 옅다(`border-neutral-default` ↔ 팝오버의 `border-neutral-strong`). | |
| 32 | +// 꼬리도 같은 값을 받는다. | |
| 33 | +// 2. 제목과 본문 사이가 좁다(`padding-3` 8px ↔ 팝오버의 `padding-6` 16px). | |
| 34 | +// 3. 그림자가 없다 — 시안이 주지 않는다. | |
| 35 | +// | |
| 36 | +// 제목 줄의 오른쪽 여백(`padding-9`)은 팝오버와 같다. 닫기 버튼이 없어 비워 둘 이유가 | |
| 37 | +// 없어 보이지만 시안이 같은 값을 주므로 그대로 둔다 — 두 말풍선의 글줄 시작·끝이 맞는다. | |
| 38 | +// | |
| 39 | +// 토큰이 아닌 값: | |
| 40 | +// - ⚠️ `inline-size: 36rem`(360px): 시안 값 그대로다. 토큰 스케일에 360이 없다. 폭을 바꿔야 | |
| 41 | +// 하는 자리는 호출부가 `className`으로 덮는다(`fox-popover`와 같은 처리). | |
| 42 | +// - line-height / letter-spacing: 시안 텍스트 스타일 heading/xsm·body/sm의 값이나 Figma가 | |
| 43 | +// 변수로 export하지 않는다. -0.375px·-0.325px은 각각 15px·13px 기준이라 `-0.025em`으로 | |
| 44 | +// 적는다(다른 컴포넌트와 같은 근거). | |
| 45 | +// - box-sizing / display / flex: 구조 규칙. | |
| 46 | + | |
| 47 | +@use "@fox/styles/abstracts" as fox; | |
| 48 | +@use "@fox/styles/bubble" as bubble; | |
| 49 | + | |
| 50 | +.fox-tooltip-rich { | |
| 51 | + @include bubble.root; | |
| 52 | + | |
| 53 | + // ── 판 ────────────────────────────────────────────────────────────────────── | |
| 54 | + &__content { | |
| 55 | + box-sizing: border-box; | |
| 56 | + display: flex; | |
| 57 | + flex-direction: column; | |
| 58 | + align-items: flex-start; | |
| 59 | + gap: fox.gap(4); | |
| 60 | + align-self: stretch; | |
| 61 | + padding: fox.padding(8); | |
| 62 | + border: fox.border(1) solid fox.color(border-neutral-default); | |
| 63 | + border-radius: fox.radius(5); | |
| 64 | + background: fox.color(surface-neutral-default); | |
| 65 | + } | |
| 66 | + | |
| 67 | + // ── 글자 ──────────────────────────────────────────────────────────────────── | |
| 68 | + &__body { | |
| 69 | + display: flex; | |
| 70 | + flex-direction: column; | |
| 71 | + align-items: flex-start; | |
| 72 | + align-self: stretch; | |
| 73 | + } | |
| 74 | + | |
| 75 | + &__title-row { | |
| 76 | + display: flex; | |
| 77 | + align-items: center; | |
| 78 | + gap: fox.gap(5); | |
| 79 | + align-self: stretch; | |
| 80 | + padding-inline-end: fox.padding(9); | |
| 81 | + } | |
| 82 | + | |
| 83 | + &__title { | |
| 84 | + flex: 1 0 0; | |
| 85 | + min-inline-size: 0; | |
| 86 | + color: fox.color(font-neutral-strong); | |
| 87 | + font-family: fox.font-family(title); | |
| 88 | + font-size: fox.font-size(heading-xsm); | |
| 89 | + font-weight: fox.font-weight(bold); | |
| 90 | + line-height: 1.4; | |
| 91 | + letter-spacing: -0.025em; | |
| 92 | + } | |
| 93 | + | |
| 94 | + // 제목과 본문 사이 간격을 본문의 윗여백이 맡는다(시안이 그렇게 준다) — 제목만 있을 때 | |
| 95 | + // 아래로 빈자리가 남지 않는다. | |
| 96 | + &__message-row { | |
| 97 | + display: flex; | |
| 98 | + align-items: flex-start; | |
| 99 | + align-self: stretch; | |
| 100 | + padding-block-start: fox.padding(3); | |
| 101 | + } | |
| 102 | + | |
| 103 | + &__message { | |
| 104 | + flex: 1 0 0; | |
| 105 | + min-inline-size: 0; | |
| 106 | + color: fox.color(font-neutral-default); | |
| 107 | + font-family: fox.font-family(body); | |
| 108 | + font-size: fox.font-size(body-sm); | |
| 109 | + font-weight: fox.font-weight(regular); | |
| 110 | + line-height: 1.5; | |
| 111 | + letter-spacing: -0.025em; | |
| 112 | + } | |
| 113 | +} | |
| 114 | + | |
| 115 | +// 꼬리. 판과 같은 색을 넘긴다 — 꼬리는 판이 뾰족하게 튀어나온 부분이다. | |
| 116 | +@include bubble.arrow( | |
| 117 | + "fox-tooltip-rich", | |
| 118 | + $border: fox.color(border-neutral-default), | |
| 119 | + $fill: fox.color(surface-neutral-default) | |
| 120 | +); |
+++ @fox/styles/_fox-tooltip.scss
... | ... | @@ -0,0 +1,110 @@ |
| 1 | +// FoxTooltip — 시안: 통합관리자페이지 디자인시스템 Figma tooltip | |
| 2 | +// | |
| 3 | +// 어두운 판에 짧은 글 한 줄을 담은 말풍선이다. `fox-tooltip-rich`의 작은 형제로, 제목이 없고 | |
| 4 | +// 꼬리가 언제나 가운데이며 판이 글자만큼만 넓어진다. | |
| 5 | +// | |
| 6 | +// 마크업 계약 (React 밖 소비자용): | |
| 7 | +// <div class="fox-tooltip fox-tooltip--top"> | |
| 8 | +// <div class="fox-tooltip__arrow-area"> <!-- top이면 판 위, bottom이면 아래 --> | |
| 9 | +// <span class="fox-tooltip__arrow">…꼬리 svg…</span> | |
| 10 | +// </div> | |
| 11 | +// <div class="fox-tooltip__content">글자</div> | |
| 12 | +// </div> | |
| 13 | +// | |
| 14 | +// `--top`은 꼬리가 판 **위**에 붙고(트리거 아래에 뜨는 말풍선), `--bottom`은 아래에 붙는다. | |
| 15 | +// 마크업 순서가 그것을 정한다 — `order`나 `column-reverse`를 쓰지 않는다. | |
| 16 | +// | |
| 17 | +// 꼬리의 가로 위치를 고르는 수식어가 없다 — 시안이 언제나 가운데에 둔다. 그래서 이 파일은 | |
| 18 | +// `_bubble.scss`의 `arrow()` 믹스인을 쓰지 않는다. 저쪽은 **테두리가 있는 판**의 이음새를 | |
| 19 | +// 지우려고 회전 정사각형을 반쯤 걸치는 방식인데, 이 판은 테두리 없이 한 색이라 그럴 일이 없고 | |
| 20 | +// 시안의 꼬리도 12×6에 끝이 둥근 다른 그림이다. | |
| 21 | +// | |
| 22 | +// 판은 `inline-flex`라 글자만큼만 넓어진다. 다만 꼬리 영역이 좌우 24px 여백을 달고 있어 | |
| 23 | +// **가장 좁을 때가 60px**(12 + 48)이다 — 글자가 그보다 짧으면 판보다 꼬리 영역이 넓어져 | |
| 24 | +// 말풍선 전체 폭을 그 값이 정한다(시안이 그렇게 준다). | |
| 25 | +// | |
| 26 | +// 토큰이 아닌 값: | |
| 27 | +// - ⚠️ 꼬리 12×6px: 시안이 준 그림의 크기이고 토큰 스케일에 없다. | |
| 28 | +// - line-height / letter-spacing: 시안 텍스트 스타일 body/sm의 값이나 Figma가 변수로 | |
| 29 | +// export하지 않는다. -0.325px은 13px 기준이라 `-0.025em`으로 적는다(다른 컴포넌트와 같은 | |
| 30 | +// 근거). | |
| 31 | +// - display / flex: 구조 규칙. | |
| 32 | + | |
| 33 | +@use "@fox/styles/abstracts" as fox; | |
| 34 | + | |
| 35 | +.fox-tooltip { | |
| 36 | + box-sizing: border-box; | |
| 37 | + display: inline-flex; | |
| 38 | + flex-direction: column; | |
| 39 | + align-items: center; | |
| 40 | + // 글자만큼 넓어진다. `inline-flex`만으로는 부족하다 — 툴팁은 대개 트리거 옆에 | |
| 41 | + // `position: absolute`로 떠 있고, 그때 폭은 **기준이 되는 조상의 폭**을 한도로 계산된다. | |
| 42 | + // 트리거가 24px짜리면 툴팁도 그 안에 맞추려 글자를 쪼갠다. `max-content`는 그 한도를 벗어나 | |
| 43 | + // 내용 그대로의 폭을 갖게 한다. | |
| 44 | + // | |
| 45 | + // ⚠️ 글이 길면 그만큼 한 줄로 길어진다. 줄바꿈이 필요한 자리는 호출부가 `className`으로 | |
| 46 | + // `max-inline-size`를 준다 — 애초에 긴 글은 `fox-tooltip-rich`가 맡는다. | |
| 47 | + inline-size: max-content; | |
| 48 | + | |
| 49 | + &__content { | |
| 50 | + box-sizing: border-box; | |
| 51 | + display: flex; | |
| 52 | + align-items: center; | |
| 53 | + justify-content: center; | |
| 54 | + padding: fox.padding(2) fox.padding(5); | |
| 55 | + border-radius: fox.radius(2); | |
| 56 | + background: fox.color(surface-neutral-default-inverse); | |
| 57 | + | |
| 58 | + color: fox.color(font-neutral-strong-inverse); | |
| 59 | + text-align: center; | |
| 60 | + font-family: fox.font-family(body); | |
| 61 | + font-size: fox.font-size(body-sm); | |
| 62 | + font-weight: fox.font-weight(regular); | |
| 63 | + line-height: 1.5; | |
| 64 | + letter-spacing: -0.025em; | |
| 65 | + } | |
| 66 | + | |
| 67 | + &__arrow-area { | |
| 68 | + box-sizing: border-box; | |
| 69 | + display: flex; | |
| 70 | + flex-direction: column; | |
| 71 | + align-items: center; | |
| 72 | + padding-inline: fox.padding(8); | |
| 73 | + } | |
| 74 | + | |
| 75 | + // 꼬리 색은 판과 같은 값이다 — 꼬리는 판이 뾰족하게 튀어나온 부분이다. SVG가 | |
| 76 | + // `fill="currentColor"`라 이 색을 그대로 입는다. | |
| 77 | + &__arrow { | |
| 78 | + display: block; | |
| 79 | + inline-size: 1.2rem; | |
| 80 | + block-size: 0.6rem; | |
| 81 | + color: fox.color(surface-neutral-default-inverse); | |
| 82 | + } | |
| 83 | + | |
| 84 | + &__arrow > svg { | |
| 85 | + display: block; | |
| 86 | + inline-size: 100%; | |
| 87 | + block-size: 100%; | |
| 88 | + } | |
| 89 | + | |
| 90 | + // 꼬리가 판 위에 붙으면 뾰족한 끝이 위를 향해야 한다. 시안 그림은 아래를 향하고 있어 | |
| 91 | + // 뒤집는다 — 같은 그림 하나로 두 방향을 만든다. | |
| 92 | + &--top &__arrow > svg { | |
| 93 | + transform: rotate(180deg); | |
| 94 | + } | |
| 95 | + | |
| 96 | + // 꼬리를 판 쪽으로 0.5px 겹친다. | |
| 97 | + // | |
| 98 | + // ⚠️ 시안에 없는 값이다. 딱 붙여만 두면 소수 배율 화면(윈도우 125% 등)에서 두 요소의 경계가 | |
| 99 | + // 물리 픽셀 중간에 떨어져 **그 사이로 밝은 실선이 비친다.** 판과 꼬리가 같은 색이라 겹쳐도 | |
| 100 | + // 보이는 모양은 그대로이고, 0.5px이면 꼬리 높이(6px)가 눈에 띄게 줄지도 않는다. | |
| 101 | + // (테두리가 있는 `fox-popover`·`fox-tooltip-rich`는 같은 문제를 다른 방식으로 푼다 — | |
| 102 | + // `_bubble.scss` 상단 참고.) | |
| 103 | + &--top &__arrow-area { | |
| 104 | + margin-block-end: -0.05rem; | |
| 105 | + } | |
| 106 | + | |
| 107 | + &--bottom &__arrow-area { | |
| 108 | + margin-block-start: -0.05rem; | |
| 109 | + } | |
| 110 | +} |
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
... | ... | @@ -8,6 +8,7 @@ |
| 8 | 8 |
@use "fox-button-group"; |
| 9 | 9 |
@use "fox-button-panel"; |
| 10 | 10 |
@use "fox-address"; |
| 11 |
+@use "fox-alert"; |
|
| 11 | 12 |
@use "fox-certified"; |
| 12 | 13 |
@use "fox-check-icon"; |
| 13 | 14 |
@use "fox-checkbox"; |
... | ... | @@ -16,11 +17,15 @@ |
| 16 | 17 |
@use "fox-chip-area"; |
| 17 | 18 |
@use "fox-chip-select-option"; |
| 18 | 19 |
@use "fox-conditional-option"; |
| 20 |
+@use "fox-contextual-trigger"; |
|
| 19 | 21 |
@use "fox-email"; |
| 20 | 22 |
@use "fox-file-upload"; |
| 21 | 23 |
@use "fox-form-label"; |
| 24 |
+@use "fox-heading-group"; |
|
| 25 |
+@use "fox-helper-text"; |
|
| 22 | 26 |
@use "fox-input"; |
| 23 | 27 |
@use "fox-phone-number"; |
| 28 |
+@use "fox-popover"; |
|
| 24 | 29 |
@use "fox-radio"; |
| 25 | 30 |
@use "fox-radio-group"; |
| 26 | 31 |
@use "fox-radio-icon"; |
... | ... | @@ -49,7 +54,15 @@ |
| 49 | 54 |
@use "fox-table-cell"; |
| 50 | 55 |
@use "fox-tab"; |
| 51 | 56 |
@use "fox-tab-item"; |
| 57 |
+@use "fox-snackbar"; |
|
| 58 |
+@use "fox-snackbar-list"; |
|
| 52 | 59 |
@use "fox-spinner"; |
| 60 |
+@use "fox-status-indicator"; |
|
| 61 |
+@use "fox-step-indicator-item"; |
|
| 62 |
+@use "fox-step-indicator"; |
|
| 63 |
+@use "fox-toast"; |
|
| 64 |
+@use "fox-tooltip"; |
|
| 65 |
+@use "fox-tooltip-rich"; |
|
| 53 | 66 |
@use "fox-toggle-quantity"; |
| 54 | 67 |
@use "fox-page-header"; |
| 55 | 68 |
@use "fox-modal"; |
--- app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx
... | ... | @@ -21,11 +21,13 @@ |
| 21 | 21 |
import { FoxChatCenteredDotsIcon } from '@fox/core/icons';
|
| 22 | 22 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 23 | 23 |
import { DEFAULT_ADMIN_ROLE_CODE } from '@/lib/domain/admin-member';
|
| 24 |
+import { DEFAULT_MOBILE_PHONE_PREFIX } from '@/lib/domain/phone-number';
|
|
| 24 | 25 |
import {
|
| 25 | 26 |
ADMIN_EMAIL_MESSAGES, |
| 26 | 27 |
ADMIN_LOGIN_ID_HELP_TEXT, |
| 27 | 28 |
ADMIN_LOGIN_ID_MESSAGES, |
| 28 | 29 |
ADMIN_LOGIN_ID_VALIDATORS, |
| 30 |
+ ADMIN_PASSWORD_ERROR_TEXT, |
|
| 29 | 31 |
ADMIN_PASSWORD_HELP_TEXT, |
| 30 | 32 |
ADMIN_PASSWORD_POLICY, |
| 31 | 33 |
INITIAL_ADMIN_MEMBER_FORM_STATE, |
... | ... | @@ -90,7 +92,8 @@ |
| 90 | 92 |
}); |
| 91 | 93 |
const [isChecking, startChecking] = useTransition(); |
| 92 | 94 |
|
| 93 |
- const [phoneNumber, setPhoneNumber] = useState('');
|
|
| 95 |
+ // 앞자리는 고르지 않아도 되도록 기본값으로 시작한다(상수 주석 참조). |
|
| 96 |
+ const [phoneNumber, setPhoneNumber] = useState(DEFAULT_MOBILE_PHONE_PREFIX); |
|
| 94 | 97 |
const [emailLocal, setEmailLocal] = useState('');
|
| 95 | 98 |
const [emailDomain, setEmailDomain] = useState(EMAIL_DOMAIN_OPTIONS[0]); |
| 96 | 99 |
const [isCustomDomain, setIsCustomDomain] = useState(false); |
... | ... | @@ -125,17 +128,19 @@ |
| 125 | 128 |
) |
| 126 | 129 |
: undefined); |
| 127 | 130 |
|
| 128 |
- // 중복 확인 결과는 서버 검증 오류보다 먼저 보여준다 — 방금 누른 버튼의 답이기 때문이다. |
|
| 131 |
+ // 중복 확인 결과는 화면 규칙보다 먼저 보여준다 — 방금 누른 버튼의 답이기 때문이다. |
|
| 129 | 132 |
// 값이 나쁜 것(unavailable)과 확인을 못 한 것(failed)은 사유가 다르므로 둘 다 문구로 낸다. |
| 130 | 133 |
const loginIdFailure = |
| 131 | 134 |
checkResult.status === 'unavailable' || checkResult.status === 'failed' |
| 132 | 135 |
? checkResult.message |
| 133 | 136 |
: undefined; |
| 137 |
+ // **서버 오류는 성공 문구를 이긴다** — 확인을 통과한 뒤에도 저장이 ID 때문에 거부될 수 있는데 |
|
| 138 |
+ // (확인과 저장 사이의 선점, 형식 규칙 변경), '사용할 수 있는 ID입니다.'가 계속 떠 있으면 |
|
| 139 |
+ // 거부된 사실이 화면에서 사라진다. |
|
| 134 | 140 |
const loginIdMessage = |
| 135 | 141 |
loginIdFailure ?? |
| 136 |
- (isLoginIdVerified |
|
| 137 |
- ? '사용할 수 있는 ID입니다.' |
|
| 138 |
- : (errors.loginId ?? ADMIN_LOGIN_ID_HELP_TEXT)); |
|
| 142 |
+ errors.loginId ?? |
|
| 143 |
+ (isLoginIdVerified ? '사용할 수 있는 ID입니다.' : ADMIN_LOGIN_ID_HELP_TEXT); |
|
| 139 | 144 |
|
| 140 | 145 |
return ( |
| 141 | 146 |
<FoxModal |
... | ... | @@ -222,7 +227,7 @@ |
| 222 | 227 |
invalid={Boolean(errors.password)}
|
| 223 | 228 |
// 규칙은 도메인이 한 벌로 갖는다 — 저장 직전 서버 검증이 같은 값을 본다. |
| 224 | 229 |
validators={[foxPasswordValidator({ ...ADMIN_PASSWORD_POLICY, required: true })]}
|
| 225 |
- validationMessages={{ minlength: ADMIN_PASSWORD_HELP_TEXT }}
|
|
| 230 |
+ validationMessages={{ minlength: ADMIN_PASSWORD_ERROR_TEXT }}
|
|
| 226 | 231 |
/> |
| 227 | 232 |
|
| 228 | 233 |
<AdminMemberFormFields |
--- app/(protected)/(basic)/admins/_components/admin-member-edit-modal.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-edit-modal.tsx
... | ... | @@ -13,6 +13,7 @@ |
| 13 | 13 |
import { DEFAULT_ADMIN_ROLE_CODE, type AdminMember } from '@/lib/domain/admin-member';
|
| 14 | 14 |
import {
|
| 15 | 15 |
ADMIN_EMAIL_MESSAGES, |
| 16 |
+ ADMIN_PASSWORD_ERROR_TEXT, |
|
| 16 | 17 |
ADMIN_PASSWORD_HELP_TEXT, |
| 17 | 18 |
ADMIN_PASSWORD_POLICY, |
| 18 | 19 |
INITIAL_ADMIN_MEMBER_FORM_STATE, |
... | ... | @@ -23,6 +24,7 @@ |
| 23 | 24 |
import { AdminMemberFormFields } from './admin-member-form-fields';
|
| 24 | 25 |
import styles from './admin-member-modal.module.scss'; |
| 25 | 26 |
import { submitFormAction } from '@/app/_hooks/submit-form-action';
|
| 27 |
+import { DEFAULT_MOBILE_PHONE_PREFIX } from '@/lib/domain/phone-number';
|
|
| 26 | 28 |
|
| 27 | 29 |
interface AdminMemberEditModalProps {
|
| 28 | 30 |
member: AdminMember; |
... | ... | @@ -62,8 +64,9 @@ |
| 62 | 64 |
const formRef = useRef<HTMLFormElement>(null); |
| 63 | 65 |
|
| 64 | 66 |
const [isPasswordEditable, setIsPasswordEditable] = useState(false); |
| 67 |
+ // 저장된 번호가 없으면 등록 팝업과 같이 기본 앞자리로 시작한다. |
|
| 65 | 68 |
const [phoneNumber, setPhoneNumber] = useState( |
| 66 |
- toPhoneDigits(member.phoneNumber) |
|
| 69 |
+ () => toPhoneDigits(member.phoneNumber) || DEFAULT_MOBILE_PHONE_PREFIX |
|
| 67 | 70 |
); |
| 68 | 71 |
const [email, setEmail] = useState(member.email ?? ''); |
| 69 | 72 |
const [roleCode, setRoleCode] = useState( |
... | ... | @@ -140,7 +143,7 @@ |
| 140 | 143 |
invalid={Boolean(errors.password)}
|
| 141 | 144 |
// 비우면 "바꾸지 않음"이라 required를 걸지 않는다(등록 팝업과 다른 점). |
| 142 | 145 |
validators={[foxPasswordValidator(ADMIN_PASSWORD_POLICY)]}
|
| 143 |
- validationMessages={{ minlength: ADMIN_PASSWORD_HELP_TEXT }}
|
|
| 146 |
+ validationMessages={{ minlength: ADMIN_PASSWORD_ERROR_TEXT }}
|
|
| 144 | 147 |
/> |
| 145 | 148 |
<FoxButton |
| 146 | 149 |
type="secondary" |
--- app/(protected)/(basic)/admins/_components/admin-member-form-fields.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-form-fields.tsx
... | ... | @@ -11,6 +11,7 @@ |
| 11 | 11 |
ADMIN_ROLE_OPTIONS, |
| 12 | 12 |
} from '@/lib/domain/admin-member'; |
| 13 | 13 |
import type { AdminMemberFormErrors } from '@/lib/domain/admin-member-form';
|
| 14 |
+import { MOBILE_PHONE_PREFIXES } from '@/lib/domain/phone-number';
|
|
| 14 | 15 |
import styles from './admin-member-modal.module.scss'; |
| 15 | 16 |
|
| 16 | 17 |
interface AdminMemberFormFieldsProps {
|
... | ... | @@ -78,6 +79,8 @@ |
| 78 | 79 |
</FoxFormLabel> |
| 79 | 80 |
<FoxPhoneNumber |
| 80 | 81 |
type="unit" |
| 82 |
+ // 넘기지 않으면 앞자리 셀렉트가 빈 목록이라 아무것도 고를 수 없다(상수 주석 참조). |
|
| 83 |
+ prefixOptions={[...MOBILE_PHONE_PREFIXES]}
|
|
| 81 | 84 |
value={phoneNumber}
|
| 82 | 85 |
onChange={onPhoneNumberChange}
|
| 83 | 86 |
message={errors.phoneNumber}
|
--- 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';
|
... | ... | @@ -16,27 +17,30 @@ |
| 16 | 17 |
/** 등록 버튼을 누른 시점의 활성 탭(유형) — 폼의 유형 기본값으로 쓴다. */ |
| 17 | 18 |
defaultItemType: DecorationItemType; |
| 18 | 19 |
typeTotalCount: number; |
| 20 |
+ /** 공통코드 `ITEM_CATE_CD` — 카테고리 선택지. */ |
|
| 21 |
+ categories: CommonCode[]; |
|
| 19 | 22 |
onClose: () => void; |
| 20 | 23 |
} |
| 21 | 24 |
|
| 22 |
-const FORM_ID = 'decoration-item-create-form'; |
|
| 23 |
- |
|
| 24 | 25 |
/** |
| 25 |
- * 꾸미기 아이템 등록 팝업(시안 ADM_ITM_102_p). |
|
| 26 |
+ * 꾸미기 아이템 등록 팝업 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) ADM_ITM_102_p (5227:4629) |
|
| 26 | 27 |
* |
| 27 |
- * 아이템ID는 입력 항목이 아니다 — 시안대로 "등록 후 자동발급됩니다."를 읽기 전용으로 보여주고, |
|
| 28 |
- * 실제 값은 백엔드가 `itemSn`(자동증가)으로 채운다. |
|
| 28 |
+ * **아이템ID는 입력받지 않는다.** 시안은 직접 입력하는 칸으로 그리지만 백엔드 `TB_COM_ITEM`에 |
|
| 29 |
+ * 그런 컬럼이 없고 쓰기 API도 그 값을 받지 않는다 — 자동증가 PK인 `itemSn`이 목록의 아이템ID로 |
|
| 30 |
+ * 그대로 노출되는 값이라, 등록 시점에는 발급 전이므로 읽기 전용 안내로 둔다(백엔드에 컬럼이 |
|
| 31 |
+ * 생기면 이 칸만 입력으로 바꾸면 된다). |
|
| 29 | 32 |
* |
| 30 |
- * 저장 버튼은 footer 슬롯에서 `form={FORM_ID}` 속성으로 폼과 연결한다(admins의 등록 팝업과
|
|
| 31 |
- * 동일한 패턴) — 버튼이 실제 DOM상 form의 자손이 아니어도 같은 문서 안에서 id만 일치하면 그 |
|
| 32 |
- * form을 제출한다. |
|
| 33 |
+ * 등록 버튼은 foot 슬롯에 그려져 폼의 자손이 아니다 — `FoxButton`에 폼을 가리키는 prop이 없어 |
|
| 34 |
+ * ref로 직접 제출한다(학생 회원 조회 팝업과 같은 방식). |
|
| 33 | 35 |
*/ |
| 34 | 36 |
export function DecorationItemCreateModal({
|
| 35 | 37 |
defaultItemType, |
| 36 | 38 |
typeTotalCount, |
| 39 |
+ categories, |
|
| 37 | 40 |
onClose, |
| 38 | 41 |
}: DecorationItemCreateModalProps) {
|
| 39 | 42 |
const { showToast } = useFeedback();
|
| 43 |
+ const formRef = useRef<HTMLFormElement>(null); |
|
| 40 | 44 |
const [state, formAction, isPending] = useActionState( |
| 41 | 45 |
createDecorationItemAction, |
| 42 | 46 |
INITIAL_DECORATION_ITEM_FORM_STATE |
... | ... | @@ -52,44 +56,45 @@ |
| 52 | 56 |
const errors = state.status === 'error' ? (state.errors ?? {}) : {};
|
| 53 | 57 |
|
| 54 | 58 |
return ( |
| 55 |
- <Modal |
|
| 59 |
+ <FoxModal |
|
| 60 |
+ open |
|
| 61 |
+ size="sm" |
|
| 56 | 62 |
title="꾸미기 아이템 등록" |
| 57 | 63 |
onClose={onClose}
|
| 58 |
- footer={
|
|
| 64 |
+ actions={
|
|
| 59 | 65 |
<> |
| 60 |
- <Button type="button" variant="ghost" onClick={onClose}>
|
|
| 61 |
- 취소 |
|
| 62 |
- </Button> |
|
| 63 |
- <Button |
|
| 64 |
- type="submit" |
|
| 65 |
- form={FORM_ID}
|
|
| 66 |
- variant="primary" |
|
| 66 |
+ <FoxButton type="default" size="md" label="취소" onAction={onClose} />
|
|
| 67 |
+ <FoxButton |
|
| 68 |
+ type="primary" |
|
| 69 |
+ size="md" |
|
| 70 |
+ label={isPending ? '등록 중...' : '등록'}
|
|
| 67 | 71 |
disabled={isPending}
|
| 68 |
- > |
|
| 69 |
- {isPending ? '등록 중...' : '등록'}
|
|
| 70 |
- </Button> |
|
| 72 |
+ onAction={() => formRef.current?.requestSubmit()}
|
|
| 73 |
+ /> |
|
| 71 | 74 |
</> |
| 72 | 75 |
} |
| 73 | 76 |
> |
| 74 |
- <form id={FORM_ID} onSubmit={(event) => submitFormAction(event, formAction)} className="flex flex-col gap-4">
|
|
| 75 |
- <p className="text-right text-body-sm text-danger"> |
|
| 76 |
- * 는 필수 항목입니다. |
|
| 77 |
- </p> |
|
| 78 |
- |
|
| 79 |
- <Field label="아이템 ID"> |
|
| 80 |
- <Input type="text" value="등록 후 자동발급됩니다." readOnly /> |
|
| 81 |
- </Field> |
|
| 82 |
- |
|
| 77 |
+ <form ref={formRef} onSubmit={(event) => submitFormAction(event, formAction)}>
|
|
| 83 | 78 |
<DecorationItemFormFields |
| 84 | 79 |
defaultItemType={defaultItemType}
|
| 85 | 80 |
typeTotalCount={typeTotalCount}
|
| 81 |
+ categories={categories}
|
|
| 86 | 82 |
errors={errors}
|
| 83 |
+ itemIdField={
|
|
| 84 |
+ <FoxInput |
|
| 85 |
+ size="md" |
|
| 86 |
+ label="아이템 ID" |
|
| 87 |
+ requirement="required" |
|
| 88 |
+ value="등록 후 자동발급됩니다." |
|
| 89 |
+ readOnly |
|
| 90 |
+ /> |
|
| 91 |
+ } |
|
| 87 | 92 |
/> |
| 88 | 93 |
|
| 89 | 94 |
{state.status === 'error' && state.message && (
|
| 90 |
- <p className="text-body-sm text-danger">{state.message}</p>
|
|
| 95 |
+ <FoxHelperText type="danger" message={state.message} />
|
|
| 91 | 96 |
)} |
| 92 | 97 |
</form> |
| 93 |
- </Modal> |
|
| 98 |
+ </FoxModal> |
|
| 94 | 99 |
); |
| 95 | 100 |
} |
--- 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';
|
... | ... | @@ -15,26 +16,27 @@ |
| 15 | 16 |
interface DecorationItemEditModalProps {
|
| 16 | 17 |
item: DecorationItem; |
| 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-edit-form'; |
|
| 22 |
- |
|
| 23 | 24 |
/** |
| 24 |
- * 꾸미기 아이템 수정 팝업(시안 ADM_ITM_103_p) — 등록과 동일 항목이되 **아이템ID는 읽기 |
|
| 25 |
- * 전용**이다. |
|
| 25 |
+ * 꾸미기 아이템 수정 팝업(시안 ADM_ITM_103_p) — 등록 팝업(5227:4629)과 같은 구성이되 |
|
| 26 |
+ * **아이템ID는 읽기 전용**이다. |
|
| 26 | 27 |
* |
| 27 |
- * readOnly로 보여주는 아이템ID `Input`에는 `name`을 주지 않아 제출 대상에서 아예 빠지게 하고, |
|
| 28 |
- * 실제 수정 대상은 별도 hidden input(`itemSn`)으로 넘긴다 — Server Action도 hidden 값만 |
|
| 29 |
- * 읽으므로 readOnly 필드를 위조해서 보내도 수정 대상이 바뀌지 않는다(admins의 이름/ID readOnly |
|
| 30 |
- * 처리와 동일한 방어 방식). |
|
| 28 |
+ * 읽기 전용 아이템ID 칸에는 `name`을 주지 않아 제출 대상에서 아예 빠지게 하고, 실제 수정 대상은 |
|
| 29 |
+ * 별도 hidden input(`itemSn`)으로 넘긴다 — Server Action도 hidden 값만 읽으므로 읽기 전용 칸을 |
|
| 30 |
+ * 위조해 보내도 수정 대상이 바뀌지 않는다. |
|
| 31 | 31 |
*/ |
| 32 | 32 |
export function DecorationItemEditModal({
|
| 33 | 33 |
item, |
| 34 | 34 |
typeTotalCount, |
| 35 |
+ categories, |
|
| 35 | 36 |
onClose, |
| 36 | 37 |
}: DecorationItemEditModalProps) {
|
| 37 | 38 |
const { showToast } = useFeedback();
|
| 39 |
+ const formRef = useRef<HTMLFormElement>(null); |
|
| 38 | 40 |
const [state, formAction, isPending] = useActionState( |
| 39 | 41 |
updateDecorationItemAction, |
| 40 | 42 |
INITIAL_DECORATION_ITEM_FORM_STATE |
... | ... | @@ -50,47 +52,48 @@ |
| 50 | 52 |
const errors = state.status === 'error' ? (state.errors ?? {}) : {};
|
| 51 | 53 |
|
| 52 | 54 |
return ( |
| 53 |
- <Modal |
|
| 55 |
+ <FoxModal |
|
| 56 |
+ open |
|
| 57 |
+ size="sm" |
|
| 54 | 58 |
title="꾸미기 아이템 수정" |
| 55 | 59 |
onClose={onClose}
|
| 56 |
- footer={
|
|
| 60 |
+ actions={
|
|
| 57 | 61 |
<> |
| 58 |
- <Button type="button" variant="ghost" onClick={onClose}>
|
|
| 59 |
- 취소 |
|
| 60 |
- </Button> |
|
| 61 |
- <Button |
|
| 62 |
- type="submit" |
|
| 63 |
- form={FORM_ID}
|
|
| 64 |
- variant="primary" |
|
| 62 |
+ <FoxButton type="default" size="md" label="취소" onAction={onClose} />
|
|
| 63 |
+ <FoxButton |
|
| 64 |
+ type="primary" |
|
| 65 |
+ size="md" |
|
| 66 |
+ label={isPending ? '수정 중...' : '수정'}
|
|
| 65 | 67 |
disabled={isPending}
|
| 66 |
- > |
|
| 67 |
- {isPending ? '수정 중...' : '수정'}
|
|
| 68 |
- </Button> |
|
| 68 |
+ onAction={() => formRef.current?.requestSubmit()}
|
|
| 69 |
+ /> |
|
| 69 | 70 |
</> |
| 70 | 71 |
} |
| 71 | 72 |
> |
| 72 |
- <form id={FORM_ID} onSubmit={(event) => submitFormAction(event, formAction)} className="flex flex-col gap-4">
|
|
| 73 |
+ <form ref={formRef} onSubmit={(event) => submitFormAction(event, formAction)}>
|
|
| 73 | 74 |
<input type="hidden" name="itemSn" value={item.itemSn} />
|
| 74 |
- |
|
| 75 |
- <p className="text-right text-body-sm text-danger"> |
|
| 76 |
- * 는 필수 항목입니다. |
|
| 77 |
- </p> |
|
| 78 |
- |
|
| 79 |
- <Field label="아이템 ID"> |
|
| 80 |
- <Input type="text" value={item.itemSn} readOnly />
|
|
| 81 |
- </Field> |
|
| 82 | 75 |
|
| 83 | 76 |
<DecorationItemFormFields |
| 84 | 77 |
item={item}
|
| 85 | 78 |
defaultItemType={item.itemType}
|
| 86 | 79 |
typeTotalCount={typeTotalCount}
|
| 80 |
+ categories={categories}
|
|
| 87 | 81 |
errors={errors}
|
| 82 |
+ itemIdField={
|
|
| 83 |
+ <FoxInput |
|
| 84 |
+ size="md" |
|
| 85 |
+ label="아이템 ID" |
|
| 86 |
+ requirement="required" |
|
| 87 |
+ value={String(item.itemSn)}
|
|
| 88 |
+ readOnly |
|
| 89 |
+ /> |
|
| 90 |
+ } |
|
| 88 | 91 |
/> |
| 89 | 92 |
|
| 90 | 93 |
{state.status === 'error' && state.message && (
|
| 91 |
- <p className="text-body-sm text-danger">{state.message}</p>
|
|
| 94 |
+ <FoxHelperText type="danger" message={state.message} />
|
|
| 92 | 95 |
)} |
| 93 | 96 |
</form> |
| 94 |
- </Modal> |
|
| 97 |
+ </FoxModal> |
|
| 95 | 98 |
); |
| 96 | 99 |
} |
+++ 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 | - * 컴포넌트가 소유한다(관리자 회원 목록과 동일한 패턴). | |
| 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 | - * (관리자 회원 목록 이전 검색바와 동일한 설계). | |
| 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.tsx
+++ app/(protected)/(basic)/students/_components/student-detail-modal.tsx
... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 |
-import { useActionState, useRef, useState } from 'react';
|
|
| 3 |
+import { useActionState, useEffect, useRef, useState } from 'react';
|
|
| 4 | 4 |
import { FoxButton } from '@fox/core/components/fox-button';
|
| 5 | 5 |
import {
|
| 6 | 6 |
FoxDescriptionList, |
... | ... | @@ -8,7 +8,9 @@ |
| 8 | 8 |
} from '@fox/core/components/fox-description-list'; |
| 9 | 9 |
import { FoxModal } from '@fox/core/components/fox-modal';
|
| 10 | 10 |
import { FoxToggleSwitch } from '@fox/core/components/fox-toggle-switch';
|
| 11 |
+import { formatPhoneNumber } from '@/lib/domain/phone-number';
|
|
| 11 | 12 |
import {
|
| 13 |
+ EMPTY_FIELD_PLACEHOLDER, |
|
| 12 | 14 |
formatGradeClassNumber, |
| 13 | 15 |
formatOptionalValue, |
| 14 | 16 |
type StudentMember, |
... | ... | @@ -44,9 +46,8 @@ |
| 44 | 46 |
* 시안은 보호자(이름/연락처)와 학교(학교/학년·반·번호)를 각각 한 줄로 묶는다 — 종전처럼 |
| 45 | 47 |
* 항목을 따로 두지 않고 시안의 8줄 구성을 그대로 따랐다. |
| 46 | 48 |
* |
| 47 |
- * 사용여부는 이 팝업에서 유일하게 수정 가능한 항목이지만, 백엔드 목록 응답에 값이 없고 변경 |
|
| 48 |
- * API도 없어 토글·저장 버튼을 비활성으로 둔다(`_actions.ts`의 미지원 처리와 짝). 폼·Server |
|
| 49 |
- * Action 배선은 남겨 두었으므로 API가 생기면 `IS_ACTIVE_STATUS_EDITABLE`만 되돌리면 된다. |
|
| 49 |
+ * 사용여부는 이 팝업에서 유일하게 수정 가능한 항목이다. 토글을 넘기면 화면이 바로 반응하고, |
|
| 50 |
+ * 저장을 누르면 `PUT /api/v1/mngr/user/{userId}/{useYn}`로 나간다.
|
|
| 50 | 51 |
* |
| 51 | 52 |
* 저장 버튼은 `form={DETAIL_FORM_ID}`로 폼과 이어 둔다 — FoxModal이 버튼을 `foot` 슬롯에
|
| 52 | 53 |
* 그려 폼의 자손이 아니게 되는데, 네이티브 `form` 속성은 같은 문서 안에서 id만 맞으면 그 |
... | ... | @@ -59,8 +60,20 @@ |
| 59 | 60 |
); |
| 60 | 61 |
const formRef = useRef<HTMLFormElement>(null); |
| 61 | 62 |
// 토글은 화면에서 바로 반응해야 하므로 이 컴포넌트가 상태를 갖는다 — prop에서 계산하면 |
| 62 |
- // 스위치를 넘겨도 라벨이 그대로다. 저장이 되지 않으므로 이 값은 화면에만 머문다. |
|
| 63 |
+ // 스위치를 넘겨도 라벨이 그대로다. 저장하면 이 값이 폼에 실려 나간다. |
|
| 63 | 64 |
const [isActive, setIsActive] = useState(member.isActive ?? false); |
| 65 |
+ |
|
| 66 |
+ // 학년·반이 하나도 없으면 `-`가 오는데, 그때는 "학교 / -"가 아니라 학교만 보여야 한다. |
|
| 67 |
+ const formattedGradeClass = formatGradeClassNumber(member); |
|
| 68 |
+ const gradeClassNumber = |
|
| 69 |
+ formattedGradeClass === EMPTY_FIELD_PLACEHOLDER ? null : formattedGradeClass; |
|
| 70 |
+ |
|
| 71 |
+ // 저장에 성공하면 닫는다. 목록은 Server Action의 revalidatePath가 다시 그린다. |
|
| 72 |
+ useEffect(() => {
|
|
| 73 |
+ if (state.status === 'success') {
|
|
| 74 |
+ onClose(); |
|
| 75 |
+ } |
|
| 76 |
+ }, [state, onClose]); |
|
| 64 | 77 |
|
| 65 | 78 |
const items: FoxDescriptionItem[] = [ |
| 66 | 79 |
{ key: 'name', term: '이름', description: member.name },
|
... | ... | @@ -68,7 +81,7 @@ |
| 68 | 81 |
{
|
| 69 | 82 |
key: 'phoneNumber', |
| 70 | 83 |
term: '휴대전화 번호', |
| 71 |
- description: formatOptionalValue(member.phoneNumber), |
|
| 84 |
+ description: formatOptionalValue(formatPhoneNumber(member.phoneNumber)), |
|
| 72 | 85 |
}, |
| 73 | 86 |
{
|
| 74 | 87 |
key: 'email', |
... | ... | @@ -83,17 +96,15 @@ |
| 83 | 96 |
{
|
| 84 | 97 |
key: 'guardian', |
| 85 | 98 |
term: '보호자 이름 / 연락처', |
| 86 |
- description: joinPair(member.guardianName, member.guardianPhoneNumber), |
|
| 99 |
+ description: joinPair( |
|
| 100 |
+ member.guardianName, |
|
| 101 |
+ formatPhoneNumber(member.guardianPhoneNumber) |
|
| 102 |
+ ), |
|
| 87 | 103 |
}, |
| 88 | 104 |
{
|
| 89 | 105 |
key: 'school', |
| 90 | 106 |
term: '학교 / 학년·반·번호', |
| 91 |
- description: joinPair( |
|
| 92 |
- member.schoolName, |
|
| 93 |
- formatGradeClassNumber(member) === '-' |
|
| 94 |
- ? null |
|
| 95 |
- : formatGradeClassNumber(member) |
|
| 96 |
- ), |
|
| 107 |
+ description: joinPair(member.schoolName, gradeClassNumber), |
|
| 97 | 108 |
}, |
| 98 | 109 |
{
|
| 99 | 110 |
key: 'isActive', |
--- app/(protected)/(basic)/students/_components/student-list.tsx
+++ app/(protected)/(basic)/students/_components/student-list.tsx
... | ... | @@ -13,7 +13,11 @@ |
| 13 | 13 |
FoxArrowsDownUpIcon, |
| 14 | 14 |
FoxDownloadSimpleIcon, |
| 15 | 15 |
} from '@fox/core/icons'; |
| 16 |
-import type { StudentMember } from '@/lib/domain/student-member';
|
|
| 16 |
+import { formatPhoneNumber } from '@/lib/domain/phone-number';
|
|
| 17 |
+import {
|
|
| 18 |
+ formatGradeClassNumber, |
|
| 19 |
+ type StudentMember, |
|
| 20 |
+} from '@/lib/domain/student-member'; |
|
| 17 | 21 |
import {
|
| 18 | 22 |
STUDENT_MEMBERS_EXCEL_PATH, |
| 19 | 23 |
STUDENT_MEMBER_PAGE_SIZE_OPTIONS, |
... | ... | @@ -39,16 +43,6 @@ |
| 39 | 43 |
/** 값이 없으면 시안처럼 자리를 비우지 않고 `-`로 채운다. */ |
| 40 | 44 |
function text(value: string | number | null | undefined) {
|
| 41 | 45 |
return value === null || value === undefined || value === '' ? '-' : String(value); |
| 42 |
-} |
|
| 43 |
- |
|
| 44 |
-/** 학년·반·번호는 시안이 "2학년 5반 7번" 한 칸으로 묶는다. */ |
|
| 45 |
-function formatClass(member: StudentMember) {
|
|
| 46 |
- const parts = [ |
|
| 47 |
- member.grade === null ? null : `${member.grade}학년`,
|
|
| 48 |
- member.classNumber === null ? null : `${member.classNumber}반`,
|
|
| 49 |
- member.studentNumber === null ? null : `${member.studentNumber}번`,
|
|
| 50 |
- ].filter(Boolean); |
|
| 51 |
- return parts.length > 0 ? parts.join(' ') : '-';
|
|
| 52 | 46 |
} |
| 53 | 47 |
|
| 54 | 48 |
/** |
... | ... | @@ -94,7 +88,7 @@ |
| 94 | 88 |
key: 'phoneNumber', |
| 95 | 89 |
header: '휴대전화번호', |
| 96 | 90 |
width: 160, |
| 97 |
- render: (row) => text(row.phoneNumber), |
|
| 91 |
+ render: (row) => text(formatPhoneNumber(row.phoneNumber)), |
|
| 98 | 92 |
}, |
| 99 | 93 |
{ key: 'email', header: '이메일', width: 200, render: (row) => text(row.email) },
|
| 100 | 94 |
{
|
... | ... | @@ -113,7 +107,7 @@ |
| 113 | 107 |
width: 160, |
| 114 | 108 |
render: (row) => text(row.schoolName), |
| 115 | 109 |
}, |
| 116 |
- { key: 'classInfo', header: '학년/반/번호', width: 160, render: formatClass },
|
|
| 110 |
+ { key: 'classInfo', header: '학년/반/번호', width: 160, render: formatGradeClassNumber },
|
|
| 117 | 111 |
{
|
| 118 | 112 |
key: 'guardianName', |
| 119 | 113 |
header: '보호자이름', |
... | ... | @@ -124,7 +118,7 @@ |
| 124 | 118 |
key: 'guardianPhoneNumber', |
| 125 | 119 |
header: '보호자연락처', |
| 126 | 120 |
width: 160, |
| 127 |
- render: (row) => text(row.guardianPhoneNumber), |
|
| 121 |
+ render: (row) => text(formatPhoneNumber(row.guardianPhoneNumber)), |
|
| 128 | 122 |
}, |
| 129 | 123 |
{
|
| 130 | 124 |
key: 'joinedAt', |
+++ app/(protected)/(basic)/system/codes/_actions.ts
... | ... | @@ -0,0 +1,217 @@ |
| 1 | +'use server'; | |
| 2 | + | |
| 3 | +import { revalidatePath } from 'next/cache'; | |
| 4 | +import { verifySession } from '@/lib/auth/dal'; | |
| 5 | +import { | |
| 6 | + createCodeDetail, | |
| 7 | + createCodeGroup, | |
| 8 | + deleteCodeDetail, | |
| 9 | + deleteCodeGroup, | |
| 10 | + updateCodeDetail, | |
| 11 | + updateCodeGroup, | |
| 12 | +} from '@/lib/data/repositories/common-code-repository'; | |
| 13 | +import { | |
| 14 | + validateCommonCodeDetail, | |
| 15 | + validateCommonCodeGroup, | |
| 16 | + type CommonCodeFormState, | |
| 17 | +} from '@/lib/domain/common-code-form'; | |
| 18 | +import { COMMON_CODES_PATH } from '@/lib/domain/common-code-query'; | |
| 19 | + | |
| 20 | +/** | |
| 21 | + * 코드관리 등록/수정/삭제 Server Action(기획 SYS_COD_001). | |
| 22 | + * | |
| 23 | + * **모든 Action이 `verifySession()`으로 시작한다** — Server Action은 UI를 거치지 않고 직접 | |
| 24 | + * POST될 수 있어 이 확인이 유일한 최종 방어선이다. | |
| 25 | + * | |
| 26 | + * 검증은 화면이 아니라 여기서 확정한다(`lib/domain/common-code-form.ts`의 규칙을 호출) — | |
| 27 | + * 화면의 required 속성과 읽기 전용 표시는 편의일 뿐 신뢰 경계가 아니다. | |
| 28 | + * | |
| 29 | + * **수정 대상(코드ID)은 읽기 전용 입력이 아니라 hidden 필드에서 읽는다** — 읽기 전용 칸은 | |
| 30 | + * 위조될 수 있고, 위조되더라도 대상이 바뀌면 안 되기 때문이다. | |
| 31 | + */ | |
| 32 | + | |
| 33 | +const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.'; | |
| 34 | +const SAVE_FAILED_MESSAGE = '저장하지 못했습니다. 잠시 후 다시 시도해 주세요.'; | |
| 35 | +const DELETE_FAILED_MESSAGE = '삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.'; | |
| 36 | + | |
| 37 | +function readString(formData: FormData, key: string): string { | |
| 38 | + const value = formData.get(key); | |
| 39 | + return typeof value === 'string' ? value : ''; | |
| 40 | +} | |
| 41 | + | |
| 42 | +/** | |
| 43 | + * 폼 숫자 입력을 파싱한다. `Number('')`이 조용히 `0`이 되는 함정을 피하려고 빈 문자열은 | |
| 44 | + * 명시적으로 `NaN`으로 둔다 — 그래야 검증이 "값이 비었음"을 잡아낸다. | |
| 45 | + */ | |
| 46 | +function parseFormNumber(formData: FormData, key: string): number { | |
| 47 | + const trimmed = readString(formData, key).trim(); | |
| 48 | + return trimmed === '' ? NaN : Number(trimmed); | |
| 49 | +} | |
| 50 | + | |
| 51 | +/* ── 공통코드 ─────────────────────────────────────────────────────────────── */ | |
| 52 | + | |
| 53 | +/** 기획 [공통코드 등록] 팝업. */ | |
| 54 | +export async function createCodeGroupAction( | |
| 55 | + _prevState: CommonCodeFormState, | |
| 56 | + formData: FormData | |
| 57 | +): Promise<CommonCodeFormState> { | |
| 58 | + await verifySession(); | |
| 59 | + | |
| 60 | + const validation = validateCommonCodeGroup({ | |
| 61 | + comCd: readString(formData, 'comCd'), | |
| 62 | + name: readString(formData, 'name'), | |
| 63 | + description: readString(formData, 'description'), | |
| 64 | + }); | |
| 65 | + if (!validation.ok) { | |
| 66 | + return { status: 'error', errors: validation.errors }; | |
| 67 | + } | |
| 68 | + | |
| 69 | + try { | |
| 70 | + await createCodeGroup(validation.values); | |
| 71 | + } catch { | |
| 72 | + return { status: 'error', message: SAVE_FAILED_MESSAGE }; | |
| 73 | + } | |
| 74 | + | |
| 75 | + revalidatePath(COMMON_CODES_PATH); | |
| 76 | + return { status: 'success' }; | |
| 77 | +} | |
| 78 | + | |
| 79 | +/** 기획 [공통코드 수정] 팝업 — 코드ID는 대상을 가리키는 값이라 바뀌지 않는다. */ | |
| 80 | +export async function updateCodeGroupAction( | |
| 81 | + _prevState: CommonCodeFormState, | |
| 82 | + formData: FormData | |
| 83 | +): Promise<CommonCodeFormState> { | |
| 84 | + await verifySession(); | |
| 85 | + | |
| 86 | + const targetComCd = readString(formData, 'targetComCd').trim(); | |
| 87 | + if (!targetComCd) { | |
| 88 | + return { status: 'error', message: INVALID_REQUEST_MESSAGE }; | |
| 89 | + } | |
| 90 | + | |
| 91 | + const validation = validateCommonCodeGroup({ | |
| 92 | + comCd: targetComCd, | |
| 93 | + name: readString(formData, 'name'), | |
| 94 | + description: readString(formData, 'description'), | |
| 95 | + }); | |
| 96 | + if (!validation.ok) { | |
| 97 | + return { status: 'error', errors: validation.errors }; | |
| 98 | + } | |
| 99 | + | |
| 100 | + try { | |
| 101 | + await updateCodeGroup(targetComCd, validation.values); | |
| 102 | + } catch { | |
| 103 | + return { status: 'error', message: SAVE_FAILED_MESSAGE }; | |
| 104 | + } | |
| 105 | + | |
| 106 | + revalidatePath(COMMON_CODES_PATH); | |
| 107 | + return { status: 'success' }; | |
| 108 | +} | |
| 109 | + | |
| 110 | +/** | |
| 111 | + * 기획 ④ 삭제 — 확인 얼럿은 화면이 띄우고 여기서는 인증과 입력만 확인한다. | |
| 112 | + * | |
| 113 | + * 폼 제출이 아니라 얼럿의 [삭제] 클릭에 반응하는 단발 호출이라 `useActionState`의 | |
| 114 | + * (prevState, formData) 규약 대신 식별자를 직접 받는다. | |
| 115 | + */ | |
| 116 | +export async function deleteCodeGroupAction( | |
| 117 | + comCd: string | |
| 118 | +): Promise<CommonCodeFormState> { | |
| 119 | + await verifySession(); | |
| 120 | + | |
| 121 | + if (!comCd) { | |
| 122 | + return { status: 'error', message: INVALID_REQUEST_MESSAGE }; | |
| 123 | + } | |
| 124 | + | |
| 125 | + try { | |
| 126 | + await deleteCodeGroup(comCd); | |
| 127 | + } catch { | |
| 128 | + return { status: 'error', message: DELETE_FAILED_MESSAGE }; | |
| 129 | + } | |
| 130 | + | |
| 131 | + revalidatePath(COMMON_CODES_PATH); | |
| 132 | + return { status: 'success' }; | |
| 133 | +} | |
| 134 | + | |
| 135 | +/* ── 상세코드 ─────────────────────────────────────────────────────────────── */ | |
| 136 | + | |
| 137 | +/** 기획 [상세코드 등록] 팝업 — 코드ID는 좌측에서 고른 공통코드다. */ | |
| 138 | +export async function createCodeDetailAction( | |
| 139 | + _prevState: CommonCodeFormState, | |
| 140 | + formData: FormData | |
| 141 | +): Promise<CommonCodeFormState> { | |
| 142 | + await verifySession(); | |
| 143 | + | |
| 144 | + const validation = validateCommonCodeDetail({ | |
| 145 | + comCd: readString(formData, 'comCd'), | |
| 146 | + comDtlCd: readString(formData, 'comDtlCd'), | |
| 147 | + name: readString(formData, 'name'), | |
| 148 | + description: readString(formData, 'description'), | |
| 149 | + sortSeq: parseFormNumber(formData, 'sortSeq'), | |
| 150 | + }); | |
| 151 | + if (!validation.ok) { | |
| 152 | + return { status: 'error', errors: validation.errors }; | |
| 153 | + } | |
| 154 | + | |
| 155 | + try { | |
| 156 | + await createCodeDetail(validation.values); | |
| 157 | + } catch { | |
| 158 | + return { status: 'error', message: SAVE_FAILED_MESSAGE }; | |
| 159 | + } | |
| 160 | + | |
| 161 | + revalidatePath(COMMON_CODES_PATH); | |
| 162 | + return { status: 'success' }; | |
| 163 | +} | |
| 164 | + | |
| 165 | +/** 기획 [상세코드 수정] 팝업 — 코드ID·코드는 대상을 가리키는 값이라 바뀌지 않는다. */ | |
| 166 | +export async function updateCodeDetailAction( | |
| 167 | + _prevState: CommonCodeFormState, | |
| 168 | + formData: FormData | |
| 169 | +): Promise<CommonCodeFormState> { | |
| 170 | + await verifySession(); | |
| 171 | + | |
| 172 | + const targetComCd = readString(formData, 'targetComCd').trim(); | |
| 173 | + const targetComDtlCd = readString(formData, 'targetComDtlCd').trim(); | |
| 174 | + if (!targetComCd || !targetComDtlCd) { | |
| 175 | + return { status: 'error', message: INVALID_REQUEST_MESSAGE }; | |
| 176 | + } | |
| 177 | + | |
| 178 | + const validation = validateCommonCodeDetail({ | |
| 179 | + comCd: targetComCd, | |
| 180 | + comDtlCd: readString(formData, 'comDtlCd'), | |
| 181 | + name: readString(formData, 'name'), | |
| 182 | + description: readString(formData, 'description'), | |
| 183 | + sortSeq: parseFormNumber(formData, 'sortSeq'), | |
| 184 | + }); | |
| 185 | + if (!validation.ok) { | |
| 186 | + return { status: 'error', errors: validation.errors }; | |
| 187 | + } | |
| 188 | + | |
| 189 | + try { | |
| 190 | + await updateCodeDetail(targetComCd, targetComDtlCd, validation.values); | |
| 191 | + } catch { | |
| 192 | + return { status: 'error', message: SAVE_FAILED_MESSAGE }; | |
| 193 | + } | |
| 194 | + | |
| 195 | + revalidatePath(COMMON_CODES_PATH); | |
| 196 | + return { status: 'success' }; | |
| 197 | +} | |
| 198 | + | |
| 199 | +export async function deleteCodeDetailAction( | |
| 200 | + comCd: string, | |
| 201 | + comDtlCd: string | |
| 202 | +): Promise<CommonCodeFormState> { | |
| 203 | + await verifySession(); | |
| 204 | + | |
| 205 | + if (!comCd || !comDtlCd) { | |
| 206 | + return { status: 'error', message: INVALID_REQUEST_MESSAGE }; | |
| 207 | + } | |
| 208 | + | |
| 209 | + try { | |
| 210 | + await deleteCodeDetail(comCd, comDtlCd); | |
| 211 | + } catch { | |
| 212 | + return { status: 'error', message: DELETE_FAILED_MESSAGE }; | |
| 213 | + } | |
| 214 | + | |
| 215 | + revalidatePath(COMMON_CODES_PATH); | |
| 216 | + return { status: 'success' }; | |
| 217 | +} |
+++ app/(protected)/(basic)/system/codes/_components/code-detail-modal.tsx
... | ... | @@ -0,0 +1,163 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 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 | +import { useFeedback } from '@/app/_hooks/use-feedback'; | |
| 9 | +import type { CommonCodeDetail } from '@/lib/domain/common-code'; | |
| 10 | +import { INITIAL_COMMON_CODE_FORM_STATE } from '@/lib/domain/common-code-form'; | |
| 11 | +import { createCodeDetailAction, updateCodeDetailAction } from '../_actions'; | |
| 12 | +import styles from './code-form.module.scss'; | |
| 13 | +import { submitFormAction } from '@/app/_hooks/submit-form-action'; | |
| 14 | + | |
| 15 | +interface CodeDetailModalProps { | |
| 16 | + /** 소속 공통코드 — 좌측에서 고른 값이라 팝업에서는 바꿀 수 없다. */ | |
| 17 | + comCd: string; | |
| 18 | + /** 수정 대상. 없으면 등록 팝업이다. */ | |
| 19 | + detail?: CommonCodeDetail; | |
| 20 | + /** 등록 시 채워 둘 정렬번호(기존 개수 + 1). */ | |
| 21 | + nextSortSeq: number; | |
| 22 | + onClose: () => void; | |
| 23 | +} | |
| 24 | + | |
| 25 | +/** | |
| 26 | + * 상세코드 등록/수정 팝업 — 기획 1842:16179 / 1842:16262. | |
| 27 | + * | |
| 28 | + * 코드ID는 좌측에서 고른 공통코드라 **항상 읽기 전용**이다(기획 ①: "상위코드 고정된 경우 비활성 | |
| 29 | + * 처리"). 수정에서는 코드(`comDtlCd`)도 대상을 가리키는 값이라 바꿀 수 없다 — 백엔드가 두 코드로 | |
| 30 | + * 행을 찾기 때문에 바꾸면 다른 행을 수정하게 된다. | |
| 31 | + * | |
| 32 | + * ⚠️ **등록 시 코드설명은 저장되지 않는다** — 백엔드 INSERT에 `DTL_CD_EXPLN`이 빠져 있다. | |
| 33 | + * 시안대로 칸은 두고 백엔드에 수정을 요청했다(Repository 주석 참조). 수정에서는 정상 저장된다. | |
| 34 | + */ | |
| 35 | +export function CodeDetailModal({ | |
| 36 | + comCd, | |
| 37 | + detail, | |
| 38 | + nextSortSeq, | |
| 39 | + onClose, | |
| 40 | +}: CodeDetailModalProps) { | |
| 41 | + const isEdit = detail !== undefined; | |
| 42 | + const { showToast } = useFeedback(); | |
| 43 | + const formRef = useRef<HTMLFormElement>(null); | |
| 44 | + const [state, formAction, isPending] = useActionState( | |
| 45 | + isEdit ? updateCodeDetailAction : createCodeDetailAction, | |
| 46 | + INITIAL_COMMON_CODE_FORM_STATE | |
| 47 | + ); | |
| 48 | + | |
| 49 | + useEffect(() => { | |
| 50 | + if (state.status === 'success') { | |
| 51 | + showToast({ | |
| 52 | + variant: 'success', | |
| 53 | + message: isEdit ? '상세코드를 수정했습니다.' : '상세코드를 등록했습니다.', | |
| 54 | + }); | |
| 55 | + onClose(); | |
| 56 | + } | |
| 57 | + }, [state, showToast, onClose, isEdit]); | |
| 58 | + | |
| 59 | + const errors = state.status === 'error' ? (state.errors ?? {}) : {}; | |
| 60 | + | |
| 61 | + return ( | |
| 62 | + <FoxModal | |
| 63 | + open | |
| 64 | + size="sm" | |
| 65 | + title={isEdit ? '상세코드 수정' : '상세코드 등록'} | |
| 66 | + onClose={onClose} | |
| 67 | + actions={ | |
| 68 | + <> | |
| 69 | + <FoxButton type="default" size="md" label="취소" onAction={onClose} /> | |
| 70 | + <FoxButton | |
| 71 | + type="primary" | |
| 72 | + size="md" | |
| 73 | + label={isPending ? '저장 중...' : '저장'} | |
| 74 | + disabled={isPending} | |
| 75 | + onAction={() => formRef.current?.requestSubmit()} | |
| 76 | + /> | |
| 77 | + </> | |
| 78 | + } | |
| 79 | + > | |
| 80 | + <form ref={formRef} onSubmit={(event) => submitFormAction(event, formAction)} className={styles.fields}> | |
| 81 | + <input type="hidden" name={isEdit ? 'targetComCd' : 'comCd'} value={comCd} /> | |
| 82 | + {isEdit && ( | |
| 83 | + <input | |
| 84 | + type="hidden" | |
| 85 | + name="targetComDtlCd" | |
| 86 | + value={detail.comDtlCd} | |
| 87 | + /> | |
| 88 | + )} | |
| 89 | + | |
| 90 | + <div className={styles.field}> | |
| 91 | + <FoxInput | |
| 92 | + size="md" | |
| 93 | + label="코드ID" | |
| 94 | + value={comCd} | |
| 95 | + readOnly | |
| 96 | + message="상위코드가 고정된 경우 비활성 처리됩니다." | |
| 97 | + /> | |
| 98 | + </div> | |
| 99 | + | |
| 100 | + <div className={styles.field}> | |
| 101 | + <FoxInput | |
| 102 | + size="md" | |
| 103 | + name="comDtlCd" | |
| 104 | + label="코드" | |
| 105 | + requirement="required" | |
| 106 | + defaultValue={detail?.comDtlCd ?? ''} | |
| 107 | + readOnly={isEdit} | |
| 108 | + placeholder="코드를 입력하세요." | |
| 109 | + invalid={Boolean(errors.comDtlCd)} | |
| 110 | + message={errors.comDtlCd} | |
| 111 | + maxLength={50} | |
| 112 | + /> | |
| 113 | + </div> | |
| 114 | + | |
| 115 | + <div className={styles.field}> | |
| 116 | + <FoxInput | |
| 117 | + size="md" | |
| 118 | + name="name" | |
| 119 | + label="코드값의미" | |
| 120 | + requirement="required" | |
| 121 | + defaultValue={detail?.name ?? ''} | |
| 122 | + placeholder="코드값의미를 입력하세요." | |
| 123 | + invalid={Boolean(errors.name)} | |
| 124 | + message={errors.name} | |
| 125 | + maxLength={100} | |
| 126 | + /> | |
| 127 | + </div> | |
| 128 | + | |
| 129 | + <div className={styles.field}> | |
| 130 | + <FoxInput | |
| 131 | + size="md" | |
| 132 | + type="number" | |
| 133 | + name="sortSeq" | |
| 134 | + label="정렬번호" | |
| 135 | + requirement="required" | |
| 136 | + min={1} | |
| 137 | + step={1} | |
| 138 | + defaultValue={detail?.sortSeq ?? nextSortSeq} | |
| 139 | + invalid={Boolean(errors.sortSeq)} | |
| 140 | + message={errors.sortSeq} | |
| 141 | + /> | |
| 142 | + </div> | |
| 143 | + | |
| 144 | + <div className={styles.field}> | |
| 145 | + <FoxInput | |
| 146 | + size="md" | |
| 147 | + name="description" | |
| 148 | + label="코드설명" | |
| 149 | + defaultValue={detail?.description ?? ''} | |
| 150 | + placeholder="코드설명을 입력하세요." | |
| 151 | + invalid={Boolean(errors.description)} | |
| 152 | + message={errors.description} | |
| 153 | + maxLength={500} | |
| 154 | + /> | |
| 155 | + </div> | |
| 156 | + | |
| 157 | + {state.status === 'error' && state.message && ( | |
| 158 | + <FoxHelperText type="danger" message={state.message} /> | |
| 159 | + )} | |
| 160 | + </form> | |
| 161 | + </FoxModal> | |
| 162 | + ); | |
| 163 | +} |
+++ app/(protected)/(basic)/system/codes/_components/code-form.module.scss
... | ... | @@ -0,0 +1,20 @@ |
| 1 | +// 등록·수정 팝업의 입력 줄 — 꾸미기 아이템 팝업(ADM_ITM_102_p)과 같은 배치를 쓴다. | |
| 2 | +// 기획 와이어프레임에는 구분선이 없지만 그건 시각 시안이 아니라서, 이 프로젝트에서 확정된 | |
| 3 | +// 팝업 폼 모양을 따른다. | |
| 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)/system/codes/_components/code-group-modal.tsx
... | ... | @@ -0,0 +1,158 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useActionState, useEffect, useRef, useState } 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 | +import { FoxSelect } from '@fox/core/components/fox-select'; | |
| 9 | +import { useFeedback } from '@/app/_hooks/use-feedback'; | |
| 10 | +import { | |
| 11 | + CODE_ID_PREFIXES, | |
| 12 | + DEFAULT_CODE_ID_PREFIX, | |
| 13 | + applyCodeIdPrefix, | |
| 14 | + readCodeIdPrefix, | |
| 15 | + type CommonCodeGroup, | |
| 16 | +} from '@/lib/domain/common-code'; | |
| 17 | +import { INITIAL_COMMON_CODE_FORM_STATE } from '@/lib/domain/common-code-form'; | |
| 18 | +import { createCodeGroupAction, updateCodeGroupAction } from '../_actions'; | |
| 19 | +import styles from './code-form.module.scss'; | |
| 20 | +import { submitFormAction } from '@/app/_hooks/submit-form-action'; | |
| 21 | + | |
| 22 | +interface CodeGroupModalProps { | |
| 23 | + /** 수정 대상. 없으면 등록 팝업이다. */ | |
| 24 | + group?: CommonCodeGroup; | |
| 25 | + onClose: () => void; | |
| 26 | +} | |
| 27 | + | |
| 28 | +/** | |
| 29 | + * 공통코드 등록/수정 팝업 — 기획 1842:16056 / 1842:16122. | |
| 30 | + * | |
| 31 | + * 두 팝업은 제목·버튼 글자와 **코드ID의 읽기 전용 여부**만 다르고 입력 항목이 같아 한 조각으로 둔다. | |
| 32 | + * | |
| 33 | + * **분류코드는 저장되지 않는다.** `TB_SYS_COM_CD`에 해당 컬럼이 없어(확인함) 이 셀렉트는 코드ID | |
| 34 | + * 접두사를 채워 주는 입력 보조로만 동작한다 — 시안 목록이 전부 `CMS004`처럼 접두사 3자 + 일련번호라 | |
| 35 | + * 그 작명 규칙을 화면이 거드는 것이다(사용자 확정 사항). 저장되는 값은 코드ID 하나다. | |
| 36 | + * | |
| 37 | + * 저장 버튼은 foot 슬롯에 그려져 폼의 자손이 아니다 — ref로 직접 제출한다(다른 팝업과 같은 방식). | |
| 38 | + */ | |
| 39 | +export function CodeGroupModal({ group, onClose }: CodeGroupModalProps) { | |
| 40 | + const isEdit = group !== undefined; | |
| 41 | + const { showToast } = useFeedback(); | |
| 42 | + const formRef = useRef<HTMLFormElement>(null); | |
| 43 | + const [state, formAction, isPending] = useActionState( | |
| 44 | + isEdit ? updateCodeGroupAction : createCodeGroupAction, | |
| 45 | + INITIAL_COMMON_CODE_FORM_STATE | |
| 46 | + ); | |
| 47 | + | |
| 48 | + const [comCd, setComCd] = useState(group?.comCd ?? ''); | |
| 49 | + const [prefix, setPrefix] = useState( | |
| 50 | + group ? readCodeIdPrefix(group.comCd) : DEFAULT_CODE_ID_PREFIX | |
| 51 | + ); | |
| 52 | + | |
| 53 | + useEffect(() => { | |
| 54 | + if (state.status === 'success') { | |
| 55 | + showToast({ | |
| 56 | + variant: 'success', | |
| 57 | + message: isEdit ? '공통코드를 수정했습니다.' : '공통코드를 등록했습니다.', | |
| 58 | + }); | |
| 59 | + onClose(); | |
| 60 | + } | |
| 61 | + }, [state, showToast, onClose, isEdit]); | |
| 62 | + | |
| 63 | + const errors = state.status === 'error' ? (state.errors ?? {}) : {}; | |
| 64 | + | |
| 65 | + return ( | |
| 66 | + <FoxModal | |
| 67 | + open | |
| 68 | + size="sm" | |
| 69 | + title={isEdit ? '공통코드 수정' : '공통코드 등록'} | |
| 70 | + onClose={onClose} | |
| 71 | + actions={ | |
| 72 | + <> | |
| 73 | + <FoxButton type="default" size="md" label="취소" onAction={onClose} /> | |
| 74 | + <FoxButton | |
| 75 | + type="primary" | |
| 76 | + size="md" | |
| 77 | + label={isPending ? '저장 중...' : '저장'} | |
| 78 | + disabled={isPending} | |
| 79 | + onAction={() => formRef.current?.requestSubmit()} | |
| 80 | + /> | |
| 81 | + </> | |
| 82 | + } | |
| 83 | + > | |
| 84 | + <form ref={formRef} onSubmit={(event) => submitFormAction(event, formAction)} className={styles.fields}> | |
| 85 | + {/* 수정 대상은 읽기 전용 칸이 아니라 이 값으로 정해진다 — 칸이 위조돼도 대상은 안 바뀐다. */} | |
| 86 | + {isEdit && ( | |
| 87 | + <input type="hidden" name="targetComCd" value={group.comCd} /> | |
| 88 | + )} | |
| 89 | + | |
| 90 | + <div className={styles.field}> | |
| 91 | + <FoxInput | |
| 92 | + size="md" | |
| 93 | + name={isEdit ? undefined : 'comCd'} | |
| 94 | + label="코드ID" | |
| 95 | + requirement="required" | |
| 96 | + value={comCd} | |
| 97 | + onChange={setComCd} | |
| 98 | + readOnly={isEdit} | |
| 99 | + placeholder="코드ID를 입력하세요." | |
| 100 | + invalid={Boolean(errors.comCd)} | |
| 101 | + message={errors.comCd} | |
| 102 | + maxLength={50} | |
| 103 | + /> | |
| 104 | + </div> | |
| 105 | + | |
| 106 | + {!isEdit && ( | |
| 107 | + <div className={styles.field}> | |
| 108 | + <FoxSelect | |
| 109 | + size="md" | |
| 110 | + label="분류코드" | |
| 111 | + options={CODE_ID_PREFIXES.map((value) => ({ | |
| 112 | + value, | |
| 113 | + label: value, | |
| 114 | + }))} | |
| 115 | + value={prefix} | |
| 116 | + onValueChange={(next) => { | |
| 117 | + setPrefix(next); | |
| 118 | + setComCd((current) => applyCodeIdPrefix(current, next)); | |
| 119 | + }} | |
| 120 | + hint="코드ID 앞에 붙는 분류입니다. 별도로 저장되지 않습니다." | |
| 121 | + /> | |
| 122 | + </div> | |
| 123 | + )} | |
| 124 | + | |
| 125 | + <div className={styles.field}> | |
| 126 | + <FoxInput | |
| 127 | + size="md" | |
| 128 | + name="name" | |
| 129 | + label="코드ID명" | |
| 130 | + requirement="required" | |
| 131 | + defaultValue={group?.name ?? ''} | |
| 132 | + placeholder="코드ID명을 입력하세요." | |
| 133 | + invalid={Boolean(errors.name)} | |
| 134 | + message={errors.name} | |
| 135 | + maxLength={100} | |
| 136 | + /> | |
| 137 | + </div> | |
| 138 | + | |
| 139 | + <div className={styles.field}> | |
| 140 | + <FoxInput | |
| 141 | + size="md" | |
| 142 | + name="description" | |
| 143 | + label="코드ID설명" | |
| 144 | + defaultValue={group?.description ?? ''} | |
| 145 | + placeholder="코드ID설명을 입력하세요." | |
| 146 | + invalid={Boolean(errors.description)} | |
| 147 | + message={errors.description} | |
| 148 | + maxLength={500} | |
| 149 | + /> | |
| 150 | + </div> | |
| 151 | + | |
| 152 | + {state.status === 'error' && state.message && ( | |
| 153 | + <FoxHelperText type="danger" message={state.message} /> | |
| 154 | + )} | |
| 155 | + </form> | |
| 156 | + </FoxModal> | |
| 157 | + ); | |
| 158 | +} |
+++ app/(protected)/(basic)/system/codes/_components/code-management.module.scss
... | ... | @@ -0,0 +1,45 @@ |
| 1 | +// 코드관리 화면 배치 — 시안 5402:13399의 `row`(공통코드 756 + 40 + 상세코드 756 = 1552). | |
| 2 | +// 두 목록이 나란히 서는 것은 이 화면만의 배치라 @fox에 넣지 않고 여기 둔다(값은 @fox 토큰). | |
| 3 | + | |
| 4 | +@use "@fox/styles/abstracts" as fox; | |
| 5 | + | |
| 6 | +.page { | |
| 7 | + display: flex; | |
| 8 | + inline-size: 100%; | |
| 9 | + flex-direction: column; | |
| 10 | +} | |
| 11 | + | |
| 12 | +// 좁은 화면에서는 두 목록이 위아래로 선다 — 나란히 두면 표가 눌려 읽을 수 없다. | |
| 13 | +.columns { | |
| 14 | + display: grid; | |
| 15 | + inline-size: 100%; | |
| 16 | + gap: fox.gap(9); | |
| 17 | + grid-template-columns: 1fr; | |
| 18 | +} | |
| 19 | + | |
| 20 | +@include fox.pc { | |
| 21 | + .columns { | |
| 22 | + grid-template-columns: 1fr 1fr; | |
| 23 | + } | |
| 24 | +} | |
| 25 | + | |
| 26 | +.panel { | |
| 27 | + display: flex; | |
| 28 | + // 표가 넓어도 칸이 늘어나지 않게 한다 — 늘어나면 옆 칸을 밀어 두 목록의 폭이 어긋난다. | |
| 29 | + min-inline-size: 0; | |
| 30 | + flex-direction: column; | |
| 31 | +} | |
| 32 | + | |
| 33 | +// 설명 열은 한 줄로 줄이고 넘치면 말줄임한다. | |
| 34 | +// | |
| 35 | +// 시안의 셀 컴포넌트에는 말줄임 스타일이 없지만(전문이 들어 있고 word-break만 걸려 있다), | |
| 36 | +// 프레임 높이가 44로 고정이라 Figma가 넘치는 글자를 잘라 보여준다. CSS는 대신 감싸므로 그대로 | |
| 37 | +// 두면 행마다 높이가 달라진다 — 설명은 자유 입력이라 길이를 예측할 수 없다. | |
| 38 | +// 잘린 글자는 title로 남겨 마우스를 올리면 전문이 보인다. | |
| 39 | +.truncate { | |
| 40 | + display: block; | |
| 41 | + inline-size: 100%; | |
| 42 | + overflow: hidden; | |
| 43 | + text-overflow: ellipsis; | |
| 44 | + white-space: nowrap; | |
| 45 | +} |
+++ app/(protected)/(basic)/system/codes/_components/code-management.tsx
... | ... | @@ -0,0 +1,343 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useRouter } from 'next/navigation'; | |
| 4 | +import { useState, useTransition } from 'react'; | |
| 5 | +import { FoxButton } from '@fox/core/components/fox-button'; | |
| 6 | +import { FoxHeadingGroup } from '@fox/core/components/fox-heading-group'; | |
| 7 | +import { | |
| 8 | + FoxListContainer, | |
| 9 | + type FoxListColumn, | |
| 10 | +} from '@fox/core/components/fox-list-container'; | |
| 11 | +import { FoxPageHeader } from '@fox/core/components/fox-page-header'; | |
| 12 | +import { FoxPlusIcon } from '@fox/core/icons'; | |
| 13 | +import { useFeedback } from '@/app/_hooks/use-feedback'; | |
| 14 | +import { | |
| 15 | + formatOptionalText, | |
| 16 | + type CommonCodeDetail, | |
| 17 | + type CommonCodeGroup, | |
| 18 | +} from '@/lib/domain/common-code'; | |
| 19 | +import { | |
| 20 | + COMMON_CODE_PAGE_SIZE, | |
| 21 | + COMMON_CODE_SEARCH_FIELD_OPTIONS, | |
| 22 | + buildCommonCodeHref, | |
| 23 | + type CommonCodeQuery, | |
| 24 | + type CommonCodeSearchField, | |
| 25 | +} from '@/lib/domain/common-code-query'; | |
| 26 | +import { deleteCodeDetailAction, deleteCodeGroupAction } from '../_actions'; | |
| 27 | +import { CodeDetailModal } from './code-detail-modal'; | |
| 28 | +import { CodeGroupModal } from './code-group-modal'; | |
| 29 | +import { CodeRowActions } from './code-row-actions'; | |
| 30 | +import styles from './code-management.module.scss'; | |
| 31 | + | |
| 32 | +interface CodeManagementProps { | |
| 33 | + query: CommonCodeQuery; | |
| 34 | + groups: CommonCodeGroup[]; | |
| 35 | + groupTotalCount: number; | |
| 36 | + groupPage: number; | |
| 37 | + groupTotalPages: number; | |
| 38 | + /** 화면이 실제로 보여주는 선택 — URL 값이 목록에 없으면 서버가 첫 행으로 바꿔 넘긴다. */ | |
| 39 | + selectedComCd: string | null; | |
| 40 | + details: CommonCodeDetail[]; | |
| 41 | + detailTotalCount: number; | |
| 42 | + detailPage: number; | |
| 43 | + detailTotalPages: number; | |
| 44 | + nextSortSeq: number; | |
| 45 | +} | |
| 46 | + | |
| 47 | +/** | |
| 48 | + * 설명 칸 — 한 줄로 줄이고 넘치면 말줄임한다(스타일 주석 참조). 잘린 글자를 읽을 방법이 | |
| 49 | + * 있어야 하므로 전문을 `title`로 남긴다. | |
| 50 | + */ | |
| 51 | +function Truncated({ text }: { text: string | null }) { | |
| 52 | + const value = formatOptionalText(text); | |
| 53 | + return ( | |
| 54 | + <span className={styles.truncate} title={text ?? undefined}> | |
| 55 | + {value} | |
| 56 | + </span> | |
| 57 | + ); | |
| 58 | +} | |
| 59 | + | |
| 60 | +/** 어느 팝업이 열려 있는지. 한 번에 하나만 열린다. */ | |
| 61 | +type OpenModal = | |
| 62 | + | { kind: 'group-create' } | |
| 63 | + | { kind: 'group-edit'; group: CommonCodeGroup } | |
| 64 | + | { kind: 'detail-create' } | |
| 65 | + | { kind: 'detail-edit'; detail: CommonCodeDetail } | |
| 66 | + | null; | |
| 67 | + | |
| 68 | +/** | |
| 69 | + * 코드관리 — 기획 SYS_COD_001, 시안 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) 5402:13399 | |
| 70 | + * | |
| 71 | + * 목록이 둘이지만 화면은 하나다. 좌측 공통코드를 **행 전체로 눌러** 고르면 우측 상세코드가 그 | |
| 72 | + * 그룹으로 갱신된다(기획 ④) — 선택은 URL에 실리므로 갱신은 서버가 다시 그려 준다. | |
| 73 | + * | |
| 74 | + * 두 목록 모두 `FoxListContainer`를 쓰되 화면 제목은 위에서 한 번만 그린다(`FoxPageHeader`). | |
| 75 | + * 패널 이름은 `FoxHeadingGroup`이다 — 화면에 여럿 설 수 있는 구역 제목이라 `<h2>`로 나간다. | |
| 76 | + * | |
| 77 | + * 우측에는 검색이 없다(시안) — 상세코드는 선택된 그룹에 속한 것이 전부라 걸러 낼 대상이 아니다. | |
| 78 | + */ | |
| 79 | +export function CodeManagement({ | |
| 80 | + query, | |
| 81 | + groups, | |
| 82 | + groupTotalCount, | |
| 83 | + groupPage, | |
| 84 | + groupTotalPages, | |
| 85 | + selectedComCd, | |
| 86 | + details, | |
| 87 | + detailTotalCount, | |
| 88 | + detailPage, | |
| 89 | + detailTotalPages, | |
| 90 | + nextSortSeq, | |
| 91 | +}: CodeManagementProps) { | |
| 92 | + const router = useRouter(); | |
| 93 | + const { showAlert, hideAlert, showToast } = useFeedback(); | |
| 94 | + const [modal, setModal] = useState<OpenModal>(null); | |
| 95 | + const [isDeleting, startDeleting] = useTransition(); | |
| 96 | + | |
| 97 | + function go(patch: Partial<CommonCodeQuery>) { | |
| 98 | + router.replace(buildCommonCodeHref(query, patch)); | |
| 99 | + } | |
| 100 | + | |
| 101 | + /** | |
| 102 | + * 삭제는 되돌릴 수 없어 확인 얼럿을 거친다(기획 ④). 성공 뒤 화면 갱신은 Server Action의 | |
| 103 | + * `revalidatePath`가 맡으므로 여기서는 알림만 띄운다. | |
| 104 | + */ | |
| 105 | + function confirmDelete( | |
| 106 | + title: string, | |
| 107 | + message: string, | |
| 108 | + run: () => Promise<{ status: string; message?: string }> | |
| 109 | + ) { | |
| 110 | + showAlert({ | |
| 111 | + variant: 'danger', | |
| 112 | + title, | |
| 113 | + message, | |
| 114 | + actions: ( | |
| 115 | + <> | |
| 116 | + <FoxButton type="default" size="md" label="취소" onAction={hideAlert} /> | |
| 117 | + <FoxButton | |
| 118 | + type="error" | |
| 119 | + size="md" | |
| 120 | + label="삭제" | |
| 121 | + onAction={() => { | |
| 122 | + hideAlert(); | |
| 123 | + startDeleting(async () => { | |
| 124 | + const result = await run(); | |
| 125 | + if (result.status === 'error') { | |
| 126 | + showToast({ | |
| 127 | + variant: 'danger', | |
| 128 | + message: result.message ?? '삭제하지 못했습니다.', | |
| 129 | + }); | |
| 130 | + return; | |
| 131 | + } | |
| 132 | + showToast({ variant: 'success', message: '삭제했습니다.' }); | |
| 133 | + }); | |
| 134 | + }} | |
| 135 | + /> | |
| 136 | + </> | |
| 137 | + ), | |
| 138 | + }); | |
| 139 | + } | |
| 140 | + | |
| 141 | + const groupColumns: FoxListColumn<CommonCodeGroup>[] = [ | |
| 142 | + { | |
| 143 | + key: 'no', | |
| 144 | + header: '번호', | |
| 145 | + width: 80, | |
| 146 | + render: (_row, index) => (groupPage - 1) * COMMON_CODE_PAGE_SIZE + index + 1, | |
| 147 | + }, | |
| 148 | + { key: 'comCd', header: '코드ID', width: 120 }, | |
| 149 | + { key: 'name', header: '코드명', width: 160, emphasis: true }, | |
| 150 | + { | |
| 151 | + key: 'description', | |
| 152 | + header: '코드설명', | |
| 153 | + width: 156, | |
| 154 | + render: (row) => <Truncated text={row.description} />, | |
| 155 | + }, | |
| 156 | + { | |
| 157 | + key: 'createdAt', | |
| 158 | + header: '생성일', | |
| 159 | + width: 120, | |
| 160 | + render: (row) => formatOptionalText(row.createdAt), | |
| 161 | + }, | |
| 162 | + { | |
| 163 | + key: 'actions', | |
| 164 | + header: '관리', | |
| 165 | + width: 120, | |
| 166 | + render: (row) => ( | |
| 167 | + <CodeRowActions | |
| 168 | + target={row.comCd} | |
| 169 | + deleting={isDeleting} | |
| 170 | + onEdit={() => setModal({ kind: 'group-edit', group: row })} | |
| 171 | + onDelete={() => | |
| 172 | + confirmDelete( | |
| 173 | + '공통코드를 삭제하시겠습니까?', | |
| 174 | + `${row.name}(${row.comCd})을(를) 삭제합니다. 속한 상세코드도 함께 쓸 수 없게 됩니다.`, | |
| 175 | + () => deleteCodeGroupAction(row.comCd) | |
| 176 | + ) | |
| 177 | + } | |
| 178 | + /> | |
| 179 | + ), | |
| 180 | + }, | |
| 181 | + ]; | |
| 182 | + | |
| 183 | + const detailColumns: FoxListColumn<CommonCodeDetail>[] = [ | |
| 184 | + { | |
| 185 | + key: 'no', | |
| 186 | + header: '번호', | |
| 187 | + width: 80, | |
| 188 | + // 기획의 상세코드 목록은 이 자리에 정렬번호를 보여준다(FSC01→1, FSC10→10). | |
| 189 | + render: (row) => row.sortSeq, | |
| 190 | + }, | |
| 191 | + { key: 'comDtlCd', header: '상세코드ID', width: 120 }, | |
| 192 | + { key: 'name', header: '상세코드명', width: 120, emphasis: true }, | |
| 193 | + { | |
| 194 | + key: 'description', | |
| 195 | + header: '상세코드설명', | |
| 196 | + width: 196, | |
| 197 | + render: (row) => <Truncated text={row.description} />, | |
| 198 | + }, | |
| 199 | + { | |
| 200 | + key: 'createdAt', | |
| 201 | + header: '생성일', | |
| 202 | + width: 120, | |
| 203 | + render: (row) => formatOptionalText(row.createdAt), | |
| 204 | + }, | |
| 205 | + { | |
| 206 | + key: 'actions', | |
| 207 | + header: '관리', | |
| 208 | + width: 120, | |
| 209 | + render: (row) => ( | |
| 210 | + <CodeRowActions | |
| 211 | + target={row.comDtlCd} | |
| 212 | + deleting={isDeleting} | |
| 213 | + onEdit={() => setModal({ kind: 'detail-edit', detail: row })} | |
| 214 | + onDelete={() => | |
| 215 | + confirmDelete( | |
| 216 | + '상세코드를 삭제하시겠습니까?', | |
| 217 | + `${row.name}(${row.comDtlCd})을(를) 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`, | |
| 218 | + () => deleteCodeDetailAction(row.comCd, row.comDtlCd) | |
| 219 | + ) | |
| 220 | + } | |
| 221 | + /> | |
| 222 | + ), | |
| 223 | + }, | |
| 224 | + ]; | |
| 225 | + | |
| 226 | + return ( | |
| 227 | + <div className={styles.page}> | |
| 228 | + <FoxPageHeader | |
| 229 | + title="코드관리" | |
| 230 | + breadcrumb={[ | |
| 231 | + { label: '홈', href: '/' }, | |
| 232 | + { label: '시스템관리' }, | |
| 233 | + { label: '코드관리' }, | |
| 234 | + ]} | |
| 235 | + /> | |
| 236 | + | |
| 237 | + <div className={styles.columns}> | |
| 238 | + <section className={styles.panel}> | |
| 239 | + <FoxHeadingGroup title="공통코드 목록" /> | |
| 240 | + <FoxListContainer<CommonCodeGroup> | |
| 241 | + caption="공통코드 목록" | |
| 242 | + columns={groupColumns} | |
| 243 | + rows={groups} | |
| 244 | + rowKey={(row) => row.comCd} | |
| 245 | + totalCount={groupTotalCount} | |
| 246 | + emptyMessage="조회된 코드가 없습니다. 다른 검색어로 다시 시도해 주세요." | |
| 247 | + searchFields={COMMON_CODE_SEARCH_FIELD_OPTIONS.map((option) => ({ | |
| 248 | + value: option.value, | |
| 249 | + label: option.label, | |
| 250 | + }))} | |
| 251 | + searchField={query.searchField} | |
| 252 | + keyword={query.keyword} | |
| 253 | + toolbarActions={ | |
| 254 | + <FoxButton | |
| 255 | + type="primary" | |
| 256 | + size="md" | |
| 257 | + leadingIcon={<FoxPlusIcon />} | |
| 258 | + label="신규 등록" | |
| 259 | + onAction={() => setModal({ kind: 'group-create' })} | |
| 260 | + /> | |
| 261 | + } | |
| 262 | + onRowSelect={(row) => go({ comCd: row.comCd })} | |
| 263 | + page={groupPage} | |
| 264 | + totalPages={groupTotalPages} | |
| 265 | + buildHref={(patch) => | |
| 266 | + buildCommonCodeHref(query, { page: Number(patch.page ?? 1) }) | |
| 267 | + } | |
| 268 | + onQueryChange={(patch) => { | |
| 269 | + if (patch.keyword !== undefined || patch.searchField !== undefined) { | |
| 270 | + go({ | |
| 271 | + keyword: String(patch.keyword ?? ''), | |
| 272 | + searchField: (patch.searchField ?? | |
| 273 | + query.searchField) as CommonCodeSearchField, | |
| 274 | + // 검색하면 목록 자체가 달라지므로 선택도 첫 행으로 다시 잡게 비운다. | |
| 275 | + comCd: null, | |
| 276 | + page: 1, | |
| 277 | + }); | |
| 278 | + } | |
| 279 | + }} | |
| 280 | + /> | |
| 281 | + </section> | |
| 282 | + | |
| 283 | + <section className={styles.panel}> | |
| 284 | + <FoxHeadingGroup title="상세코드 목록" /> | |
| 285 | + <FoxListContainer<CommonCodeDetail> | |
| 286 | + caption="상세코드 목록" | |
| 287 | + columns={detailColumns} | |
| 288 | + rows={details} | |
| 289 | + rowKey={(row) => `${row.comCd}:${row.comDtlCd}`} | |
| 290 | + totalCount={detailTotalCount} | |
| 291 | + emptyMessage={ | |
| 292 | + selectedComCd | |
| 293 | + ? '등록된 상세코드가 없습니다.' | |
| 294 | + : '왼쪽에서 공통코드를 선택해 주세요.' | |
| 295 | + } | |
| 296 | + searchHidden | |
| 297 | + toolbarActions={ | |
| 298 | + <FoxButton | |
| 299 | + type="primary" | |
| 300 | + size="md" | |
| 301 | + leadingIcon={<FoxPlusIcon />} | |
| 302 | + label="상세코드 등록" | |
| 303 | + // 소속될 공통코드가 없으면 등록할 수 없다. | |
| 304 | + disabled={!selectedComCd} | |
| 305 | + onAction={() => setModal({ kind: 'detail-create' })} | |
| 306 | + /> | |
| 307 | + } | |
| 308 | + page={detailPage} | |
| 309 | + totalPages={detailTotalPages} | |
| 310 | + buildHref={(patch) => | |
| 311 | + buildCommonCodeHref(query, { | |
| 312 | + comCd: selectedComCd, | |
| 313 | + detailPage: Number(patch.page ?? 1), | |
| 314 | + }) | |
| 315 | + } | |
| 316 | + /> | |
| 317 | + </section> | |
| 318 | + </div> | |
| 319 | + | |
| 320 | + {modal?.kind === 'group-create' && ( | |
| 321 | + <CodeGroupModal onClose={() => setModal(null)} /> | |
| 322 | + )} | |
| 323 | + {modal?.kind === 'group-edit' && ( | |
| 324 | + <CodeGroupModal group={modal.group} onClose={() => setModal(null)} /> | |
| 325 | + )} | |
| 326 | + {modal?.kind === 'detail-create' && selectedComCd && ( | |
| 327 | + <CodeDetailModal | |
| 328 | + comCd={selectedComCd} | |
| 329 | + nextSortSeq={nextSortSeq} | |
| 330 | + onClose={() => setModal(null)} | |
| 331 | + /> | |
| 332 | + )} | |
| 333 | + {modal?.kind === 'detail-edit' && ( | |
| 334 | + <CodeDetailModal | |
| 335 | + comCd={modal.detail.comCd} | |
| 336 | + detail={modal.detail} | |
| 337 | + nextSortSeq={nextSortSeq} | |
| 338 | + onClose={() => setModal(null)} | |
| 339 | + /> | |
| 340 | + )} | |
| 341 | + </div> | |
| 342 | + ); | |
| 343 | +} |
+++ app/(protected)/(basic)/system/codes/_components/code-row-actions.tsx
... | ... | @@ -0,0 +1,51 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { FoxButtonGroup } from '@fox/core/components/fox-button-group'; | |
| 4 | +import { FoxIconButton } from '@fox/core/components/fox-icon-button'; | |
| 5 | +import { FoxPencilSimpleIcon, FoxTrashIcon } from '@fox/core/icons'; | |
| 6 | + | |
| 7 | +interface CodeRowActionsProps { | |
| 8 | + /** 읽어 줄 이름에 붙는 대상 — "CMS004 수정"처럼 읽힌다. */ | |
| 9 | + target: string; | |
| 10 | + onEdit: () => void; | |
| 11 | + onDelete: () => void; | |
| 12 | + deleting?: boolean; | |
| 13 | +} | |
| 14 | + | |
| 15 | +/** | |
| 16 | + * 두 목록이 함께 쓰는 "관리" 셀(시안 5402:13455) — 28×28 테두리 버튼 두 개. | |
| 17 | + * | |
| 18 | + * **누름이 위로 새지 않게 막는다** — 공통코드 행은 눌러서 고르는 행이라, 막지 않으면 수정·삭제를 | |
| 19 | + * 누를 때 선택까지 함께 바뀐다. 상세코드 행은 고를 수 없지만 같은 조각을 쓰므로 규칙을 여기 둔다. | |
| 20 | + */ | |
| 21 | +export function CodeRowActions({ | |
| 22 | + target, | |
| 23 | + onEdit, | |
| 24 | + onDelete, | |
| 25 | + deleting = false, | |
| 26 | +}: CodeRowActionsProps) { | |
| 27 | + return ( | |
| 28 | + <span | |
| 29 | + onClick={(event) => event.stopPropagation()} | |
| 30 | + onKeyDown={(event) => event.stopPropagation()} | |
| 31 | + > | |
| 32 | + <FoxButtonGroup size="sm"> | |
| 33 | + <FoxIconButton | |
| 34 | + type="default" | |
| 35 | + size="sm" | |
| 36 | + icon={<FoxPencilSimpleIcon />} | |
| 37 | + label={`${target} 수정`} | |
| 38 | + onAction={onEdit} | |
| 39 | + /> | |
| 40 | + <FoxIconButton | |
| 41 | + type="default" | |
| 42 | + size="sm" | |
| 43 | + icon={<FoxTrashIcon />} | |
| 44 | + label={deleting ? `${target} 삭제 중` : `${target} 삭제`} | |
| 45 | + disabled={deleting} | |
| 46 | + onAction={onDelete} | |
| 47 | + /> | |
| 48 | + </FoxButtonGroup> | |
| 49 | + </span> | |
| 50 | + ); | |
| 51 | +} |
+++ app/(protected)/(basic)/system/codes/page.tsx
... | ... | @@ -0,0 +1,107 @@ |
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { verifySession } from '@/lib/auth/dal'; | |
| 3 | +import { | |
| 4 | + fetchCodeDetails, | |
| 5 | + fetchCodeGroups, | |
| 6 | +} from '@/lib/data/repositories/common-code-repository'; | |
| 7 | +import type { CommonCodeGroup } from '@/lib/domain/common-code'; | |
| 8 | +import { | |
| 9 | + COMMON_CODE_DETAIL_PAGE_SIZE, | |
| 10 | + COMMON_CODE_PAGE_SIZE, | |
| 11 | + parseCommonCodeQuery, | |
| 12 | + type CommonCodeQuery, | |
| 13 | +} from '@/lib/domain/common-code-query'; | |
| 14 | +import { CodeManagement } from './_components/code-management'; | |
| 15 | + | |
| 16 | +export const metadata: Metadata = { | |
| 17 | + title: '코드관리', | |
| 18 | +}; | |
| 19 | + | |
| 20 | +// cookies()로 이미 동적이지만, 정적 프리렌더로 데이터가 빌드 산출물에 박히는 경로를 | |
| 21 | +// 원천 차단하기 위해 명시적으로 강제한다. | |
| 22 | +export const dynamic = 'force-dynamic'; | |
| 23 | + | |
| 24 | +interface PageProps { | |
| 25 | + searchParams: Promise<Record<string, string | string[] | undefined>>; | |
| 26 | +} | |
| 27 | + | |
| 28 | +/** 부분일치 검색 — 백엔드가 완전일치만 지원해 여기서 거른다(Repository 주석 참조). */ | |
| 29 | +function filterGroups( | |
| 30 | + groups: CommonCodeGroup[], | |
| 31 | + query: CommonCodeQuery | |
| 32 | +): CommonCodeGroup[] { | |
| 33 | + const keyword = query.keyword.trim().toLowerCase(); | |
| 34 | + if (!keyword) { | |
| 35 | + return groups; | |
| 36 | + } | |
| 37 | + | |
| 38 | + return groups.filter((group) => { | |
| 39 | + const target = query.searchField === 'comCd' ? group.comCd : group.name; | |
| 40 | + return target.toLowerCase().includes(keyword); | |
| 41 | + }); | |
| 42 | +} | |
| 43 | + | |
| 44 | +/** 1-based 페이지를 잘라낸다. 범위를 벗어난 페이지는 마지막 페이지로 맞춘다. */ | |
| 45 | +function paginate<T>(rows: T[], page: number, pageSize: number) { | |
| 46 | + const totalPages = Math.max(1, Math.ceil(rows.length / pageSize)); | |
| 47 | + const currentPage = Math.min(Math.max(page, 1), totalPages); | |
| 48 | + const offset = (currentPage - 1) * pageSize; | |
| 49 | + | |
| 50 | + return { | |
| 51 | + rows: rows.slice(offset, offset + pageSize), | |
| 52 | + currentPage, | |
| 53 | + totalPages, | |
| 54 | + totalCount: rows.length, | |
| 55 | + }; | |
| 56 | +} | |
| 57 | + | |
| 58 | +/** | |
| 59 | + * 코드관리 — 기획 SYS_COD_001, 시안 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) 5402:13399 | |
| 60 | + * | |
| 61 | + * 좌측 공통코드를 고르면 우측 상세코드 목록이 그 그룹으로 갱신된다. 선택·검색·두 목록의 페이지가 | |
| 62 | + * 모두 URL에 실려, 이 서버 컴포넌트가 매 요청 그 조건대로 데이터를 만들어 넘긴다. | |
| 63 | + * | |
| 64 | + * **검색·페이징을 서버에서 처리한다** — 백엔드가 두 목록 모두 전체를 반환하고 검색도 완전일치라 | |
| 65 | + * (Repository 주석 참조), 부분일치 필터와 페이지 자르기를 여기서 한다. 전체를 받으므로 총건수는 | |
| 66 | + * 정확하다(다른 목록 화면의 하한값 보정이 여기에는 필요 없다). | |
| 67 | + * | |
| 68 | + * **선택된 공통코드가 목록에 없으면 첫 행으로 대체한다** — 검색으로 걸러졌거나 삭제된 코드가 | |
| 69 | + * URL에 남아 있으면 우측이 영영 비어 보이기 때문이다. | |
| 70 | + */ | |
| 71 | +export default async function Page({ searchParams }: PageProps) { | |
| 72 | + await verifySession(); | |
| 73 | + | |
| 74 | + const query = parseCommonCodeQuery(await searchParams); | |
| 75 | + | |
| 76 | + const groups = filterGroups(await fetchCodeGroups(), query); | |
| 77 | + const groupPage = paginate(groups, query.page, COMMON_CODE_PAGE_SIZE); | |
| 78 | + | |
| 79 | + const selectedComCd = | |
| 80 | + groups.find((group) => group.comCd === query.comCd)?.comCd ?? | |
| 81 | + groupPage.rows[0]?.comCd ?? | |
| 82 | + null; | |
| 83 | + | |
| 84 | + const details = selectedComCd ? await fetchCodeDetails(selectedComCd) : []; | |
| 85 | + const detailPage = paginate( | |
| 86 | + details, | |
| 87 | + query.detailPage, | |
| 88 | + COMMON_CODE_DETAIL_PAGE_SIZE | |
| 89 | + ); | |
| 90 | + | |
| 91 | + return ( | |
| 92 | + <CodeManagement | |
| 93 | + query={query} | |
| 94 | + groups={groupPage.rows} | |
| 95 | + groupTotalCount={groupPage.totalCount} | |
| 96 | + groupPage={groupPage.currentPage} | |
| 97 | + groupTotalPages={groupPage.totalPages} | |
| 98 | + selectedComCd={selectedComCd} | |
| 99 | + details={detailPage.rows} | |
| 100 | + detailTotalCount={detailPage.totalCount} | |
| 101 | + detailPage={detailPage.currentPage} | |
| 102 | + detailTotalPages={detailPage.totalPages} | |
| 103 | + // 새 상세코드의 기본 정렬번호 — 시안 등록 팝업이 기존 개수 다음 번호를 채워 둔다. | |
| 104 | + nextSortSeq={details.length + 1} | |
| 105 | + /> | |
| 106 | + ); | |
| 107 | +} |
+++ lib/data/repositories/common-code-repository.ts
... | ... | @@ -0,0 +1,247 @@ |
| 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 { | |
| 6 | + CommonCode, | |
| 7 | + CommonCodeDetail, | |
| 8 | + CommonCodeGroup, | |
| 9 | +} from '@/lib/domain/common-code'; | |
| 10 | +import type { | |
| 11 | + CommonCodeDetailValues, | |
| 12 | + CommonCodeGroupValues, | |
| 13 | +} from '@/lib/domain/common-code-form'; | |
| 14 | + | |
| 15 | +/** | |
| 16 | + * 공통코드 Repository — 그룹(`TB_SYS_COM_CD`)과 상세(`TB_SYS_COM_CD_DTL`) 두 층을 모두 맡는다. | |
| 17 | + * | |
| 18 | + * ``` | |
| 19 | + * GET /api/v1/mngr/code/list 그룹 목록 (ROLE_ADMIN) | |
| 20 | + * GET /api/v1/mngr/code/list/{comCd} 그룹의 상세코드 목록 | |
| 21 | + * POST /api/v1/mngr/code 그룹 등록 — @RequestBody(JSON) | |
| 22 | + * PUT /api/v1/mngr/code/{comCd} 그룹 수정 — @RequestBody(JSON) | |
| 23 | + * DELETE /api/v1/mngr/code/{comCd} 그룹 삭제 — soft delete(DEL_YN) | |
| 24 | + * POST /api/v1/mngr/code/detail 상세 등록 — 어노테이션 없음 → form | |
| 25 | + * PUT /api/v1/mngr/code/{comCd}/{comDtlCd} 상세 수정 — @RequestBody(JSON) | |
| 26 | + * DELETE /api/v1/mngr/code/{comCd}/{comDtlCd} 상세 삭제 — soft delete | |
| 27 | + * ``` | |
| 28 | + * | |
| 29 | + * 백엔드(edupay-backend, develop)의 MngrCodeApiController / MngrCodeMapper.xml을 읽고 확인한 것: | |
| 30 | + * | |
| 31 | + * - **본문 형식이 엔드포인트마다 다르다.** 상세 등록만 `@ParameterObject`(form)이고 나머지 쓰기는 | |
| 32 | + * 전부 `@RequestBody`(JSON)다 — 한쪽으로 통일해 보내면 반대쪽이 조용히 깨진다. | |
| 33 | + * - **두 목록 모두 페이징이 없다**(전체 반환). 그래서 총건수는 정확하고, 자르는 일은 호출부가 한다. | |
| 34 | + * - **그룹 검색은 완전일치다**(`com_cd = #{}` / `cd_nm = #{}`). 시안은 부분검색이라 검색 파라미터를 | |
| 35 | + * 쓰지 않고 전체를 받아 호출부가 거른다(사용자 확정 사항). | |
| 36 | + * - **정렬은 고정이다** — 두 목록 다 `ROW_NUMBER() OVER (...)`를 `ORDER BY RNUM DESC`로 뒤집는 | |
| 37 | + * 이중 역순이라 결과는 등록일 오름차순이다. 화면에서 다시 정렬하지 않는다. | |
| 38 | + * - **삭제는 soft delete**이고 조회가 `DEL_YN != 'Y'`로 거른다. | |
| 39 | + * | |
| 40 | + * ⚠️ **백엔드 결함(보고함, 미수정)** | |
| 41 | + * 1. 상세 목록 조회 SQL이 `DTL_CD_EXPLN`을 select하지 않는다 → 상세코드설명이 늘 비어 온다. | |
| 42 | + * 2. 상세 등록 INSERT에도 `DTL_CD_EXPLN`이 없다 → 등록 시 입력한 설명이 저장되지 않는다. | |
| 43 | + * (수정 UPDATE에는 있어 수정으로는 저장된다 — 다만 위 1 때문에 목록에서는 여전히 안 보인다.) | |
| 44 | + * 사용자 지시로 시안대로 화면을 두고 백엔드에 수정을 요청한다 — 고쳐지면 프론트 수정 없이 동작한다. | |
| 45 | + * | |
| 46 | + * 캐시: 조회는 `no-store` — 관리 화면이라 신선도가 우선이다. `cache()`는 한 요청 안의 중복 호출만 | |
| 47 | + * 막는다(화면이 그리기용으로, Server Action이 검증용으로 같은 목록을 부른다). | |
| 48 | + */ | |
| 49 | + | |
| 50 | +const CODE_PATH = '/api/v1/mngr/code'; | |
| 51 | + | |
| 52 | +function isRecord(value: unknown): value is Record<string, unknown> { | |
| 53 | + return value !== null && typeof value === 'object'; | |
| 54 | +} | |
| 55 | + | |
| 56 | +function readString(source: Record<string, unknown>, key: string): string | null { | |
| 57 | + const value = source[key]; | |
| 58 | + return typeof value === 'string' && value.length > 0 ? value : null; | |
| 59 | +} | |
| 60 | + | |
| 61 | +async function requestList(path: string): Promise<unknown[]> { | |
| 62 | + const accessToken = await getSessionAccessToken(); | |
| 63 | + | |
| 64 | + const result = await backendFetch<unknown>(path, { | |
| 65 | + method: 'GET', | |
| 66 | + accessToken: accessToken ?? undefined, | |
| 67 | + cache: 'no-store', | |
| 68 | + }); | |
| 69 | + | |
| 70 | + if (!result.ok) { | |
| 71 | + throw new BackendRequestError(result); | |
| 72 | + } | |
| 73 | + | |
| 74 | + if (!Array.isArray(result.data)) { | |
| 75 | + throw new Error('공통코드 응답의 형식이 올바르지 않습니다.'); | |
| 76 | + } | |
| 77 | + | |
| 78 | + return result.data; | |
| 79 | +} | |
| 80 | + | |
| 81 | +/** | |
| 82 | + * 응답 1건 → 도메인 타입. 코드값이 없는 행은 **예외 대신 건너뛴다** — 코드 한 줄이 깨졌다고 | |
| 83 | + * 화면 전체를 못 쓰게 만들 이유가 없다(목록 조회의 fail-fast와 다른 판단이다). | |
| 84 | + */ | |
| 85 | +function toGroup(raw: unknown): CommonCodeGroup[] { | |
| 86 | + if (!isRecord(raw)) { | |
| 87 | + return []; | |
| 88 | + } | |
| 89 | + | |
| 90 | + const comCd = readString(raw, 'comCd'); | |
| 91 | + if (!comCd) { | |
| 92 | + return []; | |
| 93 | + } | |
| 94 | + | |
| 95 | + return [ | |
| 96 | + { | |
| 97 | + comCd, | |
| 98 | + name: readString(raw, 'cdNm') ?? '', | |
| 99 | + description: readString(raw, 'cdExpln'), | |
| 100 | + createdAt: readString(raw, 'frstRegDtStr'), | |
| 101 | + }, | |
| 102 | + ]; | |
| 103 | +} | |
| 104 | + | |
| 105 | +function toDetail(raw: unknown): CommonCodeDetail[] { | |
| 106 | + if (!isRecord(raw)) { | |
| 107 | + return []; | |
| 108 | + } | |
| 109 | + | |
| 110 | + const comDtlCd = readString(raw, 'comDtlCd'); | |
| 111 | + if (!comDtlCd) { | |
| 112 | + return []; | |
| 113 | + } | |
| 114 | + | |
| 115 | + const sortSeq = raw.sortSeq; | |
| 116 | + | |
| 117 | + return [ | |
| 118 | + { | |
| 119 | + comCd: readString(raw, 'comCd') ?? '', | |
| 120 | + comDtlCd, | |
| 121 | + name: readString(raw, 'cdNm') ?? '', | |
| 122 | + // 지금은 백엔드가 내려 주지 않아 늘 null이다(파일 상단 결함 1). | |
| 123 | + description: readString(raw, 'dtlCdExpln'), | |
| 124 | + sortSeq: typeof sortSeq === 'number' ? sortSeq : 0, | |
| 125 | + createdAt: readString(raw, 'frstRegDtStr'), | |
| 126 | + }, | |
| 127 | + ]; | |
| 128 | +} | |
| 129 | + | |
| 130 | +/** 공통코드(그룹) 전체. 검색·페이징은 호출부가 한다(파일 상단 주석 참조). */ | |
| 131 | +export const fetchCodeGroups = cache(async function fetchCodeGroups(): Promise< | |
| 132 | + CommonCodeGroup[] | |
| 133 | +> { | |
| 134 | + const rows = await requestList(`${CODE_PATH}/list`); | |
| 135 | + return rows.flatMap(toGroup); | |
| 136 | +}); | |
| 137 | + | |
| 138 | +/** 한 그룹의 상세코드 전체. */ | |
| 139 | +export const fetchCodeDetails = cache(async function fetchCodeDetails( | |
| 140 | + comCd: string | |
| 141 | +): Promise<CommonCodeDetail[]> { | |
| 142 | + const rows = await requestList( | |
| 143 | + `${CODE_PATH}/list/${encodeURIComponent(comCd)}` | |
| 144 | + ); | |
| 145 | + return rows.flatMap(toDetail); | |
| 146 | +}); | |
| 147 | + | |
| 148 | +/** | |
| 149 | + * 다른 화면이 선택지로 쓰는 최소 표현. 상세코드 조회를 그대로 쓰되 화면이 알 필요 없는 것을 | |
| 150 | + * 덜어 낸다 — 같은 `cache()`를 타므로 한 요청 안에서 왕복이 늘지 않는다. | |
| 151 | + */ | |
| 152 | +export async function fetchCommonCodes(groupCode: string): Promise<CommonCode[]> { | |
| 153 | + const details = await fetchCodeDetails(groupCode); | |
| 154 | + return details.map((detail) => ({ code: detail.comDtlCd, label: detail.name })); | |
| 155 | +} | |
| 156 | + | |
| 157 | +/* | |
| 158 | + * ─── 쓰기 경로 ──────────────────────────────────────────────────────────────── | |
| 159 | + * 상세 등록만 form이고 나머지는 JSON이다(파일 상단 주석 참조). | |
| 160 | + * 성공 응답은 모두 `ApiResponseVO.success(null)`이라 data가 정상적으로 null이다. | |
| 161 | + */ | |
| 162 | + | |
| 163 | +async function sendWrite( | |
| 164 | + path: string, | |
| 165 | + method: 'POST' | 'PUT' | 'DELETE', | |
| 166 | + payload?: { form: Record<string, string | number | undefined> } | { body: unknown } | |
| 167 | +): Promise<void> { | |
| 168 | + const accessToken = await getSessionAccessToken(); | |
| 169 | + | |
| 170 | + const result = await backendFetch<null>(path, { | |
| 171 | + method, | |
| 172 | + ...payload, | |
| 173 | + accessToken: accessToken ?? undefined, | |
| 174 | + cache: 'no-store', | |
| 175 | + canHaveNullData: true, | |
| 176 | + }); | |
| 177 | + | |
| 178 | + if (!result.ok) { | |
| 179 | + throw new BackendRequestError(result); | |
| 180 | + } | |
| 181 | +} | |
| 182 | + | |
| 183 | +export async function createCodeGroup( | |
| 184 | + values: CommonCodeGroupValues | |
| 185 | +): Promise<void> { | |
| 186 | + await sendWrite(CODE_PATH, 'POST', { | |
| 187 | + body: { comCd: values.comCd, cdNm: values.name, cdExpln: values.description }, | |
| 188 | + }); | |
| 189 | +} | |
| 190 | + | |
| 191 | +/** 코드ID는 경로로만 간다 — 수정 대상이 아니라 대상을 가리키는 값이다. */ | |
| 192 | +export async function updateCodeGroup( | |
| 193 | + comCd: string, | |
| 194 | + values: CommonCodeGroupValues | |
| 195 | +): Promise<void> { | |
| 196 | + await sendWrite(`${CODE_PATH}/${encodeURIComponent(comCd)}`, 'PUT', { | |
| 197 | + body: { cdNm: values.name, cdExpln: values.description }, | |
| 198 | + }); | |
| 199 | +} | |
| 200 | + | |
| 201 | +export async function deleteCodeGroup(comCd: string): Promise<void> { | |
| 202 | + await sendWrite(`${CODE_PATH}/${encodeURIComponent(comCd)}`, 'DELETE'); | |
| 203 | +} | |
| 204 | + | |
| 205 | +/** 상세 등록만 form이다 — 컨트롤러가 `@RequestBody` 없이 받는다. */ | |
| 206 | +export async function createCodeDetail( | |
| 207 | + values: CommonCodeDetailValues | |
| 208 | +): Promise<void> { | |
| 209 | + await sendWrite(`${CODE_PATH}/detail`, 'POST', { | |
| 210 | + form: { | |
| 211 | + comCd: values.comCd, | |
| 212 | + comDtlCd: values.comDtlCd, | |
| 213 | + cdNm: values.name, | |
| 214 | + // 백엔드 INSERT가 이 값을 쓰지 않는다(파일 상단 결함 2). 고쳐지면 그대로 저장된다. | |
| 215 | + dtlCdExpln: values.description, | |
| 216 | + sortSeq: values.sortSeq, | |
| 217 | + }, | |
| 218 | + }); | |
| 219 | +} | |
| 220 | + | |
| 221 | +export async function updateCodeDetail( | |
| 222 | + comCd: string, | |
| 223 | + comDtlCd: string, | |
| 224 | + values: CommonCodeDetailValues | |
| 225 | +): Promise<void> { | |
| 226 | + await sendWrite( | |
| 227 | + `${CODE_PATH}/${encodeURIComponent(comCd)}/${encodeURIComponent(comDtlCd)}`, | |
| 228 | + 'PUT', | |
| 229 | + { | |
| 230 | + body: { | |
| 231 | + cdNm: values.name, | |
| 232 | + dtlCdExpln: values.description, | |
| 233 | + sortSeq: values.sortSeq, | |
| 234 | + }, | |
| 235 | + } | |
| 236 | + ); | |
| 237 | +} | |
| 238 | + | |
| 239 | +export async function deleteCodeDetail( | |
| 240 | + comCd: string, | |
| 241 | + comDtlCd: string | |
| 242 | +): Promise<void> { | |
| 243 | + await sendWrite( | |
| 244 | + `${CODE_PATH}/${encodeURIComponent(comCd)}/${encodeURIComponent(comDtlCd)}`, | |
| 245 | + 'DELETE' | |
| 246 | + ); | |
| 247 | +} |
--- lib/data/repositories/decoration-item-repository.ts
+++ lib/data/repositories/decoration-item-repository.ts
... | ... | @@ -26,16 +26,20 @@ |
| 26 | 26 |
* 아래는 백엔드 저장소(edupay-backend, develop)의 실제 구현을 읽고 확인한 것이다 — |
| 27 | 27 |
* MngrItemApiController / MngrItemServiceImpl / MngrItemMapper.xml / FileCommonApiController. |
| 28 | 28 |
* |
| 29 |
- * - **등록과 수정의 본문 형식이 서로 다르다.** 등록(POST)은 `@ParameterObject`라 form이고, |
|
| 30 |
- * 수정(PUT)은 `@RequestBody`라 JSON이다(2026-08-18 백엔드 `4d98756`에서 수정만 바뀌었다). |
|
| 31 |
- * 한쪽 방식으로 통일해 보내면 조용히 깨진다 — form을 JSON 핸들러에 보내면 415이고, 반대는 |
|
| 32 |
- * 전 필드가 null인 채 저장된다(오류도 나지 않는다). |
|
| 29 |
+ * - **등록과 수정의 본문 형식이 다르다.** 등록은 `@ParameterObject`(form 인코딩), 수정은 |
|
| 30 |
+ * `@RequestBody`(JSON)로 받는다 — 한쪽 형식으로 통일해 보내면 반대쪽이 전 필드 null이 되거나 |
|
| 31 |
+ * 415로 떨어진다. 백엔드 커밋 "FIX API 수정"에서 수정만 JSON으로 바뀌었다. |
|
| 33 | 32 |
* - **목록은 `searchItemType`이 필수다** — SQL의 WHERE에 `AND a.ITEM_TYPE = #{searchItemType}`이
|
| 34 | 33 |
* 무조건 붙는다. 값이 없으면 아무것도 조회되지 않는다. |
| 35 | 34 |
* - **검색은 아이템명(`searchCondition="1"`)만 구현돼 있다.** 아이템ID 검색 분기는 없어, 그 |
| 36 | 35 |
* 값을 보내면 조건 없이 전체가 반환된다(사용자 지시로 화면 선택지는 유지하고 백엔드에 추가 요청). |
| 37 | 36 |
* - **정렬은 고정이다** — `ROW_NUMBER() OVER (ORDER BY SORT_ORDR DESC)`를 다시 역순으로 정렬해 |
| 38 | 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 |
+ * 건너뛴다. 지우려는 의도가 조용히 무시되므로 백엔드에 조건 완화를 요청한다. |
|
| 39 | 43 |
* - **응답의 `totalCount`는 전체 건수가 아니라 그 페이지의 행 수다**(학생·관리자 목록과 동일한 |
| 40 | 44 |
* `PaginationUtil` 결함). 그대로 믿으면 페이지가 가득 찰 때마다 다음 페이지에 도달할 수 없어 |
| 41 | 45 |
* 하한값으로 보정한다. |
... | ... | @@ -139,8 +143,6 @@ |
| 139 | 143 |
sortOrder: readNumber(raw, 'sortOrdr') ?? 0, |
| 140 | 144 |
imageFileId, |
| 141 | 145 |
imageUrl: buildImageUrl(imageFileId), |
| 142 |
- // 백엔드 목록 SQL의 별칭이 `last_mdfcn_dt_str`이다(2026-08-18 `4d98756`에서 개명). |
|
| 143 |
- // 지금은 컨트롤러가 수정일시를 채우지 않아 늘 비어 있지만, 채우기 시작하면 그대로 흐른다. |
|
| 144 | 146 |
updatedAt: readString(raw, 'lastMdfcnDtStr'), |
| 145 | 147 |
}; |
| 146 | 148 |
} |
... | ... | @@ -251,11 +253,12 @@ |
| 251 | 253 |
|
| 252 | 254 |
/* |
| 253 | 255 |
* ─── 쓰기 경로 ──────────────────────────────────────────────────────────────── |
| 254 |
- * 보내는 값은 같고 **형식만 갈린다** — 등록은 form, 수정은 JSON(파일 상단 주석 참조). |
|
| 256 |
+ * 보내는 값은 등록·수정이 같고 **실어 보내는 형식만 다르다** — 등록은 form, 수정은 JSON |
|
| 257 |
+ * (파일 상단 주석 참조). |
|
| 255 | 258 |
*/ |
| 256 | 259 |
|
| 257 | 260 |
/** 등록·수정이 공유하는 전송 필드. */ |
| 258 |
-function buildWritePayload( |
|
| 261 |
+function buildWriteForm( |
|
| 259 | 262 |
values: DecorationItemEditableValues |
| 260 | 263 |
): Record<string, string | number | undefined> {
|
| 261 | 264 |
return {
|
... | ... | @@ -271,21 +274,23 @@ |
| 271 | 274 |
}; |
| 272 | 275 |
} |
| 273 | 276 |
|
| 277 |
+/** |
|
| 278 |
+ * 등록·수정 공통 호출. `payload`가 본문 형식을 정한다 — 이 한 곳에서만 갈린다. |
|
| 279 |
+ * |
|
| 280 |
+ * 등록·수정 성공 응답은 `ApiResponseVO.success(null)`이라 data가 정상적으로 null이다. |
|
| 281 |
+ */ |
|
| 274 | 282 |
async function sendWrite( |
| 275 | 283 |
path: string, |
| 276 | 284 |
method: 'POST' | 'PUT', |
| 277 |
- values: DecorationItemEditableValues |
|
| 285 |
+ payload: { form: Record<string, string | number | undefined> } | { body: unknown }
|
|
| 278 | 286 |
): Promise<void> {
|
| 279 | 287 |
const accessToken = await getSessionAccessToken(); |
| 280 |
- const payload = buildWritePayload(values); |
|
| 281 | 288 |
|
| 282 | 289 |
const result = await backendFetch<null>(path, {
|
| 283 | 290 |
method, |
| 284 |
- // 등록은 `@ParameterObject`(form), 수정은 `@RequestBody`(JSON) — 컨트롤러가 그렇게 갈린다. |
|
| 285 |
- ...(method === 'POST' ? { form: payload } : { body: payload }),
|
|
| 291 |
+ ...payload, |
|
| 286 | 292 |
accessToken: accessToken ?? undefined, |
| 287 | 293 |
cache: 'no-store', |
| 288 |
- // 등록·수정 성공 응답은 `ApiResponseVO.success(null)` — data가 정상적으로 null이다. |
|
| 289 | 294 |
canHaveNullData: true, |
| 290 | 295 |
}); |
| 291 | 296 |
|
... | ... | @@ -294,27 +299,27 @@ |
| 294 | 299 |
} |
| 295 | 300 |
} |
| 296 | 301 |
|
| 302 |
+/** 등록 — 컨트롤러가 `@ParameterObject`라 form 인코딩이다. */ |
|
| 297 | 303 |
export async function createDecorationItem( |
| 298 | 304 |
values: DecorationItemEditableValues |
| 299 | 305 |
): Promise<void> {
|
| 300 |
- await sendWrite(DECORATION_ITEM_PATH, 'POST', values); |
|
| 306 |
+ await sendWrite(DECORATION_ITEM_PATH, 'POST', { form: buildWriteForm(values) });
|
|
| 301 | 307 |
} |
| 302 | 308 |
|
| 303 | 309 |
/** |
| 304 |
- * 수정. 종전에 "수정하면 아이템이 목록에서 사라지던" 백엔드 결함은 2026-08-18(`4d98756`)에 |
|
| 305 |
- * 해소됐다 — 컨트롤러가 `itemType`을 빌더에 담게 됐고 UPDATE 문도 `<if>`로 감싸졌다. |
|
| 310 |
+ * 수정(시안 ADM_ITM_103_p) — 컨트롤러가 `@RequestBody`라 **JSON**이다. 등록과 같은 필드를 |
|
| 311 |
+ * 보내지만 형식이 다르다. |
|
| 306 | 312 |
* |
| 307 |
- * ⚠️ 남은 백엔드 결함 둘: |
|
| 308 |
- * - **설명을 비워도 지워지지 않는다** — UPDATE의 `ITEM_EXPLAN`이 `<if test="… != ''">` 안에 |
|
| 309 |
- * 있어 빈 문자열은 SET 절에서 빠진다. |
|
| 310 |
- * - **수정일시가 채워지지 않는다** — 컨트롤러가 `lastMdfcnDt`를 세팅하지 않아 UPDATE가 |
|
| 311 |
- * `LAST_MDFCN_DT = null`을 쓴다. 그래서 응답의 `lastMdfcnDtStr`도 계속 비어 있다. |
|
| 313 |
+ * UPDATE 문이 필드마다 `<if>`로 감싸여 있어 **보내지 않은 값은 건드리지 않는다** — 썸네일 |
|
| 314 |
+ * 파일 ID(`thumbAtchFileId`)를 보내지 않는 것이 기존 값을 지우지 않는 이유다. |
|
| 312 | 315 |
*/ |
| 313 | 316 |
export async function updateDecorationItem( |
| 314 | 317 |
itemSn: number, |
| 315 | 318 |
values: DecorationItemEditableValues |
| 316 | 319 |
): Promise<void> {
|
| 317 |
- await sendWrite(`${DECORATION_ITEM_PATH}/${itemSn}`, 'PUT', values);
|
|
| 320 |
+ await sendWrite(`${DECORATION_ITEM_PATH}/${itemSn}`, 'PUT', {
|
|
| 321 |
+ body: buildWriteForm(values), |
|
| 322 |
+ }); |
|
| 318 | 323 |
} |
| 319 | 324 |
|
| 320 | 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
... | ... | @@ -34,7 +34,14 @@ |
| 34 | 34 |
export const ADMIN_LOGIN_ID_HELP_TEXT = |
| 35 | 35 |
'영어 소문자, 숫자를 조합하여 입력 후 중복여부를 확인하세요.'; |
| 36 | 36 |
export const ADMIN_PASSWORD_HELP_TEXT = |
| 37 |
- '영어 소문자, 숫자, 특수문자 중 2종류 이상 조합, 최소 10자리 이상'; |
|
| 37 |
+ '영어, 숫자, 특수문자 중 2종류 이상 조합, 최소 10자리 이상'; |
|
| 38 |
+ |
|
| 39 |
+/** |
|
| 40 |
+ * 비밀번호가 규칙에 걸렸을 때의 문구. **안내 문구와 글자가 달라야 한다** — 같으면 저장이 |
|
| 41 |
+ * 거부돼도 화면의 글자가 하나도 바뀌지 않아 사용자가 실패한 줄 모른다(실제로 그랬다). |
|
| 42 |
+ */ |
|
| 43 |
+export const ADMIN_PASSWORD_ERROR_TEXT = |
|
| 44 |
+ '비밀번호가 규칙에 맞지 않습니다. 영어·숫자·특수문자 중 2종류 이상으로 10자리 이상 입력해 주세요.'; |
|
| 38 | 45 |
|
| 39 | 46 |
|
| 40 | 47 |
const LOGIN_ID_MIN_LENGTH = 4; |
... | ... | @@ -141,11 +148,15 @@ |
| 141 | 148 |
}; |
| 142 | 149 |
|
| 143 | 150 |
/** |
| 144 |
- * 비밀번호 조합 종류 수 — 영문 소문자 / 숫자 / 특수문자 중 몇 종류가 섞였는지 센다. |
|
| 145 |
- * 시안이 "2종류 이상"만 요구하므로 대문자는 별도 종류로 세지 않는다(있어도 무방하다). |
|
| 151 |
+ * 비밀번호 조합 종류 수 — 영문 / 숫자 / 특수문자 중 몇 종류가 섞였는지 센다. |
|
| 152 |
+ * |
|
| 153 |
+ * **대문자도 영문 한 종류로 센다** — 화면이 쓰는 `@fox`의 `characterKinds`가 `/[a-zA-Z]/`로 |
|
| 154 |
+ * 판정하므로 여기가 `[a-z]`면 규칙이 갈린다. 실제로 `PASSWORD123`처럼 영문자가 전부 대문자인 |
|
| 155 |
+ * 값이 화면은 통과하고 저장만 거부되는 상태였다(오류 문구가 안내 문구와 같아 화면에 아무것도 |
|
| 156 |
+ * 드러나지 않았다). |
|
| 146 | 157 |
*/ |
| 147 | 158 |
function countCharacterKinds(password: string): number {
|
| 148 |
- const kinds = [/[a-z]/, /\d/, /[^a-zA-Z0-9]/]; |
|
| 159 |
+ const kinds = [/[a-zA-Z]/, /\d/, /[^a-zA-Z0-9]/]; |
|
| 149 | 160 |
return kinds.filter((pattern) => pattern.test(password)).length; |
| 150 | 161 |
} |
| 151 | 162 |
|
... | ... | @@ -197,13 +208,13 @@ |
| 197 | 208 |
|
| 198 | 209 |
if (!password) {
|
| 199 | 210 |
if (options.passwordRequired) {
|
| 200 |
- errors.password = ADMIN_PASSWORD_HELP_TEXT; |
|
| 211 |
+ errors.password = ADMIN_PASSWORD_ERROR_TEXT; |
|
| 201 | 212 |
} |
| 202 | 213 |
} else if ( |
| 203 | 214 |
password.length < ADMIN_PASSWORD_POLICY.minLength || |
| 204 | 215 |
countCharacterKinds(password) < ADMIN_PASSWORD_POLICY.kinds |
| 205 | 216 |
) {
|
| 206 |
- errors.password = ADMIN_PASSWORD_HELP_TEXT; |
|
| 217 |
+ errors.password = ADMIN_PASSWORD_ERROR_TEXT; |
|
| 207 | 218 |
} |
| 208 | 219 |
|
| 209 | 220 |
if (!phoneNumber) {
|
+++ lib/domain/common-code-form.ts
... | ... | @@ -0,0 +1,191 @@ |
| 1 | +/** | |
| 2 | + * 코드관리 등록/수정 입력 규칙 — 순수 검증 로직만 담는다(외부 의존 없음). | |
| 3 | + * | |
| 4 | + * 시안 네 팝업(공통코드 등록/수정, 상세코드 등록/수정)이 이 파일의 규칙을 공유한다. | |
| 5 | + * **이 파일이 검증의 단일 진실원천이다** — Server Action이 저장 직전에 여기를 거친다. | |
| 6 | + * | |
| 7 | + * 시안의 필수(*) 표시는 상세코드 팝업에만 있지만(코드·코드값의미·정렬번호), 공통코드도 | |
| 8 | + * 코드ID·코드명 없이는 저장할 수 없으므로 같은 강도로 검증한다 — 화면 표시와 무관하게 | |
| 9 | + * 백엔드가 PK로 쓰는 값이다. | |
| 10 | + * | |
| 11 | + * `CommonCodeFormState`가 이 파일에 있는 이유는 Next.js가 `'use server'` 파일에서 함수가 아닌 | |
| 12 | + * 값을 export하는 것을 런타임에 거부하기 때문이다(꾸미기 아이템 폼과 같은 사정). | |
| 13 | + */ | |
| 14 | + | |
| 15 | +const CODE_MAX_LENGTH = 50; | |
| 16 | +const NAME_MAX_LENGTH = 100; | |
| 17 | +const DESCRIPTION_MAX_LENGTH = 500; | |
| 18 | + | |
| 19 | +/** 코드값은 영문 대문자·숫자·언더스코어만 받는다 — 코드테이블 키라 공백·한글이 섞이면 곤란하다. */ | |
| 20 | +const CODE_PATTERN = /^[A-Z0-9_]+$/; | |
| 21 | + | |
| 22 | +/** 공통코드(그룹) 등록·수정이 실제로 바꿀 수 있는 항목. */ | |
| 23 | +export type CommonCodeGroupValues = { | |
| 24 | + comCd: string; | |
| 25 | + name: string; | |
| 26 | + description: string; | |
| 27 | +}; | |
| 28 | + | |
| 29 | +/** 상세코드 등록·수정이 실제로 바꿀 수 있는 항목. */ | |
| 30 | +export type CommonCodeDetailValues = { | |
| 31 | + /** 소속 그룹. 화면에서는 읽기 전용이지만 저장 대상이라 값으로 다룬다. */ | |
| 32 | + comCd: string; | |
| 33 | + comDtlCd: string; | |
| 34 | + /** 시안의 "코드값의미" — 백엔드 `cdNm`이다. */ | |
| 35 | + name: string; | |
| 36 | + description: string; | |
| 37 | + sortSeq: number; | |
| 38 | +}; | |
| 39 | + | |
| 40 | +export type CommonCodeFormErrors = Partial< | |
| 41 | + Record< | |
| 42 | + keyof CommonCodeGroupValues | keyof CommonCodeDetailValues, | |
| 43 | + string | |
| 44 | + > | |
| 45 | +>; | |
| 46 | + | |
| 47 | +export type ValidationResult<T> = | |
| 48 | + | { ok: true; values: T } | |
| 49 | + | { ok: false; errors: CommonCodeFormErrors }; | |
| 50 | + | |
| 51 | +export type CommonCodeFormState = | |
| 52 | + | { status: 'idle' } | |
| 53 | + | { status: 'error'; message?: string; errors?: CommonCodeFormErrors } | |
| 54 | + | { status: 'success' }; | |
| 55 | + | |
| 56 | +export const INITIAL_COMMON_CODE_FORM_STATE: CommonCodeFormState = { | |
| 57 | + status: 'idle', | |
| 58 | +}; | |
| 59 | + | |
| 60 | +/** 코드값 공통 검증 — 그룹의 `comCd`와 상세의 `comDtlCd`가 같은 규칙을 쓴다. */ | |
| 61 | +function validateCode( | |
| 62 | + raw: string, | |
| 63 | + label: string | |
| 64 | +): { value: string; error?: string } { | |
| 65 | + // 코드는 대문자로 정규화한다 — 소문자로 저장되면 조회 조건과 어긋난다. | |
| 66 | + const value = raw.trim().toUpperCase(); | |
| 67 | + | |
| 68 | + if (!value) { | |
| 69 | + return { value, error: `${label}를 입력해 주세요.` }; | |
| 70 | + } | |
| 71 | + if (value.length > CODE_MAX_LENGTH) { | |
| 72 | + return { | |
| 73 | + value, | |
| 74 | + error: `${label}는 ${CODE_MAX_LENGTH}자 이내로 입력해 주세요.`, | |
| 75 | + }; | |
| 76 | + } | |
| 77 | + if (!CODE_PATTERN.test(value)) { | |
| 78 | + return { value, error: `${label}는 영문 대문자·숫자·_만 사용할 수 있습니다.` }; | |
| 79 | + } | |
| 80 | + return { value }; | |
| 81 | +} | |
| 82 | + | |
| 83 | +function validateName(raw: string, label: string): { value: string; error?: string } { | |
| 84 | + const value = raw.trim(); | |
| 85 | + | |
| 86 | + if (!value) { | |
| 87 | + return { value, error: `${label}을 입력해 주세요.` }; | |
| 88 | + } | |
| 89 | + if (value.length > NAME_MAX_LENGTH) { | |
| 90 | + return { | |
| 91 | + value, | |
| 92 | + error: `${label}은 ${NAME_MAX_LENGTH}자 이내로 입력해 주세요.`, | |
| 93 | + }; | |
| 94 | + } | |
| 95 | + return { value }; | |
| 96 | +} | |
| 97 | + | |
| 98 | +function validateDescription(raw: string): { value: string; error?: string } { | |
| 99 | + const value = raw.trim(); | |
| 100 | + | |
| 101 | + if (value.length > DESCRIPTION_MAX_LENGTH) { | |
| 102 | + return { | |
| 103 | + value, | |
| 104 | + error: `설명은 ${DESCRIPTION_MAX_LENGTH}자 이내로 입력해 주세요.`, | |
| 105 | + }; | |
| 106 | + } | |
| 107 | + return { value }; | |
| 108 | +} | |
| 109 | + | |
| 110 | +/** 시안 공통코드 등록/수정 — 수정은 코드ID가 읽기 전용이라 값이 폼이 아니라 경로에서 온다. */ | |
| 111 | +export function validateCommonCodeGroup( | |
| 112 | + values: CommonCodeGroupValues | |
| 113 | +): ValidationResult<CommonCodeGroupValues> { | |
| 114 | + const errors: CommonCodeFormErrors = {}; | |
| 115 | + | |
| 116 | + const code = validateCode(values.comCd, '코드ID'); | |
| 117 | + if (code.error) { | |
| 118 | + errors.comCd = code.error; | |
| 119 | + } | |
| 120 | + | |
| 121 | + const name = validateName(values.name, '코드ID명'); | |
| 122 | + if (name.error) { | |
| 123 | + errors.name = name.error; | |
| 124 | + } | |
| 125 | + | |
| 126 | + const description = validateDescription(values.description); | |
| 127 | + if (description.error) { | |
| 128 | + errors.description = description.error; | |
| 129 | + } | |
| 130 | + | |
| 131 | + if (Object.keys(errors).length > 0) { | |
| 132 | + return { ok: false, errors }; | |
| 133 | + } | |
| 134 | + | |
| 135 | + return { | |
| 136 | + ok: true, | |
| 137 | + values: { | |
| 138 | + comCd: code.value, | |
| 139 | + name: name.value, | |
| 140 | + description: description.value, | |
| 141 | + }, | |
| 142 | + }; | |
| 143 | +} | |
| 144 | + | |
| 145 | +/** 시안 상세코드 등록/수정. */ | |
| 146 | +export function validateCommonCodeDetail( | |
| 147 | + values: CommonCodeDetailValues | |
| 148 | +): ValidationResult<CommonCodeDetailValues> { | |
| 149 | + const errors: CommonCodeFormErrors = {}; | |
| 150 | + | |
| 151 | + const group = validateCode(values.comCd, '코드ID'); | |
| 152 | + if (group.error) { | |
| 153 | + // 상세코드 팝업의 코드ID는 좌측 선택에서 오는 읽기 전용 값이라, 여기가 비었다는 것은 | |
| 154 | + // 공통코드를 고르지 않고 저장이 시도됐다는 뜻이다. | |
| 155 | + errors.comCd = '공통코드를 먼저 선택해 주세요.'; | |
| 156 | + } | |
| 157 | + | |
| 158 | + const code = validateCode(values.comDtlCd, '코드'); | |
| 159 | + if (code.error) { | |
| 160 | + errors.comDtlCd = code.error; | |
| 161 | + } | |
| 162 | + | |
| 163 | + const name = validateName(values.name, '코드값의미'); | |
| 164 | + if (name.error) { | |
| 165 | + errors.name = name.error; | |
| 166 | + } | |
| 167 | + | |
| 168 | + const description = validateDescription(values.description); | |
| 169 | + if (description.error) { | |
| 170 | + errors.description = description.error; | |
| 171 | + } | |
| 172 | + | |
| 173 | + if (!Number.isInteger(values.sortSeq) || values.sortSeq < 1) { | |
| 174 | + errors.sortSeq = '정렬번호는 1 이상의 숫자로 입력해 주세요.'; | |
| 175 | + } | |
| 176 | + | |
| 177 | + if (Object.keys(errors).length > 0) { | |
| 178 | + return { ok: false, errors }; | |
| 179 | + } | |
| 180 | + | |
| 181 | + return { | |
| 182 | + ok: true, | |
| 183 | + values: { | |
| 184 | + comCd: group.value, | |
| 185 | + comDtlCd: code.value, | |
| 186 | + name: name.value, | |
| 187 | + description: description.value, | |
| 188 | + sortSeq: values.sortSeq, | |
| 189 | + }, | |
| 190 | + }; | |
| 191 | +} |
+++ lib/domain/common-code-query.ts
... | ... | @@ -0,0 +1,137 @@ |
| 1 | +/** | |
| 2 | + * 코드관리 화면(SYS_COD_001)의 URL 조건 — 순수 규칙만 담는다(next/react 의존 없음). | |
| 3 | + * | |
| 4 | + * 이 화면은 목록이 둘이라 상태가 셋이다. **선택된 공통코드(`comCd`)**, 공통코드 목록의 검색· | |
| 5 | + * 페이지, 상세코드 목록의 페이지. 셋 다 URL이 소유한다 — 다른 목록 화면과 같은 규칙이고, | |
| 6 | + * 새로고침·뒤로가기·링크 공유가 그대로 동작한다. | |
| 7 | + * | |
| 8 | + * **선택이 바뀌면 상세 페이지는 1로 돌아간다** — 다른 그룹의 3페이지는 의미가 없다. | |
| 9 | + * 그 규칙은 `buildCommonCodeHref`가 강제한다(호출부가 잊어도 어긋나지 않게). | |
| 10 | + * | |
| 11 | + * 페이징·검색을 URL에 두면서도 백엔드에는 넘기지 않는다 — 백엔드가 두 목록 모두 전체를 | |
| 12 | + * 반환하고 검색도 완전일치라, 자르고 거르는 일은 서버 컴포넌트가 한다(Repository 주석 참조). | |
| 13 | + */ | |
| 14 | + | |
| 15 | +/** 라우트 경로 — 이 파일 안에서만 하드코딩하고 나머지는 이 상수를 참조한다. */ | |
| 16 | +export const COMMON_CODES_PATH = '/system/codes'; | |
| 17 | + | |
| 18 | +/** 검색 대상 — 시안(SYS_COD_001 ①) "코드명 / 코드ID". */ | |
| 19 | +export type CommonCodeSearchField = 'name' | 'comCd'; | |
| 20 | + | |
| 21 | +export const COMMON_CODE_SEARCH_FIELD_OPTIONS: ReadonlyArray<{ | |
| 22 | + value: CommonCodeSearchField; | |
| 23 | + label: string; | |
| 24 | +}> = [ | |
| 25 | + { value: 'name', label: '코드명' }, | |
| 26 | + { value: 'comCd', label: '코드ID' }, | |
| 27 | +]; | |
| 28 | + | |
| 29 | +export const DEFAULT_COMMON_CODE_SEARCH_FIELD: CommonCodeSearchField = 'name'; | |
| 30 | + | |
| 31 | +/** 시안의 두 목록은 한 화면에 나란히 서므로 페이지 크기를 고르는 자리가 없다 — 고정값이다. */ | |
| 32 | +export const COMMON_CODE_PAGE_SIZE = 10; | |
| 33 | +export const COMMON_CODE_DETAIL_PAGE_SIZE = 10; | |
| 34 | + | |
| 35 | +const DEFAULT_PAGE = 1; | |
| 36 | +const MAX_KEYWORD_LENGTH = 100; | |
| 37 | + | |
| 38 | +export type CommonCodeQuery = { | |
| 39 | + /** 선택된 공통코드. 아직 고르지 않았으면 null이고, 화면이 첫 행으로 채운다. */ | |
| 40 | + comCd: string | null; | |
| 41 | + searchField: CommonCodeSearchField; | |
| 42 | + keyword: string; | |
| 43 | + /** 공통코드 목록의 페이지. */ | |
| 44 | + page: number; | |
| 45 | + /** 상세코드 목록의 페이지. */ | |
| 46 | + detailPage: number; | |
| 47 | +}; | |
| 48 | + | |
| 49 | +type RawSearchParams = Record<string, string | string[] | undefined>; | |
| 50 | + | |
| 51 | +function readParam(params: RawSearchParams, key: string): string | undefined { | |
| 52 | + const value = params[key]; | |
| 53 | + return Array.isArray(value) ? value[0] : value; | |
| 54 | +} | |
| 55 | + | |
| 56 | +function readPage(params: RawSearchParams, key: string): number { | |
| 57 | + const value = Number(readParam(params, key)); | |
| 58 | + return Number.isInteger(value) && value > 0 ? value : DEFAULT_PAGE; | |
| 59 | +} | |
| 60 | + | |
| 61 | +function isSearchField( | |
| 62 | + value: string | undefined | |
| 63 | +): value is CommonCodeSearchField { | |
| 64 | + return ( | |
| 65 | + value !== undefined && | |
| 66 | + COMMON_CODE_SEARCH_FIELD_OPTIONS.some((option) => option.value === value) | |
| 67 | + ); | |
| 68 | +} | |
| 69 | + | |
| 70 | +/** | |
| 71 | + * URL의 searchParams를 검증된 `CommonCodeQuery`로 정규화한다. searchParams는 사용자가 임의로 | |
| 72 | + * 조작할 수 있는 값이라 신뢰하지 않는다 — 허용 목록을 벗어나면 기본값으로 떨어진다. | |
| 73 | + * | |
| 74 | + * `comCd`만은 허용 목록을 여기서 확인할 수 없다(코드 목록이 서버에 있다) — 존재 여부는 화면이 | |
| 75 | + * 조회 결과와 맞춰 보고 없으면 첫 행으로 대체한다. | |
| 76 | + */ | |
| 77 | +export function parseCommonCodeQuery( | |
| 78 | + searchParams: RawSearchParams | |
| 79 | +): CommonCodeQuery { | |
| 80 | + const searchFieldRaw = readParam(searchParams, 'searchField'); | |
| 81 | + const comCd = readParam(searchParams, 'comCd')?.trim(); | |
| 82 | + | |
| 83 | + return { | |
| 84 | + comCd: comCd ? comCd.slice(0, 50) : null, | |
| 85 | + searchField: isSearchField(searchFieldRaw) | |
| 86 | + ? searchFieldRaw | |
| 87 | + : DEFAULT_COMMON_CODE_SEARCH_FIELD, | |
| 88 | + keyword: (readParam(searchParams, 'keyword') ?? '') | |
| 89 | + .trim() | |
| 90 | + .slice(0, MAX_KEYWORD_LENGTH), | |
| 91 | + page: readPage(searchParams, 'page'), | |
| 92 | + detailPage: readPage(searchParams, 'detailPage'), | |
| 93 | + }; | |
| 94 | +} | |
| 95 | + | |
| 96 | +/** | |
| 97 | + * `CommonCodeQuery`(+ 부분 override)를 링크로 직렬화한다. `parseCommonCodeQuery`의 역연산이며 | |
| 98 | + * 기본값과 같은 필드는 URL에서 생략해 링크를 짧게 유지한다. | |
| 99 | + * | |
| 100 | + * **공통코드 선택이 바뀌면 상세 페이지를 1로 되돌린다** — 호출부가 잊어도 어긋나지 않도록 | |
| 101 | + * 여기서 강제한다(override로 detailPage를 함께 준 경우는 그 값을 존중한다). | |
| 102 | + */ | |
| 103 | +export function buildCommonCodeHref( | |
| 104 | + query: CommonCodeQuery, | |
| 105 | + overrides: Partial<CommonCodeQuery> = {} | |
| 106 | +): string { | |
| 107 | + const merged = { ...query, ...overrides }; | |
| 108 | + | |
| 109 | + if ( | |
| 110 | + overrides.comCd !== undefined && | |
| 111 | + overrides.comCd !== query.comCd && | |
| 112 | + overrides.detailPage === undefined | |
| 113 | + ) { | |
| 114 | + merged.detailPage = DEFAULT_PAGE; | |
| 115 | + } | |
| 116 | + | |
| 117 | + const params = new URLSearchParams(); | |
| 118 | + | |
| 119 | + if (merged.comCd) { | |
| 120 | + params.set('comCd', merged.comCd); | |
| 121 | + } | |
| 122 | + if (merged.searchField !== DEFAULT_COMMON_CODE_SEARCH_FIELD) { | |
| 123 | + params.set('searchField', merged.searchField); | |
| 124 | + } | |
| 125 | + if (merged.keyword) { | |
| 126 | + params.set('keyword', merged.keyword); | |
| 127 | + } | |
| 128 | + if (merged.page !== DEFAULT_PAGE) { | |
| 129 | + params.set('page', String(merged.page)); | |
| 130 | + } | |
| 131 | + if (merged.detailPage !== DEFAULT_PAGE) { | |
| 132 | + params.set('detailPage', String(merged.detailPage)); | |
| 133 | + } | |
| 134 | + | |
| 135 | + const queryString = params.toString(); | |
| 136 | + return queryString ? `${COMMON_CODES_PATH}?${queryString}` : COMMON_CODES_PATH; | |
| 137 | +} |
+++ lib/domain/common-code.ts
... | ... | @@ -0,0 +1,117 @@ |
| 1 | +/** | |
| 2 | + * 공통코드 도메인 — 순수 데이터 표현, 외부 의존 없음. | |
| 3 | + * | |
| 4 | + * 백엔드 `TB_SYS_COM_CD`(그룹)와 `TB_SYS_COM_CD_DTL`(상세)의 두 층이며, 둘 다 | |
| 5 | + * `/api/v1/mngr/code/**`(ROLE_ADMIN)로 읽고 쓴다. | |
| 6 | + * | |
| 7 | + * 이 파일은 두 종류의 소비자를 함께 섬긴다. | |
| 8 | + * - 코드관리 화면(SYS_COD_001): 그룹·상세를 편집한다 → `CommonCodeGroup`·`CommonCodeDetail` | |
| 9 | + * - 다른 화면의 선택지: 상세코드를 `{code,label}`로만 쓴다 → `CommonCode` | |
| 10 | + */ | |
| 11 | + | |
| 12 | +/** 선택지로 쓸 때의 최소 표현. 화면이 코드 편집에 관심이 없을 때 쓴다. */ | |
| 13 | +export type CommonCode = { | |
| 14 | + /** 백엔드 `comDtlCd` — 저장·전송에 쓰는 코드값. */ | |
| 15 | + code: string; | |
| 16 | + /** 백엔드 `cdNm` — 화면에 보이는 이름. */ | |
| 17 | + label: string; | |
| 18 | +}; | |
| 19 | + | |
| 20 | +/** 코드 그룹 ID — 화면이 문자열을 직접 적지 않도록 여기 모은다. */ | |
| 21 | +export const CODE_GROUP = { | |
| 22 | + /** 꾸미기 아이템 카테고리. */ | |
| 23 | + decorationItemCategory: 'ITEM_CATE_CD', | |
| 24 | +} as const; | |
| 25 | + | |
| 26 | +/** 값이 없는 항목의 화면 표기. */ | |
| 27 | +export const EMPTY_FIELD_PLACEHOLDER = '-'; | |
| 28 | + | |
| 29 | +/** | |
| 30 | + * 공통코드(그룹) — 시안의 "공통코드 목록" 한 줄. | |
| 31 | + * | |
| 32 | + * `TB_SYS_COM_CD`의 컬럼은 이 셋이 전부다(+감사 컬럼). 시안 등록 팝업의 "분류코드"에 | |
| 33 | + * 해당하는 컬럼은 **없다** — 그 셀렉트는 코드ID 접두사를 채워 주는 입력 보조일 뿐이고 | |
| 34 | + * 저장되는 값은 `comCd` 하나다(사용자 확정 사항). | |
| 35 | + */ | |
| 36 | +export type CommonCodeGroup = { | |
| 37 | + /** 백엔드 `comCd` — PK이자 화면의 "코드ID". 상세코드를 묶는 키다. */ | |
| 38 | + comCd: string; | |
| 39 | + /** 백엔드 `cdNm` — 화면의 "코드명". */ | |
| 40 | + name: string; | |
| 41 | + /** 백엔드 `cdExpln` — 화면의 "코드설명". */ | |
| 42 | + description: string | null; | |
| 43 | + /** 백엔드 `frstRegDtStr` — `YYYY-MM-DD`. */ | |
| 44 | + createdAt: string | null; | |
| 45 | +}; | |
| 46 | + | |
| 47 | +/** | |
| 48 | + * 상세코드 — 시안의 "상세코드 목록" 한 줄. | |
| 49 | + * | |
| 50 | + * ⚠️ `description`은 지금 **항상 null이다** — 목록 조회 SQL이 `DTL_CD_EXPLN`을 select하지 | |
| 51 | + * 않는다. 등록 INSERT에서도 빠져 있어 수정으로만 저장된다(Repository 주석 참조). | |
| 52 | + */ | |
| 53 | +export type CommonCodeDetail = { | |
| 54 | + /** 백엔드 `comCd` — 이 상세코드가 속한 그룹. */ | |
| 55 | + comCd: string; | |
| 56 | + /** 백엔드 `comDtlCd` — 그룹 안에서의 코드값. 화면의 "상세코드ID". */ | |
| 57 | + comDtlCd: string; | |
| 58 | + /** 백엔드 `cdNm` — 화면의 "상세코드명"이자 등록 팝업의 "코드값의미". */ | |
| 59 | + name: string; | |
| 60 | + /** 백엔드 `dtlCdExpln`. */ | |
| 61 | + description: string | null; | |
| 62 | + /** 백엔드 `sortSeq` — 등록 팝업의 "정렬번호"이자 목록의 "번호". */ | |
| 63 | + sortSeq: number; | |
| 64 | + /** 백엔드 `frstRegDtStr` — `YYYY-MM-DD`. */ | |
| 65 | + createdAt: string | null; | |
| 66 | +}; | |
| 67 | + | |
| 68 | +/** | |
| 69 | + * 코드ID 접두사(시안의 "분류코드") — 저장되는 값이 아니라 코드ID를 지을 때의 작명 규칙이다. | |
| 70 | + * 시안 목록이 전부 `CMS004`처럼 접두사 3자 + 일련번호라 그 규칙을 화면이 거들게 한다. | |
| 71 | + * | |
| 72 | + * 백엔드에 분류 컬럼이 생기면 이 상수 대신 그 코드 목록을 쓰면 된다. | |
| 73 | + */ | |
| 74 | +export const CODE_ID_PREFIXES: readonly string[] = ['CMS', 'SYS', 'FSC', 'CST']; | |
| 75 | + | |
| 76 | +export const DEFAULT_CODE_ID_PREFIX = CODE_ID_PREFIXES[0]; | |
| 77 | + | |
| 78 | +/** | |
| 79 | + * 코드ID에서 접두사를 읽는다 — 앞 3자가 아는 접두사면 그것을, 아니면 기본값을 돌려준다. | |
| 80 | + * 수정 팝업이 기존 코드ID로 셀렉트의 초기값을 정할 때 쓴다. | |
| 81 | + */ | |
| 82 | +export function readCodeIdPrefix(comCd: string): string { | |
| 83 | + const head = comCd.slice(0, 3).toUpperCase(); | |
| 84 | + return CODE_ID_PREFIXES.includes(head) ? head : DEFAULT_CODE_ID_PREFIX; | |
| 85 | +} | |
| 86 | + | |
| 87 | +/** | |
| 88 | + * 접두사를 바꿔 끼운 코드ID를 만든다. 기존 값이 아는 접두사로 시작하면 그 자리를 갈아 끼우고, | |
| 89 | + * 아니면 앞에 덧붙인다 — 사용자가 이미 적어 둔 일련번호를 지우지 않기 위해서다. | |
| 90 | + */ | |
| 91 | +export function applyCodeIdPrefix(comCd: string, prefix: string): string { | |
| 92 | + const rest = CODE_ID_PREFIXES.includes(comCd.slice(0, 3).toUpperCase()) | |
| 93 | + ? comCd.slice(3) | |
| 94 | + : comCd; | |
| 95 | + return `${prefix}${rest}`; | |
| 96 | +} | |
| 97 | + | |
| 98 | +export function formatOptionalText(value: string | null | undefined): string { | |
| 99 | + return value === null || value === undefined || value === '' | |
| 100 | + ? EMPTY_FIELD_PLACEHOLDER | |
| 101 | + : value; | |
| 102 | +} | |
| 103 | + | |
| 104 | +/** 코드값 → 이름. 목록에 없는 코드는 코드값 자체를 보여준다(이름을 지어내지 않는다). */ | |
| 105 | +export function formatCommonCode( | |
| 106 | + codes: readonly CommonCode[], | |
| 107 | + code: string | |
| 108 | +): string { | |
| 109 | + return codes.find((item) => item.code === code)?.label ?? code; | |
| 110 | +} | |
| 111 | + | |
| 112 | +export function isKnownCommonCode( | |
| 113 | + codes: readonly CommonCode[], | |
| 114 | + code: string | |
| 115 | +): boolean { | |
| 116 | + return codes.some((item) => item.code === code); | |
| 117 | +} |
--- 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,82 @@ |
| 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 | +} | |
| 60 | + | |
| 61 | +/** | |
| 62 | + * 휴대전화 앞자리 목록 — `FoxPhoneNumber`의 앞자리 셀렉트에 넘긴다. | |
| 63 | + * | |
| 64 | + * **넘기지 않으면 그 셀렉트에 고를 것이 하나도 없다**(@fox의 기본값이 빈 배열이다). 그때도 칸에는 | |
| 65 | + * placeholder "010"이 떠서 고른 것처럼 보이는데 값은 빈 문자열이라, 가운데·끝자리를 다 채워도 | |
| 66 | + * 8자리만 제출돼 "휴대전화 번호를 정확히 입력해 주세요."로 막힌다. | |
| 67 | + */ | |
| 68 | +export const MOBILE_PHONE_PREFIXES: readonly string[] = [ | |
| 69 | + '010', | |
| 70 | + '011', | |
| 71 | + '016', | |
| 72 | + '017', | |
| 73 | + '018', | |
| 74 | + '019', | |
| 75 | +]; | |
| 76 | + | |
| 77 | +/** | |
| 78 | + * 앞자리 기본값. **화면이 이 값으로 시작해야 한다** — 셀렉트를 비워 두면 placeholder "010"이 | |
| 79 | + * 고른 것처럼 보이는데 값은 비어 있어, 사용자가 앞자리를 건드리지 않고 뒷자리만 채우면 8자리가 | |
| 80 | + * 제출된다(실제로 그래서 등록이 막혔다). | |
| 81 | + */ | |
| 82 | +export const DEFAULT_MOBILE_PHONE_PREFIX = MOBILE_PHONE_PREFIXES[0]; |
--- 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?