feat: 꾸미기 아이템 관리 화면을 시안대로 — 유형 탭 + @fox 목록/등록 팝업
시안 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) ADM_ITM_101(5227:3549)·ADM_ITM_102_p(5227:4629).
로직(백엔드 연동·Server Action·검증)은 이미 있었고 화면만 Tailwind 제거 이후 무스타일로
남아 있던 components/ui 위에 있었다 — 관리자·학생 화면과 같이 @fox로 옮겼다.
목록
- FoxListContainer + 유형 탭(개별/셋트). 셋트는 별도 화면이 없어 탭이 조회 조건(itemType)만
바꾼다 — 검색·페이지와 같은 층위라 URL이 소유하고, 탭을 바꾸면 검색어를 지운다(유형이
다르면 아이템 집합 자체가 달라 이전 검색어는 "결과 없음"만 만든다).
- href 대신 router.replace를 쓴다. 같은 화면 안의 전환이라 role="tablist" 의미가 맞고
(FoxTabItem은 href를 주면 탭 역할을 뗀다), 평범한 는 App Router에서 문서를 다시 받는다.
- 10개 열·사용여부는 FoxStatusIndicator(점+사용/미사용)·관리는 테두리 있는 FoxIconButton.
등록·수정 팝업
- FoxModal(sm=400) + 폼 안의 유형 탭. FoxTab·FoxSelect는 폼 요소가 아니라 값은 hidden으로
실어 보낸다.
- 썸네일은 FoxFileUpload(image) — 네이티브 입력에 name을 주면 고른 파일이 폼 제출에 그대로
실려 파일을 따로 들고 있지 않는다.
@fox (전부 추가·수정이고 기존 화면의 모양은 그대로다)
- FoxListContainer.tabs — 머리말과 목록 사이 슬롯(시안이 탭을 그 자리에 둔다).
- FoxInput.suffix — 입력 오른쪽 글자(시안 정렬순서의 "/ 총 39개").
- FoxInput.requirement / FoxFileUpload.requirement — 라벨의 필수 표시. FoxSelect에만 있던
prop을 같은 규약으로 맞췄다.
- FoxFileUpload.name — 네이티브 파일 입력의 name 통과.
- FoxTab(line) 밑줄을 border → inset 그림자로. Figma는 stroke를 상자 안쪽에 그려 시안의
tab-item이 고름 여부와 무관하게 60px인데, border는 상자를 키워 고른 탭만 64px이 되고
옆 탭과 3px 어긋났다.
검증 — 시안 대조(PC 1600): 열 폭 80/236/160/236/160/160/120/80/160/160 일치, 탭 60px 일치,
썸네일 80×80·1px #e6e8ea·radius 4, 사용여부 #267337·점 6px, 관리 버튼 28×28·1px #b1b8be·
아이콘 20, 팝업 400px·버튼 80×40, 정렬순서 suffix "/ 총 39개".
Co-Authored-By: Claude Opus 5
@f15976e62c8b4e74cb36a226693564499b51947c
--- @fox/core/components/fox-file-upload/fox-file-upload.tsx
+++ @fox/core/components/fox-file-upload/fox-file-upload.tsx
... | ... | @@ -9,7 +9,7 @@ |
| 9 | 9 |
type CSSProperties, |
| 10 | 10 |
type DragEvent, |
| 11 | 11 |
} from "react"; |
| 12 |
-import { FoxFormLabel } from "../fox-form-label";
|
|
| 12 |
+import { FoxFormLabel, type FoxFormLabelRequirement } from "../fox-form-label";
|
|
| 13 | 13 |
import { cx } from "../../utils";
|
| 14 | 14 |
import { FoxButton } from "../fox-button";
|
| 15 | 15 |
import { FoxIconButton } from "../fox-icon-button";
|
... | ... | @@ -47,6 +47,11 @@ |
| 47 | 47 |
accept?: string; |
| 48 | 48 |
multiple?: boolean; |
| 49 | 49 |
/** |
| 50 |
+ * 네이티브 파일 입력의 `name`. 주면 고른 파일이 감싼 `<form>`의 제출에 그대로 실린다 — |
|
| 51 |
+ * 값을 따로 들고 있지 않아도 서버로 넘어간다. |
|
| 52 |
+ */ |
|
| 53 |
+ name?: string; |
|
| 54 |
+ /** |
|
| 50 | 55 |
* `default`·`area`에서 목록을 보여준다. `image`에는 목록이 없다. |
| 51 | 56 |
* 넘기지 않으면 `area`이거나 여러 개를 받을 때만 켜진다 — 파일 하나는 칸이 곧 목록이다. |
| 52 | 57 |
*/ |
... | ... | @@ -55,6 +60,8 @@ |
| 55 | 60 |
droppable?: boolean; |
| 56 | 61 |
|
| 57 | 62 |
label?: string; |
| 63 |
+ /** 라벨 뒤 필수·선택 표시. `label`이 없으면 의미 없다(`FoxInput`·`FoxSelect`와 같은 규약). */ |
|
| 64 |
+ requirement?: FoxFormLabelRequirement; |
|
| 58 | 65 |
description?: string; |
| 59 | 66 |
placeholder?: string; |
| 60 | 67 |
areaText?: string; |
... | ... | @@ -188,9 +195,11 @@ |
| 188 | 195 |
onRemove, |
| 189 | 196 |
accept, |
| 190 | 197 |
multiple = false, |
| 198 |
+ name, |
|
| 191 | 199 |
showList, |
| 192 | 200 |
droppable = false, |
| 193 | 201 |
label, |
| 202 |
+ requirement, |
|
| 194 | 203 |
description, |
| 195 | 204 |
placeholder = "파일을 선택해 주세요.", |
| 196 | 205 |
areaText = DEFAULT_AREA_TEXT, |
... | ... | @@ -318,7 +327,7 @@ |
| 318 | 327 |
onDrop={handleDrop}
|
| 319 | 328 |
> |
| 320 | 329 |
{label && (
|
| 321 |
- <FoxFormLabel as="span" id={`${autoId}-label`}>
|
|
| 330 |
+ <FoxFormLabel as="span" id={`${autoId}-label`} requirement={requirement}>
|
|
| 322 | 331 |
{label}
|
| 323 | 332 |
</FoxFormLabel> |
| 324 | 333 |
)} |
... | ... | @@ -327,6 +336,7 @@ |
| 327 | 336 |
<input |
| 328 | 337 |
ref={pickerRef}
|
| 329 | 338 |
type="file" |
| 339 |
+ name={name}
|
|
| 330 | 340 |
accept={accept}
|
| 331 | 341 |
multiple={multiple}
|
| 332 | 342 |
disabled={disabled}
|
--- @fox/core/components/fox-input/fox-input.tsx
+++ @fox/core/components/fox-input/fox-input.tsx
... | ... | @@ -10,7 +10,7 @@ |
| 10 | 10 |
type ReactNode, |
| 11 | 11 |
} from "react"; |
| 12 | 12 |
import { cx } from "../../utils";
|
| 13 |
-import { FoxFormLabel } from "../fox-form-label";
|
|
| 13 |
+import { FoxFormLabel, type FoxFormLabelRequirement } from "../fox-form-label";
|
|
| 14 | 14 |
|
| 15 | 15 |
export type FoxInputSize = "lg" | "md" | "sm"; |
| 16 | 16 |
export type FoxInputState = "default" | "focused" | "completed" | "error" | "disabled" | "view"; |
... | ... | @@ -31,12 +31,16 @@ |
| 31 | 31 |
onChange?: (value: string, event?: ChangeEvent<HTMLInputElement>) => void; |
| 32 | 32 |
onInput?: (value: string, event: FormEvent<HTMLInputElement>) => void; |
| 33 | 33 |
label?: string; |
| 34 |
+ /** 라벨 뒤 필수·선택 표시. `label`이 없으면 의미 없다(`FoxSelect`와 같은 규약). */ |
|
| 35 |
+ requirement?: FoxFormLabelRequirement; |
|
| 34 | 36 |
/** 헬퍼 메시지. 색은 상태를 따라간다. */ |
| 35 | 37 |
message?: string; |
| 36 | 38 |
/** 헬퍼 메시지 앞 아이콘. `currentColor`로 그려야 상태 색을 따라간다. */ |
| 37 | 39 |
messageIcon?: ReactNode; |
| 38 | 40 |
/** 입력 오른쪽 아이콘. `currentColor`로 그린 SVG여야 색이 적용된다. */ |
| 39 | 41 |
icon?: ReactNode; |
| 42 |
+ /** 입력 오른쪽에 붙는 글자. 아이콘 슬롯과 달리 크기가 글자에 맞춰진다. */ |
|
| 43 |
+ suffix?: ReactNode; |
|
| 40 | 44 |
/** 포커스 중이고 값이 있으면 지우기 버튼을 보여준다. */ |
| 41 | 45 |
clearable?: boolean; |
| 42 | 46 |
clearLabel?: string; |
... | ... | @@ -70,9 +74,11 @@ |
| 70 | 74 |
onChange, |
| 71 | 75 |
onInput, |
| 72 | 76 |
label, |
| 77 |
+ requirement, |
|
| 73 | 78 |
message, |
| 74 | 79 |
messageIcon, |
| 75 | 80 |
icon, |
| 81 |
+ suffix, |
|
| 76 | 82 |
clearable = false, |
| 77 | 83 |
clearLabel = "입력 지우기", |
| 78 | 84 |
invalid = false, |
... | ... | @@ -138,7 +144,9 @@ |
| 138 | 144 |
data-state={state}
|
| 139 | 145 |
> |
| 140 | 146 |
{label && (
|
| 141 |
- <FoxFormLabel htmlFor={fieldId}>{label}</FoxFormLabel>
|
|
| 147 |
+ <FoxFormLabel htmlFor={fieldId} requirement={requirement}>
|
|
| 148 |
+ {label}
|
|
| 149 |
+ </FoxFormLabel> |
|
| 142 | 150 |
)} |
| 143 | 151 |
|
| 144 | 152 |
<div className="fox-input__box"> |
... | ... | @@ -170,6 +178,8 @@ |
| 170 | 178 |
</button> |
| 171 | 179 |
)} |
| 172 | 180 |
|
| 181 |
+ {suffix && <span className="fox-input__suffix">{suffix}</span>}
|
|
| 182 |
+ |
|
| 173 | 183 |
{icon && (
|
| 174 | 184 |
<span className="fox-input__icon" aria-hidden="true"> |
| 175 | 185 |
{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/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-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 |
} |
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-create-modal.tsx
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-create-modal.tsx
... | ... | @@ -1,10 +1,10 @@ |
| 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 | 9 |
import type { DecorationItemType } from '@/lib/domain/decoration-item';
|
| 10 | 10 |
import { INITIAL_DECORATION_ITEM_FORM_STATE } from '@/lib/domain/decoration-item-form';
|
... | ... | @@ -18,17 +18,16 @@ |
| 18 | 18 |
onClose: () => void; |
| 19 | 19 |
} |
| 20 | 20 |
|
| 21 |
-const FORM_ID = 'decoration-item-create-form'; |
|
| 22 |
- |
|
| 23 | 21 |
/** |
| 24 |
- * 꾸미기 아이템 등록 팝업(시안 ADM_ITM_102_p). |
|
| 22 |
+ * 꾸미기 아이템 등록 팝업 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) ADM_ITM_102_p (5227:4629) |
|
| 25 | 23 |
* |
| 26 |
- * 아이템ID는 입력 항목이 아니다 — 시안대로 "등록 후 자동발급됩니다."를 읽기 전용으로 보여주고, |
|
| 27 |
- * 실제 값은 백엔드가 `itemSn`(자동증가)으로 채운다. |
|
| 24 |
+ * **아이템ID는 입력받지 않는다.** 시안은 직접 입력하는 칸으로 그리지만 백엔드 `TB_COM_ITEM`에 |
|
| 25 |
+ * 그런 컬럼이 없고 쓰기 API도 그 값을 받지 않는다 — 자동증가 PK인 `itemSn`이 목록의 아이템ID로 |
|
| 26 |
+ * 그대로 노출되는 값이라, 등록 시점에는 발급 전이므로 읽기 전용 안내로 둔다(백엔드에 컬럼이 |
|
| 27 |
+ * 생기면 이 칸만 입력으로 바꾸면 된다). |
|
| 28 | 28 |
* |
| 29 |
- * 저장 버튼은 footer 슬롯에서 `form={FORM_ID}` 속성으로 폼과 연결한다(admins의 등록 팝업과
|
|
| 30 |
- * 동일한 패턴) — 버튼이 실제 DOM상 form의 자손이 아니어도 같은 문서 안에서 id만 일치하면 그 |
|
| 31 |
- * form을 제출한다. |
|
| 29 |
+ * 등록 버튼은 foot 슬롯에 그려져 폼의 자손이 아니다 — `FoxButton`에 폼을 가리키는 prop이 없어 |
|
| 30 |
+ * ref로 직접 제출한다(학생 회원 조회 팝업과 같은 방식). |
|
| 32 | 31 |
*/ |
| 33 | 32 |
export function DecorationItemCreateModal({
|
| 34 | 33 |
defaultItemType, |
... | ... | @@ -36,6 +35,7 @@ |
| 36 | 35 |
onClose, |
| 37 | 36 |
}: DecorationItemCreateModalProps) {
|
| 38 | 37 |
const { showToast } = useFeedback();
|
| 38 |
+ const formRef = useRef<HTMLFormElement>(null); |
|
| 39 | 39 |
const [state, formAction, isPending] = useActionState( |
| 40 | 40 |
createDecorationItemAction, |
| 41 | 41 |
INITIAL_DECORATION_ITEM_FORM_STATE |
... | ... | @@ -51,44 +51,44 @@ |
| 51 | 51 |
const errors = state.status === 'error' ? (state.errors ?? {}) : {};
|
| 52 | 52 |
|
| 53 | 53 |
return ( |
| 54 |
- <Modal |
|
| 54 |
+ <FoxModal |
|
| 55 |
+ open |
|
| 56 |
+ size="sm" |
|
| 55 | 57 |
title="꾸미기 아이템 등록" |
| 56 | 58 |
onClose={onClose}
|
| 57 |
- footer={
|
|
| 59 |
+ actions={
|
|
| 58 | 60 |
<> |
| 59 |
- <Button type="button" variant="ghost" onClick={onClose}>
|
|
| 60 |
- 취소 |
|
| 61 |
- </Button> |
|
| 62 |
- <Button |
|
| 63 |
- type="submit" |
|
| 64 |
- form={FORM_ID}
|
|
| 65 |
- variant="primary" |
|
| 61 |
+ <FoxButton type="default" size="md" label="취소" onAction={onClose} />
|
|
| 62 |
+ <FoxButton |
|
| 63 |
+ type="primary" |
|
| 64 |
+ size="md" |
|
| 65 |
+ label={isPending ? '등록 중...' : '등록'}
|
|
| 66 | 66 |
disabled={isPending}
|
| 67 |
- > |
|
| 68 |
- {isPending ? '등록 중...' : '등록'}
|
|
| 69 |
- </Button> |
|
| 67 |
+ onAction={() => formRef.current?.requestSubmit()}
|
|
| 68 |
+ /> |
|
| 70 | 69 |
</> |
| 71 | 70 |
} |
| 72 | 71 |
> |
| 73 |
- <form id={FORM_ID} action={formAction} className="flex flex-col gap-4">
|
|
| 74 |
- <p className="text-right text-body-sm text-danger"> |
|
| 75 |
- * 는 필수 항목입니다. |
|
| 76 |
- </p> |
|
| 77 |
- |
|
| 78 |
- <Field label="아이템 ID"> |
|
| 79 |
- <Input type="text" value="등록 후 자동발급됩니다." readOnly /> |
|
| 80 |
- </Field> |
|
| 81 |
- |
|
| 72 |
+ <form ref={formRef} action={formAction}>
|
|
| 82 | 73 |
<DecorationItemFormFields |
| 83 | 74 |
defaultItemType={defaultItemType}
|
| 84 | 75 |
typeTotalCount={typeTotalCount}
|
| 85 | 76 |
errors={errors}
|
| 77 |
+ itemIdField={
|
|
| 78 |
+ <FoxInput |
|
| 79 |
+ size="md" |
|
| 80 |
+ label="아이템 ID" |
|
| 81 |
+ requirement="required" |
|
| 82 |
+ value="등록 후 자동발급됩니다." |
|
| 83 |
+ readOnly |
|
| 84 |
+ /> |
|
| 85 |
+ } |
|
| 86 | 86 |
/> |
| 87 | 87 |
|
| 88 | 88 |
{state.status === 'error' && state.message && (
|
| 89 |
- <p className="text-body-sm text-danger">{state.message}</p>
|
|
| 89 |
+ <FoxHelperText type="danger" message={state.message} />
|
|
| 90 | 90 |
)} |
| 91 | 91 |
</form> |
| 92 |
- </Modal> |
|
| 92 |
+ </FoxModal> |
|
| 93 | 93 |
); |
| 94 | 94 |
} |
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-edit-modal.tsx
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-edit-modal.tsx
... | ... | @@ -1,10 +1,10 @@ |
| 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 | 9 |
import type { DecorationItem } from '@/lib/domain/decoration-item';
|
| 10 | 10 |
import { INITIAL_DECORATION_ITEM_FORM_STATE } from '@/lib/domain/decoration-item-form';
|
... | ... | @@ -17,16 +17,13 @@ |
| 17 | 17 |
onClose: () => void; |
| 18 | 18 |
} |
| 19 | 19 |
|
| 20 |
-const FORM_ID = 'decoration-item-edit-form'; |
|
| 21 |
- |
|
| 22 | 20 |
/** |
| 23 |
- * 꾸미기 아이템 수정 팝업(시안 ADM_ITM_103_p) — 등록과 동일 항목이되 **아이템ID는 읽기 |
|
| 24 |
- * 전용**이다. |
|
| 21 |
+ * 꾸미기 아이템 수정 팝업(시안 ADM_ITM_103_p) — 등록 팝업(5227:4629)과 같은 구성이되 |
|
| 22 |
+ * **아이템ID는 읽기 전용**이다. |
|
| 25 | 23 |
* |
| 26 |
- * readOnly로 보여주는 아이템ID `Input`에는 `name`을 주지 않아 제출 대상에서 아예 빠지게 하고, |
|
| 27 |
- * 실제 수정 대상은 별도 hidden input(`itemSn`)으로 넘긴다 — Server Action도 hidden 값만 |
|
| 28 |
- * 읽으므로 readOnly 필드를 위조해서 보내도 수정 대상이 바뀌지 않는다(admins의 이름/ID readOnly |
|
| 29 |
- * 처리와 동일한 방어 방식). |
|
| 24 |
+ * 읽기 전용 아이템ID 칸에는 `name`을 주지 않아 제출 대상에서 아예 빠지게 하고, 실제 수정 대상은 |
|
| 25 |
+ * 별도 hidden input(`itemSn`)으로 넘긴다 — Server Action도 hidden 값만 읽으므로 읽기 전용 칸을 |
|
| 26 |
+ * 위조해 보내도 수정 대상이 바뀌지 않는다. |
|
| 30 | 27 |
*/ |
| 31 | 28 |
export function DecorationItemEditModal({
|
| 32 | 29 |
item, |
... | ... | @@ -34,6 +31,7 @@ |
| 34 | 31 |
onClose, |
| 35 | 32 |
}: DecorationItemEditModalProps) {
|
| 36 | 33 |
const { showToast } = useFeedback();
|
| 34 |
+ const formRef = useRef<HTMLFormElement>(null); |
|
| 37 | 35 |
const [state, formAction, isPending] = useActionState( |
| 38 | 36 |
updateDecorationItemAction, |
| 39 | 37 |
INITIAL_DECORATION_ITEM_FORM_STATE |
... | ... | @@ -49,47 +47,47 @@ |
| 49 | 47 |
const errors = state.status === 'error' ? (state.errors ?? {}) : {};
|
| 50 | 48 |
|
| 51 | 49 |
return ( |
| 52 |
- <Modal |
|
| 50 |
+ <FoxModal |
|
| 51 |
+ open |
|
| 52 |
+ size="sm" |
|
| 53 | 53 |
title="꾸미기 아이템 수정" |
| 54 | 54 |
onClose={onClose}
|
| 55 |
- footer={
|
|
| 55 |
+ actions={
|
|
| 56 | 56 |
<> |
| 57 |
- <Button type="button" variant="ghost" onClick={onClose}>
|
|
| 58 |
- 취소 |
|
| 59 |
- </Button> |
|
| 60 |
- <Button |
|
| 61 |
- type="submit" |
|
| 62 |
- form={FORM_ID}
|
|
| 63 |
- variant="primary" |
|
| 57 |
+ <FoxButton type="default" size="md" label="취소" onAction={onClose} />
|
|
| 58 |
+ <FoxButton |
|
| 59 |
+ type="primary" |
|
| 60 |
+ size="md" |
|
| 61 |
+ label={isPending ? '수정 중...' : '수정'}
|
|
| 64 | 62 |
disabled={isPending}
|
| 65 |
- > |
|
| 66 |
- {isPending ? '수정 중...' : '수정'}
|
|
| 67 |
- </Button> |
|
| 63 |
+ onAction={() => formRef.current?.requestSubmit()}
|
|
| 64 |
+ /> |
|
| 68 | 65 |
</> |
| 69 | 66 |
} |
| 70 | 67 |
> |
| 71 |
- <form id={FORM_ID} action={formAction} className="flex flex-col gap-4">
|
|
| 68 |
+ <form ref={formRef} action={formAction}>
|
|
| 72 | 69 |
<input type="hidden" name="itemSn" value={item.itemSn} />
|
| 73 |
- |
|
| 74 |
- <p className="text-right text-body-sm text-danger"> |
|
| 75 |
- * 는 필수 항목입니다. |
|
| 76 |
- </p> |
|
| 77 |
- |
|
| 78 |
- <Field label="아이템 ID"> |
|
| 79 |
- <Input type="text" value={item.itemSn} readOnly />
|
|
| 80 |
- </Field> |
|
| 81 | 70 |
|
| 82 | 71 |
<DecorationItemFormFields |
| 83 | 72 |
item={item}
|
| 84 | 73 |
defaultItemType={item.itemType}
|
| 85 | 74 |
typeTotalCount={typeTotalCount}
|
| 86 | 75 |
errors={errors}
|
| 76 |
+ itemIdField={
|
|
| 77 |
+ <FoxInput |
|
| 78 |
+ size="md" |
|
| 79 |
+ label="아이템 ID" |
|
| 80 |
+ requirement="required" |
|
| 81 |
+ value={String(item.itemSn)}
|
|
| 82 |
+ readOnly |
|
| 83 |
+ /> |
|
| 84 |
+ } |
|
| 87 | 85 |
/> |
| 88 | 86 |
|
| 89 | 87 |
{state.status === 'error' && state.message && (
|
| 90 |
- <p className="text-body-sm text-danger">{state.message}</p>
|
|
| 88 |
+ <FoxHelperText type="danger" message={state.message} />
|
|
| 91 | 89 |
)} |
| 92 | 90 |
</form> |
| 93 |
- </Modal> |
|
| 91 |
+ </FoxModal> |
|
| 94 | 92 |
); |
| 95 | 93 |
} |
+++ 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,213 @@ |
| 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';
|
|
| 7 | 12 |
import {
|
| 8 | 13 |
DECORATION_ITEM_CATEGORIES, |
| 9 | 14 |
DECORATION_ITEM_TYPE_OPTIONS, |
| 10 | 15 |
DEFAULT_DECORATION_ITEM_CATEGORY_CODE, |
| 16 |
+ formatDecorationItemActiveLabel, |
|
| 11 | 17 |
type DecorationItem, |
| 12 | 18 |
type DecorationItemType, |
| 13 | 19 |
} from '@/lib/domain/decoration-item'; |
| 14 | 20 |
import type { DecorationItemFormErrors } from '@/lib/domain/decoration-item-form';
|
| 15 |
- |
|
| 16 |
-const ACTIVE_STATUS_OPTIONS = [ |
|
| 17 |
- { value: 'true', label: '사용' },
|
|
| 18 |
- { value: 'false', label: '미사용' },
|
|
| 19 |
-]; |
|
| 21 |
+import styles from './decoration-item-form-fields.module.scss'; |
|
| 20 | 22 |
|
| 21 | 23 |
interface DecorationItemFormFieldsProps {
|
| 22 | 24 |
/** 수정 팝업의 기존 값. 등록 팝업은 넘기지 않는다(빈 폼 + 기본값). */ |
| 23 | 25 |
item?: DecorationItem; |
| 24 | 26 |
/** 등록 팝업을 연 시점의 활성 탭(유형) — 수정 팝업에서는 `item.itemType`이 대신 쓰인다. */ |
| 25 | 27 |
defaultItemType: DecorationItemType; |
| 26 |
- /** "정렬순서" 라벨의 "(총 등록 N개)" 힌트와 등록 시 기본값 계산에 쓰는, 현재 유형의 전체 |
|
| 27 |
- * 등록 건수(검색어 무관). */ |
|
| 28 |
+ /** 현재 유형의 전체 등록 건수(검색어 무관) — 정렬순서의 "/ 총 N개"와 기본값에 쓴다. */ |
|
| 28 | 29 |
typeTotalCount: number; |
| 29 | 30 |
errors: DecorationItemFormErrors; |
| 31 |
+ /** 아이템ID 줄 — 등록은 자동발급 안내, 수정은 발급된 값이라 팝업마다 달라 밖에서 받는다. */ |
|
| 32 |
+ itemIdField: ReactNode; |
|
| 30 | 33 |
} |
| 31 | 34 |
|
| 35 |
+/** 시안의 안내 문구 그대로. */ |
|
| 36 |
+const IMAGE_DESCRIPTION = |
|
| 37 |
+ 'JPG, PNG 파일만 가능 (권장 사이즈: 1280x720px, 최대 5MB)'; |
|
| 38 |
+ |
|
| 32 | 39 |
/** |
| 33 |
- * 등록·수정 팝업이 공유하는 입력 항목 — 유형/아이템명/카테고리/포인트/썸네일 이미지/설명/ |
|
| 34 |
- * 사용여부/정렬순서(시안 ADM_ITM_102_p / 103_p). 아이템ID 표시만 각 팝업이 따로 그린다. |
|
| 40 |
+ * 등록·수정 팝업이 공유하는 입력 항목 — 시안 ADM_ITM_102_p(5227:4629). |
|
| 35 | 41 |
* |
| 36 |
- * **유형(개별/셋트) 전환 시 폼 구성이 바뀔 수 있다는 시안 설명이 있지만, 두 유형의 입력 항목이 |
|
| 37 |
- * 완전히 같아 조건부 렌더링을 두지 않았다** — 유형 값 자체만 폼에 실어 저장한다. |
|
| 42 |
+ * 유형은 라디오가 아니라 **탭**이다(시안). 목록의 유형 탭과 달리 이건 화면 이동이 아니라 폼의 |
|
| 43 |
+ * 값이라 상태를 여기서 들고, 저장에는 hidden 필드로 실어 보낸다 — `FoxTab`은 폼 요소가 아니다. |
|
| 44 |
+ * 카테고리(`FoxSelect`)도 네이티브 `<select>`가 아니라 버튼+리스트박스라 같은 방식이다. |
|
| 38 | 45 |
* |
| 39 |
- * **이미지는 파일 하나만 받는다** — 시안의 "썸네일, 아이템 이미지 각각 등록? → NO"에 따른 것이고, |
|
| 40 |
- * 저장은 백엔드의 `atchFileId`에만 한다(썸네일은 추후 백엔드가 자동생성할 예정, 사용자 확정 사항). |
|
| 41 |
- * 수정 팝업에서 파일을 새로 고르지 않으면 hidden 필드로 유지한 기존 파일 ID가 그대로 저장된다. |
|
| 46 |
+ * **두 유형의 입력 항목은 완전히 같다** — 시안이 "셋트아이템도 동일한 항목으로 처리"라고 |
|
| 47 |
+ * 명시해 탭은 저장될 유형 값만 바꾼다. |
|
| 42 | 48 |
* |
| 43 |
- * 설명은 여러 줄 입력이 자연스럽지만 공용 textarea 컴포넌트가 없어 로직 우선 단계에서는 단일행 |
|
| 44 |
- * `Input`으로 둔다(신설은 design 레인 소관). |
|
| 49 |
+ * 썸네일은 파일 하나만 받는다. `FoxFileUpload`의 네이티브 입력에 `name`을 주면 고른 파일이 |
|
| 50 |
+ * 폼 제출에 그대로 실리므로 파일을 따로 들고 있지 않는다. 수정 팝업에서 새로 고르지 않으면 |
|
| 51 |
+ * hidden 필드의 기존 파일 ID가 그대로 저장된다. |
|
| 45 | 52 |
*/ |
| 46 | 53 |
export function DecorationItemFormFields({
|
| 47 | 54 |
item, |
| 48 | 55 |
defaultItemType, |
| 49 | 56 |
typeTotalCount, |
| 50 | 57 |
errors, |
| 58 |
+ itemIdField, |
|
| 51 | 59 |
}: DecorationItemFormFieldsProps) {
|
| 60 |
+ const [itemType, setItemType] = useState<DecorationItemType>( |
|
| 61 |
+ item?.itemType ?? defaultItemType |
|
| 62 |
+ ); |
|
| 63 |
+ const [categoryCode, setCategoryCode] = useState( |
|
| 64 |
+ item?.categoryCode || DEFAULT_DECORATION_ITEM_CATEGORY_CODE |
|
| 65 |
+ ); |
|
| 66 |
+ const [isActive, setIsActive] = useState(item?.isActive ?? true); |
|
| 67 |
+ |
|
| 52 | 68 |
return ( |
| 53 | 69 |
<> |
| 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} />
|
|
| 70 |
+ <FoxTab |
|
| 71 |
+ type="line" |
|
| 72 |
+ size="lg" |
|
| 73 |
+ label="아이템 유형" |
|
| 74 |
+ value={itemType}
|
|
| 75 |
+ onValueChange={(value) => setItemType(value as DecorationItemType)}
|
|
| 76 |
+ > |
|
| 77 |
+ {DECORATION_ITEM_TYPE_OPTIONS.map((option) => (
|
|
| 78 |
+ <FoxTabItem key={option.value} value={option.value}>
|
|
| 79 |
+ {option.label}
|
|
| 80 |
+ </FoxTabItem> |
|
| 81 |
+ ))} |
|
| 82 |
+ </FoxTab> |
|
| 83 |
+ <input type="hidden" name="itemType" value={itemType} />
|
|
| 65 | 84 |
|
| 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} />
|
|
| 85 |
+ <div className={styles.fields}>
|
|
| 86 |
+ <div className={styles.field}>{itemIdField}</div>
|
|
| 75 | 87 |
|
| 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} />
|
|
| 88 |
+ <div className={styles.field}>
|
|
| 89 |
+ <FoxInput |
|
| 90 |
+ size="md" |
|
| 91 |
+ name="name" |
|
| 92 |
+ label="아이템명" |
|
| 93 |
+ requirement="required" |
|
| 94 |
+ defaultValue={item?.name ?? ''}
|
|
| 95 |
+ placeholder="아이템명을 입력하세요." |
|
| 96 |
+ invalid={Boolean(errors.name)}
|
|
| 97 |
+ message={errors.name}
|
|
| 98 |
+ maxLength={100}
|
|
| 99 |
+ /> |
|
| 100 |
+ </div> |
|
| 91 | 101 |
|
| 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} />
|
|
| 102 |
+ <div className={styles.field}>
|
|
| 103 |
+ <FoxSelect |
|
| 104 |
+ size="md" |
|
| 105 |
+ label="카테고리" |
|
| 106 |
+ options={DECORATION_ITEM_CATEGORIES.map((category) => ({
|
|
| 107 |
+ value: category.code, |
|
| 108 |
+ label: category.label, |
|
| 109 |
+ }))} |
|
| 110 |
+ value={categoryCode}
|
|
| 111 |
+ onValueChange={setCategoryCode}
|
|
| 112 |
+ error={Boolean(errors.categoryCode)}
|
|
| 113 |
+ hint={errors.categoryCode}
|
|
| 114 |
+ /> |
|
| 115 |
+ <input type="hidden" name="categoryCode" value={categoryCode} />
|
|
| 116 |
+ </div> |
|
| 103 | 117 |
|
| 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} />
|
|
| 118 |
+ <div className={styles.field}>
|
|
| 119 |
+ <FoxInput |
|
| 120 |
+ size="md" |
|
| 121 |
+ type="number" |
|
| 122 |
+ name="points" |
|
| 123 |
+ label="포인트(오픈 가능한 포인트)" |
|
| 124 |
+ requirement="required" |
|
| 125 |
+ min={0}
|
|
| 126 |
+ step={1}
|
|
| 127 |
+ defaultValue={item?.points}
|
|
| 128 |
+ placeholder="숫자를 입력하세요." |
|
| 129 |
+ invalid={Boolean(errors.points)}
|
|
| 130 |
+ message={errors.points}
|
|
| 131 |
+ /> |
|
| 132 |
+ </div> |
|
| 120 | 133 |
|
| 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} />
|
|
| 134 |
+ <div className={styles.field}>
|
|
| 135 |
+ <FoxFileUpload |
|
| 136 |
+ mode="image" |
|
| 137 |
+ name="imageFile" |
|
| 138 |
+ label="썸네일 이미지" |
|
| 139 |
+ requirement="required" |
|
| 140 |
+ accept="image/jpeg,image/png" |
|
| 141 |
+ selectLabel="파일선택" |
|
| 142 |
+ description={IMAGE_DESCRIPTION}
|
|
| 143 |
+ thumbnailWidth={120}
|
|
| 144 |
+ thumbnailHeight={120}
|
|
| 145 |
+ defaultFiles={
|
|
| 146 |
+ // 수정 팝업은 이미 등록된 이미지를 썸네일 자리에 먼저 보여준다. |
|
| 147 |
+ item?.imageUrl |
|
| 148 |
+ ? [ |
|
| 149 |
+ {
|
|
| 150 |
+ id: item.imageFileId ?? 'current', |
|
| 151 |
+ name: item.name, |
|
| 152 |
+ previewUrl: item.imageUrl, |
|
| 153 |
+ }, |
|
| 154 |
+ ] |
|
| 155 |
+ : undefined |
|
| 156 |
+ } |
|
| 157 |
+ /> |
|
| 158 |
+ {/* 파일을 새로 고르지 않았을 때 기존 이미지를 유지하는 값. 등록 팝업에서는 빈
|
|
| 159 |
+ 문자열이라 파일을 고르지 않으면 검증에서 걸린다. */} |
|
| 160 |
+ <input |
|
| 161 |
+ type="hidden" |
|
| 162 |
+ name="imageFileId" |
|
| 163 |
+ defaultValue={item?.imageFileId ?? ''}
|
|
| 164 |
+ /> |
|
| 165 |
+ {errors.imageFileId && (
|
|
| 166 |
+ <FoxHelperText type="danger" message={errors.imageFileId} />
|
|
| 167 |
+ )} |
|
| 168 |
+ </div> |
|
| 130 | 169 |
|
| 131 |
- <Field label="사용여부"> |
|
| 132 |
- <RadioGroup |
|
| 133 |
- name="isActive" |
|
| 134 |
- options={ACTIVE_STATUS_OPTIONS}
|
|
| 135 |
- defaultValue={String(item?.isActive ?? true)}
|
|
| 136 |
- /> |
|
| 137 |
- </Field> |
|
| 170 |
+ <div className={styles.field}>
|
|
| 171 |
+ <FoxInput |
|
| 172 |
+ size="md" |
|
| 173 |
+ name="description" |
|
| 174 |
+ label="설명" |
|
| 175 |
+ defaultValue={item?.description ?? ''}
|
|
| 176 |
+ placeholder="설명을 입력하세요." |
|
| 177 |
+ invalid={Boolean(errors.description)}
|
|
| 178 |
+ message={errors.description}
|
|
| 179 |
+ maxLength={500}
|
|
| 180 |
+ /> |
|
| 181 |
+ </div> |
|
| 138 | 182 |
|
| 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} />
|
|
| 183 |
+ <div className={styles.field}>
|
|
| 184 |
+ <FoxFormLabel as="span">사용여부</FoxFormLabel> |
|
| 185 |
+ <FoxToggleSwitch |
|
| 186 |
+ size="md" |
|
| 187 |
+ name="isActive" |
|
| 188 |
+ value="true" |
|
| 189 |
+ label={formatDecorationItemActiveLabel(isActive)}
|
|
| 190 |
+ checked={isActive}
|
|
| 191 |
+ onChange={setIsActive}
|
|
| 192 |
+ /> |
|
| 193 |
+ </div> |
|
| 194 |
+ |
|
| 195 |
+ <div className={styles.field}>
|
|
| 196 |
+ <FoxInput |
|
| 197 |
+ size="md" |
|
| 198 |
+ type="number" |
|
| 199 |
+ name="sortOrder" |
|
| 200 |
+ label="정렬순서" |
|
| 201 |
+ min={1}
|
|
| 202 |
+ step={1}
|
|
| 203 |
+ defaultValue={item?.sortOrder ?? typeTotalCount + 1}
|
|
| 204 |
+ placeholder="0번" |
|
| 205 |
+ suffix={`/ 총 ${typeTotalCount}개`}
|
|
| 206 |
+ invalid={Boolean(errors.sortOrder)}
|
|
| 207 |
+ message={errors.sortOrder}
|
|
| 208 |
+ /> |
|
| 209 |
+ </div> |
|
| 210 |
+ </div> |
|
| 149 | 211 |
</> |
| 150 | 212 |
); |
| 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 | 213 |
} |
--- 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,12 @@ |
| 1 | +// 목록 표의 썸네일 — 시안 ADM_ITM_101의 `img-thumb`(80×80 · 1px 테두리 · radius 4 · cover). | |
| 2 | +// @fox에 이미지 조각이 없어 이 화면 전용으로 둔다(값은 전부 @fox 토큰). | |
| 3 | + | |
| 4 | +@use "@fox/styles/abstracts" as fox; | |
| 5 | + | |
| 6 | +.thumbnail { | |
| 7 | + inline-size: 8rem; | |
| 8 | + block-size: 8rem; | |
| 9 | + border: fox.border(1) solid fox.color(border-neutral-subtler); | |
| 10 | + border-radius: fox.radius(2); | |
| 11 | + object-fit: cover; | |
| 12 | +} |
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-list.tsx
... | ... | @@ -0,0 +1,234 @@ |
| 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 { | |
| 16 | + DECORATION_ITEM_TYPE_OPTIONS, | |
| 17 | + EMPTY_FIELD_PLACEHOLDER, | |
| 18 | + formatDecorationItemActiveLabel, | |
| 19 | + formatDecorationItemCategory, | |
| 20 | + formatDecorationItemPoints, | |
| 21 | + formatDecorationItemUpdatedAt, | |
| 22 | + type DecorationItem, | |
| 23 | + type DecorationItemType, | |
| 24 | +} from '@/lib/domain/decoration-item'; | |
| 25 | +import { | |
| 26 | + DECORATION_ITEM_PAGE_SIZE_OPTIONS, | |
| 27 | + DECORATION_ITEM_SEARCH_FIELD_OPTIONS, | |
| 28 | + buildDecorationItemHref, | |
| 29 | + type DecorationItemPageSize, | |
| 30 | + type DecorationItemQuery, | |
| 31 | + type DecorationItemSearchField, | |
| 32 | +} from '@/lib/domain/decoration-item-query'; | |
| 33 | +import { DecorationItemCreateModal } from './decoration-item-create-modal'; | |
| 34 | +import { DecorationItemRowActions } from './decoration-item-row-actions'; | |
| 35 | +import styles from './decoration-item-list.module.scss'; | |
| 36 | + | |
| 37 | +interface DecorationItemListProps { | |
| 38 | + items: DecorationItem[]; | |
| 39 | + query: DecorationItemQuery; | |
| 40 | + /** 화면이 실제로 보여주는 페이지 — 순번 계산의 기준이다. */ | |
| 41 | + currentPage: number; | |
| 42 | + totalPages: number; | |
| 43 | + totalCount: number; | |
| 44 | + /** 현재 유형(개별/셋트)의 검색어 무관 전체 건수 — 등록·수정 팝업의 정렬순서 힌트에 쓴다. */ | |
| 45 | + typeTotalCount: number; | |
| 46 | +} | |
| 47 | + | |
| 48 | +/** | |
| 49 | + * 꾸미기 아이템 목록 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) ADM_ITM_101 (5227:3549) | |
| 50 | + * | |
| 51 | + * 뼈대는 `FoxListContainer`가 그리고, 이 파일은 이 화면 고유의 것만 갖는다 — 유형 탭, 10개 열의 | |
| 52 | + * 정의, 페이지 크기 셀렉트, 신규 등록 버튼. | |
| 53 | + * | |
| 54 | + * **유형 탭이 이 화면의 중심이다.** 셋트아이템은 별도 화면이 없어 탭이 목록의 조회 조건 | |
| 55 | + * (`itemType`)만 바꾼다 — 그래서 검색어·페이지 크기와 같은 층위의 상태이고, 다른 조건과 | |
| 56 | + * 마찬가지로 URL이 소유한다. 링크(`href`)가 아니라 `router.replace`를 쓰는 이유는 두 가지다. | |
| 57 | + * 같은 화면 안의 전환이라 `role="tablist"` 의미가 맞고(FoxTabItem은 `href`를 주면 탭 역할을 | |
| 58 | + * 뗀다), 평범한 `<a>`는 App Router에서 문서 전체를 다시 받는다. | |
| 59 | + * | |
| 60 | + * 탭을 바꾸면 검색어까지 지운다 — 유형이 다르면 아이템 집합 자체가 달라 이전 검색어를 이어 | |
| 61 | + * 붙이면 "결과 없음"만 보게 된다. | |
| 62 | + */ | |
| 63 | +export function DecorationItemList({ | |
| 64 | + items, | |
| 65 | + query, | |
| 66 | + currentPage, | |
| 67 | + totalPages, | |
| 68 | + totalCount, | |
| 69 | + typeTotalCount, | |
| 70 | +}: DecorationItemListProps) { | |
| 71 | + const router = useRouter(); | |
| 72 | + const [isCreateOpen, setIsCreateOpen] = useState(false); | |
| 73 | + | |
| 74 | + /** 목록 조건이 바뀌면 늘 1페이지로 되돌린다 — 이전 페이지 번호는 새 조건에서 의미가 다르다. */ | |
| 75 | + function go(patch: Partial<DecorationItemQuery>) { | |
| 76 | + router.replace(buildDecorationItemHref(query, { ...patch, page: 1 })); | |
| 77 | + } | |
| 78 | + | |
| 79 | + const columns: FoxListColumn<DecorationItem>[] = [ | |
| 80 | + { | |
| 81 | + key: 'no', | |
| 82 | + header: '번호', | |
| 83 | + width: 80, | |
| 84 | + // 저장된 값이 아니라 현재 페이지 기준 표시 순번이다. 목록이 정렬순서 오름차순이라 | |
| 85 | + // "가장 작은 정렬순서가 1번"이 되도록 앞에서부터 센다. | |
| 86 | + render: (_row, index) => (currentPage - 1) * query.pageSize + index + 1, | |
| 87 | + }, | |
| 88 | + { key: 'itemSn', header: '아이템ID', width: 236 }, | |
| 89 | + { | |
| 90 | + key: 'thumbnail', | |
| 91 | + header: '썸네일', | |
| 92 | + width: 160, | |
| 93 | + render: (row) => | |
| 94 | + row.imageUrl ? ( | |
| 95 | + // 백엔드의 공개 이미지 GET이라 브라우저가 직접 부른다. `next/image`를 쓰려면 백엔드 | |
| 96 | + // 호스트를 next.config에 등록해야 해 여기서는 <img>로 둔다. | |
| 97 | + // eslint-disable-next-line @next/next/no-img-element | |
| 98 | + <img | |
| 99 | + className={styles.thumbnail} | |
| 100 | + src={row.imageUrl} | |
| 101 | + alt={`${row.name} 썸네일`} | |
| 102 | + /> | |
| 103 | + ) : ( | |
| 104 | + EMPTY_FIELD_PLACEHOLDER | |
| 105 | + ), | |
| 106 | + }, | |
| 107 | + { key: 'name', header: '아이템명', width: 236 }, | |
| 108 | + { | |
| 109 | + key: 'category', | |
| 110 | + header: '카테고리', | |
| 111 | + width: 160, | |
| 112 | + render: formatDecorationItemCategory, | |
| 113 | + }, | |
| 114 | + { | |
| 115 | + key: 'points', | |
| 116 | + header: '포인트', | |
| 117 | + width: 160, | |
| 118 | + render: (row) => formatDecorationItemPoints(row.points), | |
| 119 | + }, | |
| 120 | + { | |
| 121 | + key: 'isActive', | |
| 122 | + header: '사용여부', | |
| 123 | + width: 120, | |
| 124 | + render: (row) => ( | |
| 125 | + <FoxStatusIndicator | |
| 126 | + type={row.isActive ? 'success' : 'danger'} | |
| 127 | + label={formatDecorationItemActiveLabel(row.isActive)} | |
| 128 | + /> | |
| 129 | + ), | |
| 130 | + }, | |
| 131 | + { key: 'sortOrder', header: '정렬순서', width: 80 }, | |
| 132 | + { | |
| 133 | + key: 'updatedAt', | |
| 134 | + header: '수정일시', | |
| 135 | + width: 160, | |
| 136 | + render: (row) => formatDecorationItemUpdatedAt(row.updatedAt), | |
| 137 | + }, | |
| 138 | + { | |
| 139 | + key: 'actions', | |
| 140 | + header: '관리', | |
| 141 | + width: 160, | |
| 142 | + render: (row) => ( | |
| 143 | + <DecorationItemRowActions item={row} typeTotalCount={typeTotalCount} /> | |
| 144 | + ), | |
| 145 | + }, | |
| 146 | + ]; | |
| 147 | + | |
| 148 | + return ( | |
| 149 | + <> | |
| 150 | + <FoxListContainer<DecorationItem> | |
| 151 | + title="꾸미기 아이템 목록" | |
| 152 | + breadcrumb={[ | |
| 153 | + { label: '홈', href: '/' }, | |
| 154 | + { label: '꾸미기아이템관리' }, | |
| 155 | + { label: '아이템 관리' }, | |
| 156 | + ]} | |
| 157 | + caption="꾸미기 아이템 목록" | |
| 158 | + tabs={ | |
| 159 | + <FoxTab | |
| 160 | + type="line" | |
| 161 | + size="lg" | |
| 162 | + label="아이템 유형" | |
| 163 | + value={query.itemType} | |
| 164 | + onValueChange={(value) => | |
| 165 | + go({ itemType: value as DecorationItemType, keyword: '' }) | |
| 166 | + } | |
| 167 | + > | |
| 168 | + {DECORATION_ITEM_TYPE_OPTIONS.map((option) => ( | |
| 169 | + <FoxTabItem key={option.value} value={option.value}> | |
| 170 | + {option.label} | |
| 171 | + </FoxTabItem> | |
| 172 | + ))} | |
| 173 | + </FoxTab> | |
| 174 | + } | |
| 175 | + columns={columns} | |
| 176 | + rows={items} | |
| 177 | + rowKey={(row) => String(row.itemSn)} | |
| 178 | + totalCount={totalCount} | |
| 179 | + emptyMessage="등록된 아이템이 없습니다. 다른 검색어로 다시 시도해 주세요." | |
| 180 | + sorts={ | |
| 181 | + <FoxSelectText | |
| 182 | + size="sm" | |
| 183 | + ariaLabel="페이지 크기" | |
| 184 | + options={DECORATION_ITEM_PAGE_SIZE_OPTIONS.map((size) => ({ | |
| 185 | + value: String(size), | |
| 186 | + label: `${size}개씩 보기`, | |
| 187 | + }))} | |
| 188 | + value={String(query.pageSize)} | |
| 189 | + onValueChange={(value) => | |
| 190 | + go({ pageSize: Number(value) as DecorationItemPageSize }) | |
| 191 | + } | |
| 192 | + /> | |
| 193 | + } | |
| 194 | + searchFields={DECORATION_ITEM_SEARCH_FIELD_OPTIONS.map((option) => ({ | |
| 195 | + value: option.value, | |
| 196 | + label: option.label, | |
| 197 | + }))} | |
| 198 | + searchField={query.searchField} | |
| 199 | + keyword={query.keyword} | |
| 200 | + toolbarActions={ | |
| 201 | + <FoxButton | |
| 202 | + type="primary" | |
| 203 | + size="md" | |
| 204 | + leadingIcon={<FoxPlusIcon />} | |
| 205 | + label="신규 등록" | |
| 206 | + onAction={() => setIsCreateOpen(true)} | |
| 207 | + /> | |
| 208 | + } | |
| 209 | + page={currentPage} | |
| 210 | + totalPages={totalPages} | |
| 211 | + buildHref={(patch) => | |
| 212 | + buildDecorationItemHref(query, { page: Number(patch.page ?? 1) }) | |
| 213 | + } | |
| 214 | + onQueryChange={(patch) => { | |
| 215 | + if (patch.keyword !== undefined || patch.searchField !== undefined) { | |
| 216 | + go({ | |
| 217 | + keyword: String(patch.keyword ?? ''), | |
| 218 | + searchField: (patch.searchField ?? | |
| 219 | + query.searchField) as DecorationItemSearchField, | |
| 220 | + }); | |
| 221 | + } | |
| 222 | + }} | |
| 223 | + /> | |
| 224 | + | |
| 225 | + {isCreateOpen && ( | |
| 226 | + <DecorationItemCreateModal | |
| 227 | + defaultItemType={query.itemType} | |
| 228 | + typeTotalCount={typeTotalCount} | |
| 229 | + onClose={() => setIsCreateOpen(false)} | |
| 230 | + /> | |
| 231 | + )} | |
| 232 | + </> | |
| 233 | + ); | |
| 234 | +} |
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-row-actions.tsx
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-row-actions.tsx
... | ... | @@ -1,7 +1,8 @@ |
| 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';
|
... | ... | @@ -11,21 +12,16 @@ |
| 11 | 12 |
|
| 12 | 13 |
interface DecorationItemRowActionsProps {
|
| 13 | 14 |
item: DecorationItem; |
| 14 |
- /** 수정 팝업의 "(총 등록 N개)" 힌트로 그대로 흘려보낸다(현재 유형 전체 등록 건수). */ |
|
| 15 |
+ /** 수정 팝업의 "/ 총 N개" 힌트로 그대로 흘려보낸다(현재 유형 전체 등록 건수). */ |
|
| 15 | 16 |
typeTotalCount: number; |
| 16 | 17 |
} |
| 17 | 18 |
|
| 18 | 19 |
/** |
| 19 |
- * 목록 행의 "관리" 셀(시안 ADM_ITM_101) — 수정 팝업 열림 상태만 소유하는 최말단 상호작용 |
|
| 20 |
- * 경계다(테이블 전체를 클라이언트로 내리지 않기 위해 이 셀만 분리했다, admins와 동일 패턴). |
|
| 20 |
+ * 목록 행의 "관리" 셀(시안 ADM_ITM_101) — 시안의 btn-ico 두 개(28×28 · 테두리 있는 default |
|
| 21 |
+ * 계열)를 4px 간격으로 세운다. |
|
| 21 | 22 |
* |
| 22 |
- * **삭제는 시안대로 확인 얼럿을 거친 뒤 실행한다** — 전역 얼럿(FeedbackProvider)의 actions |
|
| 23 |
- * 슬롯에 직접 [취소]/[삭제] 버튼을 넣어 구성한다. 삭제는 되돌릴 수 없는 동작이라 얼럿 문구에 |
|
| 24 |
- * 명시한다. |
|
| 25 |
- * |
|
| 26 |
- * 시안의 연필·휴지통 아이콘 버튼이다. 버튼은 `components/ui/button`이 아니라 `FoxIconButton`을 |
|
| 27 |
- * 쓴다 — 아이콘은 스스로 크기를 갖지 않고 감싼 슬롯이 정하는데, 그 슬롯 크기를 실제로 정의하는 |
|
| 28 |
- * 건 @fox 쪽뿐이다(components/ui는 Tailwind 제거 이후 무스타일이다). |
|
| 23 |
+ * **삭제는 확인 얼럿을 거친다** — 전역 얼럿(FeedbackProvider)의 actions 슬롯에 [취소]/[삭제]를 |
|
| 24 |
+ * 넣어 구성한다. 되돌릴 수 없는 동작이라 얼럿 문구에 명시한다. |
|
| 29 | 25 |
*/ |
| 30 | 26 |
export function DecorationItemRowActions({
|
| 31 | 27 |
item, |
... | ... | @@ -57,34 +53,32 @@ |
| 57 | 53 |
message: `${item.name}(아이템ID ${item.itemSn})을(를) 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
|
| 58 | 54 |
actions: ( |
| 59 | 55 |
<> |
| 60 |
- <Button type="button" variant="ghost" onClick={hideAlert}>
|
|
| 61 |
- 취소 |
|
| 62 |
- </Button> |
|
| 63 |
- <Button type="button" variant="primary" onClick={runDelete}>
|
|
| 64 |
- 삭제 |
|
| 65 |
- </Button> |
|
| 56 |
+ <FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
|
|
| 57 |
+ <FoxButton type="error" size="md" label="삭제" onAction={runDelete} />
|
|
| 66 | 58 |
</> |
| 67 | 59 |
), |
| 68 | 60 |
}); |
| 69 | 61 |
} |
| 70 | 62 |
|
| 71 | 63 |
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 |
- /> |
|
| 64 |
+ <> |
|
| 65 |
+ <FoxButtonGroup size="sm"> |
|
| 66 |
+ <FoxIconButton |
|
| 67 |
+ type="default" |
|
| 68 |
+ size="sm" |
|
| 69 |
+ icon={<FoxPencilSimpleIcon />}
|
|
| 70 |
+ label="수정" |
|
| 71 |
+ onAction={() => setIsEditOpen(true)}
|
|
| 72 |
+ /> |
|
| 73 |
+ <FoxIconButton |
|
| 74 |
+ type="default" |
|
| 75 |
+ size="sm" |
|
| 76 |
+ icon={<FoxTrashIcon />}
|
|
| 77 |
+ label={isDeleting ? '삭제 중' : '삭제'}
|
|
| 78 |
+ disabled={isDeleting}
|
|
| 79 |
+ onAction={confirmDelete}
|
|
| 80 |
+ /> |
|
| 81 |
+ </FoxButtonGroup> |
|
| 88 | 82 |
|
| 89 | 83 |
{isEditOpen && (
|
| 90 | 84 |
<DecorationItemEditModal |
... | ... | @@ -93,6 +87,6 @@ |
| 93 | 87 |
onClose={() => setIsEditOpen(false)}
|
| 94 | 88 |
/> |
| 95 | 89 |
)} |
| 96 |
- </div> |
|
| 90 |
+ </> |
|
| 97 | 91 |
); |
| 98 | 92 |
} |
--- 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,35 +1,27 @@ |
| 1 | 1 |
import type { Metadata } from 'next';
|
| 2 |
-import Link from 'next/link'; |
|
| 3 |
-import { Alert } from '@/components/ui/alert';
|
|
| 4 |
-import { Pagination } from '@/components/ui/pagination';
|
|
| 5 | 2 |
import { verifySession } from '@/lib/auth/dal';
|
| 6 | 3 |
import { 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';
|
|
| 4 |
+import { parseDecorationItemQuery } from '@/lib/domain/decoration-item-query';
|
|
| 5 |
+import { DecorationItemList } from './_components/decoration-item-list';
|
|
| 15 | 6 |
|
| 16 | 7 |
export const metadata: Metadata = {
|
| 17 | 8 |
title: '꾸미기 아이템', |
| 18 | 9 |
}; |
| 10 |
+ |
|
| 11 |
+// cookies()로 이미 동적이지만, 정적 프리렌더로 데이터가 빌드 산출물에 박히는 경로를 |
|
| 12 |
+// 원천 차단하기 위해 명시적으로 강제한다(학생 회원 목록과 동일). |
|
| 13 |
+export const dynamic = 'force-dynamic'; |
|
| 19 | 14 |
|
| 20 | 15 |
interface PageProps {
|
| 21 | 16 |
searchParams: Promise<Record<string, string | string[] | undefined>>; |
| 22 | 17 |
} |
| 23 | 18 |
|
| 24 | 19 |
/** |
| 25 |
- * 꾸미기 아이템 목록(시안 ADM_ITM_101). |
|
| 20 |
+ * 꾸미기 아이템 목록(시안 ADM_ITM_101 — 5227:3549). |
|
| 26 | 21 |
* |
| 27 | 22 |
* **셋트아이템은 별도 화면이 없다** — 유형 탭(개별/셋트)만 바꿔 같은 목록·같은 폼을 재사용 |
| 28 |
- * 한다(시안 두 곳에 명시된 사양). `query.itemType`이 곧 그 탭 상태이고, 다른 검색·페이지 |
|
| 29 |
- * 상태와 마찬가지로 URL(searchParams)이 단일 진실원천이다(`useState`로 들지 않는다). |
|
| 30 |
- * |
|
| 31 |
- * `verifySession()`을 호출만 하고 반환값을 쓰지 않는 이유는 인증 확인 자체가 목적이기 |
|
| 32 |
- * 때문이다 — admins의 "본인 계정 삭제 방지" 같은 사용자 조건부 로직이 이 화면에는 없다. |
|
| 23 |
+ * 한다(시안 두 곳에 명시된 사양). `query.itemType`이 곧 그 탭 상태이고, 검색·페이지와 마찬가지로 |
|
| 24 |
+ * URL(searchParams)이 단일 진실원천이다. |
|
| 33 | 25 |
*/ |
| 34 | 26 |
export default async function Page({ searchParams }: PageProps) {
|
| 35 | 27 |
await verifySession(); |
... | ... | @@ -40,60 +32,22 @@ |
| 40 | 32 |
|
| 41 | 33 |
// 전체 건수가 확정되지 않았다면(백엔드가 count를 주지 않아 하한값만 아는 상태) 다음 페이지를 |
| 42 | 34 |
// 한 칸 열어 둔다 — 열어 두지 않으면 가득 찬 페이지 뒤의 데이터에 접근할 방법이 없어진다 |
| 43 |
- // (학생 목록과 동일한 보정. Repository 주석 참조). |
|
| 35 |
+ // (Repository 주석 참조). |
|
| 44 | 36 |
const totalPages = isTotalCountExact |
| 45 | 37 |
? Math.max(1, Math.ceil(totalCount / query.pageSize)) |
| 46 | 38 |
: query.page + 1; |
| 47 |
- // 요청 페이지가 범위를 벗어나면(예: 삭제로 마지막 페이지가 사라짐, 또는 유형 탭 전환으로 |
|
| 48 |
- // 전체 건수가 줄어듦) 마지막 페이지로 맞춘다 — 표의 순번 계산도 이 값을 기준으로 해야 헤더의 |
|
| 49 |
- // "현재페이지"와 어긋나지 않는다. |
|
| 39 |
+ // 요청 페이지가 범위를 벗어나면(삭제로 마지막 페이지가 사라짐, 유형 탭 전환으로 건수가 줄어듦) |
|
| 40 |
+ // 마지막 페이지로 맞춘다 — 표의 순번도 이 값을 기준으로 세야 페이지 표시와 어긋나지 않는다. |
|
| 50 | 41 |
const currentPage = Math.min(query.page, totalPages); |
| 51 | 42 |
|
| 52 | 43 |
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> |
|
| 44 |
+ <DecorationItemList |
|
| 45 |
+ items={items}
|
|
| 46 |
+ query={query}
|
|
| 47 |
+ currentPage={currentPage}
|
|
| 48 |
+ totalPages={totalPages}
|
|
| 49 |
+ totalCount={totalCount}
|
|
| 50 |
+ typeTotalCount={typeTotalCount}
|
|
| 51 |
+ /> |
|
| 98 | 52 |
); |
| 99 | 53 |
} |
--- lib/domain/decoration-item.ts
+++ lib/domain/decoration-item.ts
... | ... | @@ -82,9 +82,9 @@ |
| 82 | 82 |
/** 값이 없는 항목의 화면 표기. */ |
| 83 | 83 |
export const EMPTY_FIELD_PLACEHOLDER = '-'; |
| 84 | 84 |
|
| 85 |
-/** 사용여부 → 화면 표기(시안: O/X). */ |
|
| 85 |
+/** 사용여부 → 화면 표기(시안 ADM_ITM_101: 점 + "사용"/"미사용"). */ |
|
| 86 | 86 |
export function formatDecorationItemActiveLabel(isActive: boolean): string {
|
| 87 |
- return isActive ? 'O' : 'X'; |
|
| 87 |
+ return isActive ? '사용' : '미사용'; |
|
| 88 | 88 |
} |
| 89 | 89 |
|
| 90 | 90 |
/** 포인트 → 화면 표기(시안: "100 P" 형태). */ |
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?