feat: 시스템관리 > 가맹몰 상세 관리 화면을 만든다
기획 BO-MAL-001~004(지역상생몰 > 업체관리)를 「가맹몰 상세 관리」로 옮겼다. 백엔드 (15d409a)의 상세는 기존 가맹점(frcsNo)에 1:1로 붙는 구조라 업체를 새로 만들지 않는다 — 목록에 가맹점 전체를 보이고 행에서 [상세 등록]으로 들어가며, 업체명·연락처·주소는 가맹점 원본을 읽기 전용으로 보여 준다(사용자 확정). - 목록: 업체명·카테고리·노출상태·등록일 검색, 체크박스 일괄 비활성·삭제, 행 관리 - 등록·수정(/[frcsNo]): 기본 정보 + 이미지(대표 1 + 추가 11) + 메뉴 표 + 등록 정보 - 메뉴 등록·수정(/[frcsNo]/products): 이미지·메뉴명·가격·설명·배지·노출·순서 - @fox FoxSortableList 신설 — 네이티브 DnD + 방향키, 상태 없이 onReorder만 (사용자 확정) - 배지는 코드를 쉼표로 이어 frcsBadge에 싣는다(BEST,MAIN,NEW,SALE — 사용자 확정) - 파일 저장소 모듈은 바우처용이 없어 잠정 MODULE_VOUCHER (사용자 확정) 백엔드가 아직 주지 않는 값(노출 상태·카테고리·수정 프리필·기간 검색·메뉴 건수·바우처 사용)은 화면을 시안대로 두고 그대로 보낸다(사용자 확정). 목록의 노출 토글만은 예외다 — PUT이 모든 컬럼을 덮어써 단건 조회가 비어 있는 동안 값 없이 보내면 상세가 지워지므로, 조회 결과에 대표이미지가 없으면 거부하고 수정 화면으로 안내한다. 추가 이미지 묶음은 fileSn이 1..n으로 채번되어 순서·삭제·추가 어느 것이든 통째로 다시 올린다. 저장된 장은 백엔드에서 다시 읽어 합친다. 묶음의 장 수를 묻는 API가 없어 HEAD로 더듬는다. Co-Authored-By: Claude Opus 5
@e545cdcfc2d432620b1663e5ab0653b1aba48dc3
+++ @fox/core/components/fox-sortable-list/fox-sortable-list.tsx
... | ... | @@ -0,0 +1,174 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { | |
| 4 | + useId, | |
| 5 | + useState, | |
| 6 | + type DragEvent, | |
| 7 | + type KeyboardEvent, | |
| 8 | + type ReactNode, | |
| 9 | +} from "react"; | |
| 10 | +import { cx } from "../../utils"; | |
| 11 | + | |
| 12 | +export type FoxSortableListDirection = "horizontal" | "vertical"; | |
| 13 | + | |
| 14 | +export interface FoxSortableListProps<T> { | |
| 15 | + items: readonly T[]; | |
| 16 | + /** 항목의 안정적인 키. 인덱스를 쓰면 옮긴 뒤 상태가 어긋난다. */ | |
| 17 | + getKey: (item: T) => string; | |
| 18 | + /** 항목 본문. 핸들은 목록이 그리므로 여기서 다시 그리지 않는다. */ | |
| 19 | + renderItem: (item: T, index: number) => ReactNode; | |
| 20 | + /** 순서가 바뀔 때마다 새 배열을 준다. 목록은 상태를 갖지 않는다. */ | |
| 21 | + onReorder: (next: T[]) => void; | |
| 22 | + direction?: FoxSortableListDirection; | |
| 23 | + /** 목록 전체를 읽어 줄 이름. 핸들의 안내문에도 쓰인다. */ | |
| 24 | + ariaLabel: string; | |
| 25 | + disabled?: boolean; | |
| 26 | + className?: string; | |
| 27 | +} | |
| 28 | + | |
| 29 | +/** | |
| 30 | + * @fox 정렬 목록 — 항목의 **순서만** 바꾼다. 내용은 `renderItem`이 그린다. | |
| 31 | + * | |
| 32 | + * 마우스는 핸들을 끌어 놓고(네이티브 HTML5 드래그 — 의존성 없음), 키보드는 핸들에 초점을 | |
| 33 | + * 두고 방향키로 옮긴다. 둘 다 `onReorder`로 새 배열을 돌려주며 목록은 상태를 갖지 않는다. | |
| 34 | + * | |
| 35 | + * 놓는 위치는 "끌고 있는 항목을 지금 가리키는 항목 자리로" 한 가지다 — 앞뒤 절반을 나누면 | |
| 36 | + * 타일 크기가 제각각인 곳에서 예측이 안 된다. | |
| 37 | + * | |
| 38 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 39 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 40 | + */ | |
| 41 | +export function FoxSortableList<T>({ | |
| 42 | + items, | |
| 43 | + getKey, | |
| 44 | + renderItem, | |
| 45 | + onReorder, | |
| 46 | + direction = "horizontal", | |
| 47 | + ariaLabel, | |
| 48 | + disabled = false, | |
| 49 | + className, | |
| 50 | +}: FoxSortableListProps<T>) { | |
| 51 | + const [draggingKey, setDraggingKey] = useState<string | null>(null); | |
| 52 | + const [overKey, setOverKey] = useState<string | null>(null); | |
| 53 | + const hintId = useId(); | |
| 54 | + | |
| 55 | + function move(from: number, to: number) { | |
| 56 | + if (from === to || from < 0 || to < 0 || from >= items.length || to >= items.length) { | |
| 57 | + return; | |
| 58 | + } | |
| 59 | + const next = [...items]; | |
| 60 | + const [picked] = next.splice(from, 1); | |
| 61 | + next.splice(to, 0, picked); | |
| 62 | + onReorder(next); | |
| 63 | + } | |
| 64 | + | |
| 65 | + function indexOfKey(key: string) { | |
| 66 | + return items.findIndex((item) => getKey(item) === key); | |
| 67 | + } | |
| 68 | + | |
| 69 | + function handleDragStart(event: DragEvent<HTMLElement>, key: string) { | |
| 70 | + // Firefox는 데이터가 없으면 드래그를 시작하지 않는다. | |
| 71 | + event.dataTransfer.setData("text/plain", key); | |
| 72 | + event.dataTransfer.effectAllowed = "move"; | |
| 73 | + setDraggingKey(key); | |
| 74 | + } | |
| 75 | + | |
| 76 | + function handleDragOver(event: DragEvent<HTMLElement>, key: string) { | |
| 77 | + if (draggingKey === null) { | |
| 78 | + return; | |
| 79 | + } | |
| 80 | + event.preventDefault(); | |
| 81 | + event.dataTransfer.dropEffect = "move"; | |
| 82 | + if (overKey !== key) { | |
| 83 | + setOverKey(key); | |
| 84 | + } | |
| 85 | + } | |
| 86 | + | |
| 87 | + function handleDrop(event: DragEvent<HTMLElement>, key: string) { | |
| 88 | + event.preventDefault(); | |
| 89 | + if (draggingKey !== null) { | |
| 90 | + move(indexOfKey(draggingKey), indexOfKey(key)); | |
| 91 | + } | |
| 92 | + setDraggingKey(null); | |
| 93 | + setOverKey(null); | |
| 94 | + } | |
| 95 | + | |
| 96 | + function handleDragEnd() { | |
| 97 | + setDraggingKey(null); | |
| 98 | + setOverKey(null); | |
| 99 | + } | |
| 100 | + | |
| 101 | + function handleHandleKeyDown(event: KeyboardEvent<HTMLButtonElement>, index: number) { | |
| 102 | + const backward = direction === "horizontal" ? "ArrowLeft" : "ArrowUp"; | |
| 103 | + const forward = direction === "horizontal" ? "ArrowRight" : "ArrowDown"; | |
| 104 | + | |
| 105 | + if (event.key === backward) { | |
| 106 | + event.preventDefault(); | |
| 107 | + move(index, index - 1); | |
| 108 | + } else if (event.key === forward) { | |
| 109 | + event.preventDefault(); | |
| 110 | + move(index, index + 1); | |
| 111 | + } else if (event.key === "Home") { | |
| 112 | + event.preventDefault(); | |
| 113 | + move(index, 0); | |
| 114 | + } else if (event.key === "End") { | |
| 115 | + event.preventDefault(); | |
| 116 | + move(index, items.length - 1); | |
| 117 | + } | |
| 118 | + } | |
| 119 | + | |
| 120 | + const keyHint = | |
| 121 | + direction === "horizontal" | |
| 122 | + ? "왼쪽·오른쪽 방향키로 순서를 옮깁니다." | |
| 123 | + : "위·아래 방향키로 순서를 옮깁니다."; | |
| 124 | + | |
| 125 | + return ( | |
| 126 | + <ul | |
| 127 | + className={cx( | |
| 128 | + "fox-sortable-list", | |
| 129 | + `fox-sortable-list--${direction}`, | |
| 130 | + disabled && "fox-sortable-list--disabled", | |
| 131 | + className, | |
| 132 | + )} | |
| 133 | + aria-label={ariaLabel} | |
| 134 | + > | |
| 135 | + <span id={hintId} hidden> | |
| 136 | + {keyHint} | |
| 137 | + </span> | |
| 138 | + {items.map((item, index) => { | |
| 139 | + const key = getKey(item); | |
| 140 | + const isDragging = draggingKey === key; | |
| 141 | + const isOver = overKey === key && draggingKey !== null && !isDragging; | |
| 142 | + | |
| 143 | + return ( | |
| 144 | + <li | |
| 145 | + key={key} | |
| 146 | + className={cx( | |
| 147 | + "fox-sortable-list__item", | |
| 148 | + isDragging && "fox-sortable-list__item--dragging", | |
| 149 | + isOver && "fox-sortable-list__item--over", | |
| 150 | + )} | |
| 151 | + onDragOver={disabled ? undefined : (event) => handleDragOver(event, key)} | |
| 152 | + onDrop={disabled ? undefined : (event) => handleDrop(event, key)} | |
| 153 | + > | |
| 154 | + {!disabled && ( | |
| 155 | + <button | |
| 156 | + type="button" | |
| 157 | + className="fox-sortable-list__handle" | |
| 158 | + aria-label={`${index + 1}번째 항목 순서 변경`} | |
| 159 | + aria-describedby={hintId} | |
| 160 | + draggable | |
| 161 | + onDragStart={(event) => handleDragStart(event, key)} | |
| 162 | + onDragEnd={handleDragEnd} | |
| 163 | + onKeyDown={(event) => handleHandleKeyDown(event, index)} | |
| 164 | + > | |
| 165 | + <i className="fox-ico fox-ico-DotsSixVertical" aria-hidden="true" /> | |
| 166 | + </button> | |
| 167 | + )} | |
| 168 | + <div className="fox-sortable-list__body">{renderItem(item, index)}</div> | |
| 169 | + </li> | |
| 170 | + ); | |
| 171 | + })} | |
| 172 | + </ul> | |
| 173 | + ); | |
| 174 | +} |
+++ @fox/core/components/fox-sortable-list/index.ts
... | ... | @@ -0,0 +1,5 @@ |
| 1 | +export { | |
| 2 | + FoxSortableList, | |
| 3 | + type FoxSortableListDirection, | |
| 4 | + type FoxSortableListProps, | |
| 5 | +} from "./fox-sortable-list"; |
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
... | ... | @@ -70,3 +70,4 @@ |
| 70 | 70 |
export * from "./fox-list-toolbar"; |
| 71 | 71 |
export * from "./fox-list-container"; |
| 72 | 72 |
export * from "./fox-description-list"; |
| 73 |
+export * from "./fox-sortable-list"; |
--- @fox/dev-test/component-groups.ts
+++ @fox/dev-test/component-groups.ts
... | ... | @@ -75,7 +75,7 @@ |
| 75 | 75 |
{
|
| 76 | 76 |
label: "Layout", |
| 77 | 77 |
subgroups: [ |
| 78 |
- { label: "Table & List", ids: ["fox-table", "fox-bullet-list"] },
|
|
| 78 |
+ { label: "Table & List", ids: ["fox-table", "fox-bullet-list", "fox-sortable-list"] },
|
|
| 79 | 79 |
{ label: "Tab", ids: ["fox-tab"] },
|
| 80 | 80 |
{ label: "Badge", ids: ["fox-badge", "fox-badge-dot", "fox-badge-group"] },
|
| 81 | 81 |
{ label: "Disclosure", ids: ["fox-accordion", "fox-disclosure"] },
|
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
... | ... | @@ -64,6 +64,7 @@ |
| 64 | 64 |
import { FoxAccordion } from "../core/components/fox-accordion";
|
| 65 | 65 |
import { FoxCard } from "../core/components/fox-card";
|
| 66 | 66 |
import { FoxDisclosure } from "../core/components/fox-disclosure";
|
| 67 |
+import { FoxSortableList } from "../core/components/fox-sortable-list";
|
|
| 67 | 68 |
import styles from "./dev-test.module.scss"; |
| 68 | 69 |
|
| 69 | 70 |
export interface ComponentExample {
|
... | ... | @@ -139,6 +140,35 @@ |
| 139 | 140 |
} |
| 140 | 141 |
|
| 141 | 142 |
// 카운터가 실제로 올라가는 걸 보려면 예제가 값을 들고 있어야 한다. |
| 143 |
+function SortableListLiveDemo({ direction }: { direction: "horizontal" | "vertical" }) {
|
|
| 144 |
+ const [items, setItems] = useState(["첫째", "둘째", "셋째", "넷째"]); |
|
| 145 |
+ return ( |
|
| 146 |
+ <FoxSortableList |
|
| 147 |
+ items={items}
|
|
| 148 |
+ getKey={(item) => item}
|
|
| 149 |
+ direction={direction}
|
|
| 150 |
+ ariaLabel="정렬 예제" |
|
| 151 |
+ onReorder={setItems}
|
|
| 152 |
+ renderItem={(item) => (
|
|
| 153 |
+ <div |
|
| 154 |
+ style={{
|
|
| 155 |
+ display: "flex", |
|
| 156 |
+ alignItems: "center", |
|
| 157 |
+ justifyContent: "center", |
|
| 158 |
+ inlineSize: "8rem", |
|
| 159 |
+ blockSize: "8rem", |
|
| 160 |
+ border: "var(--fox-border-1) solid var(--fox-color-border-neutral-subtle)", |
|
| 161 |
+ borderRadius: "var(--fox-radius-3)", |
|
| 162 |
+ background: "var(--fox-color-surface-neutral-gray)", |
|
| 163 |
+ }} |
|
| 164 |
+ > |
|
| 165 |
+ {item}
|
|
| 166 |
+ </div> |
|
| 167 |
+ )} |
|
| 168 |
+ /> |
|
| 169 |
+ ); |
|
| 170 |
+} |
|
| 171 |
+ |
|
| 142 | 172 |
function TextAreaLiveDemo() {
|
| 143 | 173 |
const [text, setText] = useState("입력하면 아래 숫자가 따라 올라갑니다.");
|
| 144 | 174 |
return ( |
... | ... | @@ -4283,4 +4313,20 @@ |
| 4283 | 4313 |
}, |
| 4284 | 4314 |
], |
| 4285 | 4315 |
}, |
| 4316 |
+ {
|
|
| 4317 |
+ id: "fox-sortable-list", |
|
| 4318 |
+ name: "FoxSortableList", |
|
| 4319 |
+ description: |
|
| 4320 |
+ "항목 순서를 끌어서 바꾸는 목록입니다. 시안에 정렬 목록 컴포넌트가 없어 기획 문구(추가 이미지는 드래그로 순서를 바꾼다)만으로 만들었습니다 — 핸들 하나와 끌고 있는 항목·놓을 자리 표시가 전부이고 본문은 호출부가 그립니다. 핸들에 초점을 두고 방향키(가로는 ←→, 세로는 ↑↓)·Home·End로도 옮길 수 있어 마우스 없이도 됩니다. 목록은 상태를 갖지 않고 onReorder로 새 배열만 돌려줍니다.", |
|
| 4321 |
+ variants: [ |
|
| 4322 |
+ {
|
|
| 4323 |
+ label: "가로 — 핸들을 끌거나 초점을 두고 ←→", |
|
| 4324 |
+ node: <SortableListLiveDemo direction="horizontal" />, |
|
| 4325 |
+ }, |
|
| 4326 |
+ {
|
|
| 4327 |
+ label: "세로 — ↑↓", |
|
| 4328 |
+ node: <SortableListLiveDemo direction="vertical" />, |
|
| 4329 |
+ }, |
|
| 4330 |
+ ], |
|
| 4331 |
+ }, |
|
| 4286 | 4332 |
]; |
+++ @fox/styles/_fox-sortable-list.scss
... | ... | @@ -0,0 +1,90 @@ |
| 1 | +// FoxSortableList — 항목 순서를 끌어서(또는 방향키로) 바꾸는 목록. | |
| 2 | +// | |
| 3 | +// 시안에 정렬 목록 컴포넌트가 없어 기획 문구("추가 이미지는 드래그로 순서를 바꾼다", | |
| 4 | +// BO-MAL-002 ⑤)만으로 만들었다(사용자 확정). 모양은 최소한으로 둔다 — 핸들 하나와 | |
| 5 | +// 끌고 있는 항목·놓을 자리 표시뿐이고, 항목 본문은 호출부가 그린다. | |
| 6 | +// | |
| 7 | +// 마크업 계약 (React 밖 소비자용): | |
| 8 | +// <ul class="fox-sortable-list fox-sortable-list--horizontal" aria-label="…"> | |
| 9 | +// <li class="fox-sortable-list__item [--dragging|--over]"> | |
| 10 | +// <button class="fox-sortable-list__handle" type="button" draggable aria-label="…"> | |
| 11 | +// <i class="fox-ico fox-ico-DotsSixVertical"></i> | |
| 12 | +// </button> | |
| 13 | +// <div class="fox-sortable-list__body">…항목 본문…</div> | |
| 14 | +// </li> | |
| 15 | +// </ul> | |
| 16 | +// | |
| 17 | +// 토큰이 아닌 값: | |
| 18 | +// - 끌고 있는 항목의 opacity: 상태 표현이라 색 토큰이 없다. 관례값 0.4. | |
| 19 | +// - 버튼 초기화(appearance/cursor/색 상속): 구조 규칙. | |
| 20 | + | |
| 21 | +@use "abstracts" as fox; | |
| 22 | + | |
| 23 | +.fox-sortable-list { | |
| 24 | + display: flex; | |
| 25 | + flex-wrap: wrap; | |
| 26 | + align-items: flex-start; | |
| 27 | + gap: fox.gap(4); | |
| 28 | + padding: 0; | |
| 29 | + margin: 0; | |
| 30 | + list-style: none; | |
| 31 | + | |
| 32 | + &--vertical { | |
| 33 | + flex-direction: column; | |
| 34 | + flex-wrap: nowrap; | |
| 35 | + align-items: stretch; | |
| 36 | + } | |
| 37 | + | |
| 38 | + &__item { | |
| 39 | + position: relative; | |
| 40 | + display: flex; | |
| 41 | + align-items: flex-start; | |
| 42 | + gap: fox.gap(2); | |
| 43 | + border-radius: fox.radius(3); | |
| 44 | + // 놓을 자리 테두리가 나타날 때 항목이 밀리지 않도록 투명 테두리를 미리 둔다. | |
| 45 | + outline: fox.border(2) solid transparent; | |
| 46 | + outline-offset: fox.number(2); | |
| 47 | + | |
| 48 | + &--dragging { | |
| 49 | + opacity: 0.4; | |
| 50 | + } | |
| 51 | + | |
| 52 | + &--over { | |
| 53 | + outline-color: fox.color(border-theme-primary); | |
| 54 | + } | |
| 55 | + } | |
| 56 | + | |
| 57 | + &__handle { | |
| 58 | + display: inline-flex; | |
| 59 | + flex-shrink: 0; | |
| 60 | + align-items: center; | |
| 61 | + justify-content: center; | |
| 62 | + padding: fox.padding(1); | |
| 63 | + margin: 0; | |
| 64 | + border: 0; | |
| 65 | + border-radius: fox.radius(2); | |
| 66 | + background: transparent; | |
| 67 | + color: fox.color(icon-neutral-subtle); | |
| 68 | + font-size: fox.icon(3); | |
| 69 | + cursor: grab; | |
| 70 | + appearance: none; | |
| 71 | + | |
| 72 | + &:hover { | |
| 73 | + background: fox.color(action-default-hover); | |
| 74 | + color: fox.color(icon-neutral-default); | |
| 75 | + } | |
| 76 | + | |
| 77 | + &:active { | |
| 78 | + cursor: grabbing; | |
| 79 | + } | |
| 80 | + } | |
| 81 | + | |
| 82 | + &__body { | |
| 83 | + min-inline-size: 0; | |
| 84 | + flex: 1 1 auto; | |
| 85 | + } | |
| 86 | + | |
| 87 | + &--disabled &__item { | |
| 88 | + outline: 0; | |
| 89 | + } | |
| 90 | +} |
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
... | ... | @@ -74,4 +74,5 @@ |
| 74 | 74 |
@use "fox-list-toolbar"; |
| 75 | 75 |
@use "fox-list-container"; |
| 76 | 76 |
@use "fox-disclosure"; |
| 77 |
+@use "fox-sortable-list"; |
|
| 77 | 78 |
@use "fox-description-list"; |
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/_components/merchant-detail-form.module.scss
... | ... | @@ -0,0 +1,50 @@ |
| 1 | +@use "@fox/styles/abstracts" as fox; | |
| 2 | + | |
| 3 | +.form { | |
| 4 | + display: flex; | |
| 5 | + inline-size: 100%; | |
| 6 | + flex-direction: column; | |
| 7 | + gap: fox.gap(6); | |
| 8 | +} | |
| 9 | + | |
| 10 | +.section { | |
| 11 | + inline-size: 100%; | |
| 12 | +} | |
| 13 | + | |
| 14 | +// 기획 BO-MAL-002의 한 줄 — 칸 폭은 시안 실측(400 / 280 / 160 / 520 / 680)을 따르되 고정하지 | |
| 15 | +// 않고 기준 폭으로 두어 창이 좁으면 접는다. 토큰 스케일 밖의 값이라 리터럴로 둔다. | |
| 16 | +.grid { | |
| 17 | + display: flex; | |
| 18 | + flex-wrap: wrap; | |
| 19 | + align-items: flex-start; | |
| 20 | + gap: fox.gap(4); | |
| 21 | + inline-size: 100%; | |
| 22 | + | |
| 23 | + & + & { | |
| 24 | + margin-block-start: fox.gap(5); | |
| 25 | + } | |
| 26 | +} | |
| 27 | + | |
| 28 | +.field { | |
| 29 | + display: flex; | |
| 30 | + flex: 1 1 28rem; | |
| 31 | + min-inline-size: 0; | |
| 32 | + flex-direction: column; | |
| 33 | + gap: fox.gap(2); | |
| 34 | +} | |
| 35 | + | |
| 36 | +.fieldWide { | |
| 37 | + display: flex; | |
| 38 | + flex: 2 1 40rem; | |
| 39 | + min-inline-size: 0; | |
| 40 | + flex-direction: column; | |
| 41 | + gap: fox.gap(2); | |
| 42 | +} | |
| 43 | + | |
| 44 | +.fieldNarrow { | |
| 45 | + display: flex; | |
| 46 | + flex: 0 1 18rem; | |
| 47 | + min-inline-size: 0; | |
| 48 | + flex-direction: column; | |
| 49 | + gap: fox.gap(2); | |
| 50 | +} |
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/_components/merchant-detail-form.tsx
... | ... | @@ -0,0 +1,340 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useRouter } from 'next/navigation'; | |
| 4 | +import { useActionState, useState } from 'react'; | |
| 5 | +import { FoxButton } from '@fox/core/components/fox-button'; | |
| 6 | +import { FoxButtonGroup } from '@fox/core/components/fox-button-group'; | |
| 7 | +import { FoxButtonPanel } from '@fox/core/components/fox-button-panel'; | |
| 8 | +import { FoxCard } from '@fox/core/components/fox-card'; | |
| 9 | +import { FoxDescriptionList } from '@fox/core/components/fox-description-list'; | |
| 10 | +import { FoxHelperText } from '@fox/core/components/fox-helper-text'; | |
| 11 | +import { FoxInput } from '@fox/core/components/fox-input'; | |
| 12 | +import { FoxPageHeader } from '@fox/core/components/fox-page-header'; | |
| 13 | +import { FoxSelect } from '@fox/core/components/fox-select'; | |
| 14 | +import { submitFormAction } from '@/app/_hooks/submit-form-action'; | |
| 15 | +import { useBlockingAction } from '@/app/_hooks/use-blocking-action'; | |
| 16 | +import { useFeedback } from '@/app/_hooks/use-feedback'; | |
| 17 | +import type { CommonCode } from '@/lib/domain/common-code'; | |
| 18 | +import { | |
| 19 | + MERCHANT_DETAIL_VISIBILITY_OPTIONS, | |
| 20 | + MERCHANT_VOUCHER_USE_OPTIONS, | |
| 21 | + formatMerchantDetailStatusLabel, | |
| 22 | + formatOptionalValue, | |
| 23 | + type MerchantDetail, | |
| 24 | +} from '@/lib/domain/merchant-detail'; | |
| 25 | +import { | |
| 26 | + CLOSED_DAYS_MAX_LENGTH, | |
| 27 | + DESCRIPTION_MAX_LENGTH, | |
| 28 | + INITIAL_MERCHANT_DETAIL_FORM_STATE, | |
| 29 | + SORT_ORDER_MAX, | |
| 30 | +} from '@/lib/domain/merchant-detail-form'; | |
| 31 | +import { MERCHANT_DETAILS_PATH } from '@/lib/domain/merchant-detail-query'; | |
| 32 | +import type { MerchantProduct } from '@/lib/domain/merchant-product'; | |
| 33 | +import { formatPhoneNumber } from '@/lib/domain/phone-number'; | |
| 34 | +import { | |
| 35 | + deleteMerchantDetailAction, | |
| 36 | + saveMerchantDetailAction, | |
| 37 | + updateMerchantDetailVisibilityAction, | |
| 38 | +} from '../../_actions'; | |
| 39 | +import { MerchantChoiceChips } from '../../_components/merchant-choice-chips'; | |
| 40 | +import { MerchantImageField } from '../../_components/merchant-image-field'; | |
| 41 | +import { MerchantProductTable } from '../../_components/merchant-product-table'; | |
| 42 | +import styles from './merchant-detail-form.module.scss'; | |
| 43 | + | |
| 44 | +interface MerchantDetailFormProps { | |
| 45 | + detail: MerchantDetail; | |
| 46 | + products: MerchantProduct[]; | |
| 47 | + categories: CommonCode[]; | |
| 48 | +} | |
| 49 | + | |
| 50 | +/** | |
| 51 | + * 가맹몰 상세 등록·수정 — 기획 BO-MAL-002(등록)·003(수정)을 한 화면으로 합쳤다. 상세는 | |
| 52 | + * 가맹점당 하나라 `frcsNo`만으로 등록인지 수정인지 갈린다(`detail.hasDetail`). | |
| 53 | + * | |
| 54 | + * 업체명·연락처·우편번호·주소는 가맹점 원본이라 읽기 전용이다(사용자 확정). 기획의 [주소 검색]은 | |
| 55 | + * 가맹점 관리 화면의 것이고 여기서는 값을 바꾸지 않는다. | |
| 56 | + */ | |
| 57 | +export function MerchantDetailForm({ detail, products, categories }: MerchantDetailFormProps) { | |
| 58 | + const router = useRouter(); | |
| 59 | + const { showAlert, hideAlert, showToast } = useFeedback(); | |
| 60 | + const [isBusy, startAction] = useBlockingAction(); | |
| 61 | + const [state, formAction, isPending] = useActionState( | |
| 62 | + saveMerchantDetailAction, | |
| 63 | + INITIAL_MERCHANT_DETAIL_FORM_STATE | |
| 64 | + ); | |
| 65 | + const isEdit = detail.hasDetail; | |
| 66 | + const errors = state.status === 'error' ? (state.errors ?? {}) : {}; | |
| 67 | + | |
| 68 | + const [categoryCode, setCategoryCode] = useState(detail.categoryCode ?? ''); | |
| 69 | + const [visibility, setVisibility] = useState<string[]>([ | |
| 70 | + detail.isVisible === false ? 'N' : 'Y', | |
| 71 | + ]); | |
| 72 | + const [voucherUse, setVoucherUse] = useState<string[]>(['Y']); | |
| 73 | + | |
| 74 | + function toggleVisibility() { | |
| 75 | + const next = detail.isVisible === false; | |
| 76 | + startAction(async () => { | |
| 77 | + const result = await updateMerchantDetailVisibilityAction(detail.id, next); | |
| 78 | + showToast( | |
| 79 | + result.status === 'error' | |
| 80 | + ? { variant: 'danger', message: result.message } | |
| 81 | + : { variant: 'success', message: next ? '노출로 바꿨습니다.' : '비활성으로 바꿨습니다.' } | |
| 82 | + ); | |
| 83 | + }); | |
| 84 | + } | |
| 85 | + | |
| 86 | + function runDelete() { | |
| 87 | + hideAlert(); | |
| 88 | + startAction(async () => { | |
| 89 | + const result = await deleteMerchantDetailAction(detail.id); | |
| 90 | + if (result.status === 'error') { | |
| 91 | + showToast({ variant: 'danger', message: result.message }); | |
| 92 | + return; | |
| 93 | + } | |
| 94 | + showToast({ variant: 'success', message: '삭제했습니다.' }); | |
| 95 | + router.push(MERCHANT_DETAILS_PATH); | |
| 96 | + }); | |
| 97 | + } | |
| 98 | + | |
| 99 | + function confirmDelete() { | |
| 100 | + showAlert({ | |
| 101 | + variant: 'danger', | |
| 102 | + title: '가맹몰 상세를 삭제하시겠습니까?', | |
| 103 | + message: `"${detail.name}"의 상세 정보를 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`, | |
| 104 | + actions: ( | |
| 105 | + <FoxButtonGroup size="md"> | |
| 106 | + <FoxButton type="default" size="md" label="취소" onAction={hideAlert} /> | |
| 107 | + <FoxButton type="primary" size="md" label="삭제" onAction={runDelete} /> | |
| 108 | + </FoxButtonGroup> | |
| 109 | + ), | |
| 110 | + }); | |
| 111 | + } | |
| 112 | + | |
| 113 | + return ( | |
| 114 | + <> | |
| 115 | + <FoxPageHeader | |
| 116 | + title={`가맹몰 상세 ${isEdit ? '수정' : '등록'}`} | |
| 117 | + breadcrumb={[ | |
| 118 | + { label: '홈', href: '/' }, | |
| 119 | + { label: '시스템관리' }, | |
| 120 | + { label: '가맹몰 상세 관리', href: MERCHANT_DETAILS_PATH }, | |
| 121 | + { label: isEdit ? '수정' : '등록' }, | |
| 122 | + ]} | |
| 123 | + /> | |
| 124 | + | |
| 125 | + <form | |
| 126 | + id="merchant-detail-form" | |
| 127 | + onSubmit={(event) => submitFormAction(event, formAction)} | |
| 128 | + className={styles.form} | |
| 129 | + > | |
| 130 | + <input type="hidden" name="frcsNo" value={detail.id} /> | |
| 131 | + <input type="hidden" name="mode" value={isEdit ? 'edit' : 'create'} /> | |
| 132 | + | |
| 133 | + <FoxCard size="lg" title="기본 정보" className={styles.section}> | |
| 134 | + <div className={styles.grid}> | |
| 135 | + <div className={styles.fieldWide}> | |
| 136 | + <FoxInput size="md" label="업체명" requirement="required" value={detail.name} readOnly state="view" /> | |
| 137 | + </div> | |
| 138 | + <div className={styles.field}> | |
| 139 | + <FoxSelect | |
| 140 | + size="md" | |
| 141 | + label="카테고리" | |
| 142 | + requirement="required" | |
| 143 | + placeholder="선택" | |
| 144 | + options={categories.map((code) => ({ value: code.code, label: code.label }))} | |
| 145 | + value={categoryCode} | |
| 146 | + onValueChange={setCategoryCode} | |
| 147 | + error={Boolean(errors.categoryCode)} | |
| 148 | + /> | |
| 149 | + <input type="hidden" name="categoryCode" value={categoryCode} /> | |
| 150 | + {errors.categoryCode && <FoxHelperText type="danger" message={errors.categoryCode} />} | |
| 151 | + </div> | |
| 152 | + <div className={styles.field}> | |
| 153 | + <FoxInput | |
| 154 | + size="md" | |
| 155 | + label="업체 연락처" | |
| 156 | + requirement="required" | |
| 157 | + value={formatOptionalValue(formatPhoneNumber(detail.phoneNumber))} | |
| 158 | + readOnly | |
| 159 | + state="view" | |
| 160 | + /> | |
| 161 | + </div> | |
| 162 | + </div> | |
| 163 | + | |
| 164 | + <div className={styles.grid}> | |
| 165 | + <div className={styles.fieldNarrow}> | |
| 166 | + <FoxInput size="md" label="우편번호" requirement="required" value={formatOptionalValue(detail.postalCode)} readOnly state="view" /> | |
| 167 | + </div> | |
| 168 | + <div className={styles.fieldWide}> | |
| 169 | + <FoxInput size="md" label="주소" value={formatOptionalValue(detail.address)} readOnly state="view" /> | |
| 170 | + </div> | |
| 171 | + <div className={styles.field}> | |
| 172 | + <FoxInput size="md" label="상세주소" value={formatOptionalValue(detail.addressDetail)} readOnly state="view" /> | |
| 173 | + </div> | |
| 174 | + </div> | |
| 175 | + | |
| 176 | + <div className={styles.grid}> | |
| 177 | + <div className={styles.fieldWide}> | |
| 178 | + <FoxInput | |
| 179 | + size="md" | |
| 180 | + name="description" | |
| 181 | + label="한줄 소개" | |
| 182 | + placeholder="앱 목록·상세에 노출되는 한 줄 설명" | |
| 183 | + defaultValue={detail.description ?? ''} | |
| 184 | + maxLength={DESCRIPTION_MAX_LENGTH} | |
| 185 | + invalid={Boolean(errors.description)} | |
| 186 | + message={errors.description} | |
| 187 | + /> | |
| 188 | + </div> | |
| 189 | + <div className={styles.fieldNarrow}> | |
| 190 | + <FoxInput | |
| 191 | + size="md" | |
| 192 | + name="openTime" | |
| 193 | + type="time" | |
| 194 | + label="영업 시작" | |
| 195 | + defaultValue={detail.openTime ?? ''} | |
| 196 | + invalid={Boolean(errors.openTime)} | |
| 197 | + message={errors.openTime} | |
| 198 | + /> | |
| 199 | + </div> | |
| 200 | + <div className={styles.fieldNarrow}> | |
| 201 | + <FoxInput | |
| 202 | + size="md" | |
| 203 | + name="closeTime" | |
| 204 | + type="time" | |
| 205 | + label="영업 종료" | |
| 206 | + defaultValue={detail.closeTime ?? ''} | |
| 207 | + invalid={Boolean(errors.closeTime)} | |
| 208 | + message={errors.closeTime} | |
| 209 | + /> | |
| 210 | + </div> | |
| 211 | + <div className={styles.field}> | |
| 212 | + <FoxInput | |
| 213 | + size="md" | |
| 214 | + name="closedDays" | |
| 215 | + label="휴무일" | |
| 216 | + placeholder="예) 매주 월요일" | |
| 217 | + defaultValue={detail.closedDays ?? ''} | |
| 218 | + maxLength={CLOSED_DAYS_MAX_LENGTH} | |
| 219 | + invalid={Boolean(errors.closedDays)} | |
| 220 | + message={errors.closedDays} | |
| 221 | + /> | |
| 222 | + </div> | |
| 223 | + </div> | |
| 224 | + | |
| 225 | + <div className={styles.grid}> | |
| 226 | + <MerchantChoiceChips | |
| 227 | + label="노출 상태" | |
| 228 | + name="isVisible" | |
| 229 | + required | |
| 230 | + options={MERCHANT_DETAIL_VISIBILITY_OPTIONS} | |
| 231 | + values={visibility} | |
| 232 | + onChange={setVisibility} | |
| 233 | + /> | |
| 234 | + <MerchantChoiceChips | |
| 235 | + label="바우처 사용" | |
| 236 | + name="isVoucherUsable" | |
| 237 | + required | |
| 238 | + options={MERCHANT_VOUCHER_USE_OPTIONS} | |
| 239 | + values={voucherUse} | |
| 240 | + onChange={setVoucherUse} | |
| 241 | + /> | |
| 242 | + <div className={styles.fieldNarrow}> | |
| 243 | + <FoxInput | |
| 244 | + size="md" | |
| 245 | + name="sortOrder" | |
| 246 | + type="number" | |
| 247 | + inputMode="numeric" | |
| 248 | + min={0} | |
| 249 | + max={SORT_ORDER_MAX} | |
| 250 | + label="노출 순서" | |
| 251 | + placeholder="숫자 (낮을수록 상단)" | |
| 252 | + defaultValue={detail.sortOrder === null ? '' : String(detail.sortOrder)} | |
| 253 | + invalid={Boolean(errors.sortOrder)} | |
| 254 | + message={errors.sortOrder} | |
| 255 | + /> | |
| 256 | + </div> | |
| 257 | + </div> | |
| 258 | + </FoxCard> | |
| 259 | + | |
| 260 | + <FoxCard | |
| 261 | + size="lg" | |
| 262 | + title="이미지 등록" | |
| 263 | + description="대표 1장 필수 · 추가 최대 11장 (앱 슬라이더 최대 12장)" | |
| 264 | + className={styles.section} | |
| 265 | + > | |
| 266 | + <MerchantImageField | |
| 267 | + defaultThumbnailUrl={detail.thumbnailUrl} | |
| 268 | + retainedThumbnailId={detail.thumbnailFileId} | |
| 269 | + defaultImageUrls={detail.imageUrls} | |
| 270 | + retainedImageFileId={detail.imageFileId} | |
| 271 | + thumbnailError={errors.thumbnail} | |
| 272 | + imagesError={errors.images} | |
| 273 | + /> | |
| 274 | + </FoxCard> | |
| 275 | + | |
| 276 | + <FoxCard | |
| 277 | + size="lg" | |
| 278 | + title="메뉴 정보" | |
| 279 | + description="앱 상세 화면의 메뉴 목록에 노출된다" | |
| 280 | + className={styles.section} | |
| 281 | + > | |
| 282 | + <MerchantProductTable frcsNo={detail.id} products={products} canAdd={isEdit} /> | |
| 283 | + </FoxCard> | |
| 284 | + | |
| 285 | + {isEdit && ( | |
| 286 | + <FoxCard size="lg" title="등록 정보" className={styles.section}> | |
| 287 | + <FoxDescriptionList | |
| 288 | + items={[ | |
| 289 | + { key: 'registeredAt', term: '등록일시', description: formatOptionalValue(detail.registeredAt) }, | |
| 290 | + { key: 'registeredBy', term: '등록자', description: formatOptionalValue(detail.registeredBy) }, | |
| 291 | + { | |
| 292 | + key: 'modified', | |
| 293 | + term: '최종 수정', | |
| 294 | + description: [detail.modifiedAt, detail.modifiedBy] | |
| 295 | + .filter((value): value is string => Boolean(value)) | |
| 296 | + .join(' / ') || '-', | |
| 297 | + }, | |
| 298 | + { key: 'status', term: '현재 상태', description: formatMerchantDetailStatusLabel(detail) }, | |
| 299 | + ]} | |
| 300 | + /> | |
| 301 | + </FoxCard> | |
| 302 | + )} | |
| 303 | + | |
| 304 | + {state.status === 'error' && state.message && ( | |
| 305 | + <FoxHelperText type="danger" message={state.message} /> | |
| 306 | + )} | |
| 307 | + | |
| 308 | + <FoxButtonPanel> | |
| 309 | + <FoxButtonGroup size="lg"> | |
| 310 | + {isEdit && ( | |
| 311 | + <> | |
| 312 | + <FoxButton type="default" size="lg" label="삭제" disabled={isBusy} onAction={confirmDelete} /> | |
| 313 | + <FoxButton | |
| 314 | + type="default" | |
| 315 | + size="lg" | |
| 316 | + label={detail.isVisible === false ? '노출' : '비활성'} | |
| 317 | + disabled={isBusy} | |
| 318 | + onAction={toggleVisibility} | |
| 319 | + /> | |
| 320 | + </> | |
| 321 | + )} | |
| 322 | + <FoxButton | |
| 323 | + type="default" | |
| 324 | + size="lg" | |
| 325 | + label="취소" | |
| 326 | + onAction={() => router.push(MERCHANT_DETAILS_PATH)} | |
| 327 | + /> | |
| 328 | + <FoxButton | |
| 329 | + type="primary" | |
| 330 | + size="lg" | |
| 331 | + htmlType="submit" | |
| 332 | + label={isEdit ? '저장' : '등록'} | |
| 333 | + loading={isPending} | |
| 334 | + /> | |
| 335 | + </FoxButtonGroup> | |
| 336 | + </FoxButtonPanel> | |
| 337 | + </form> | |
| 338 | + </> | |
| 339 | + ); | |
| 340 | +} |
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/page.tsx
... | ... | @@ -0,0 +1,33 @@ |
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { notFound } from 'next/navigation'; | |
| 3 | +import { verifySession } from '@/lib/auth/dal'; | |
| 4 | +import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository'; | |
| 5 | +import { fetchMerchantDetail } from '@/lib/data/repositories/merchant-detail-repository'; | |
| 6 | +import { fetchMerchantProducts } from '@/lib/data/repositories/merchant-product-repository'; | |
| 7 | +import { CODE_GROUP } from '@/lib/domain/common-code'; | |
| 8 | +import { MerchantDetailForm } from './_components/merchant-detail-form'; | |
| 9 | + | |
| 10 | +export const metadata: Metadata = { title: '가맹몰 상세 등록·수정' }; | |
| 11 | +export const dynamic = 'force-dynamic'; | |
| 12 | + | |
| 13 | +interface PageProps { | |
| 14 | + params: Promise<{ frcsNo: string }>; | |
| 15 | +} | |
| 16 | + | |
| 17 | +export default async function Page({ params }: PageProps) { | |
| 18 | + await verifySession(); | |
| 19 | + | |
| 20 | + const { frcsNo } = await params; | |
| 21 | + const detail = await fetchMerchantDetail(frcsNo); | |
| 22 | + if (!detail) { | |
| 23 | + notFound(); | |
| 24 | + } | |
| 25 | + | |
| 26 | + // 상세가 없으면 백엔드가 메뉴 조회를 빈 목록으로 답한다 — 따로 가릴 것 없다. | |
| 27 | + const [products, categories] = await Promise.all([ | |
| 28 | + detail.hasDetail ? fetchMerchantProducts(frcsNo) : Promise.resolve([]), | |
| 29 | + fetchCommonCodes(CODE_GROUP.merchantType), | |
| 30 | + ]); | |
| 31 | + | |
| 32 | + return <MerchantDetailForm detail={detail} products={products} categories={categories} />; | |
| 33 | +} |
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/products/[productSn]/page.tsx
... | ... | @@ -0,0 +1,32 @@ |
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { notFound } from 'next/navigation'; | |
| 3 | +import { verifySession } from '@/lib/auth/dal'; | |
| 4 | +import { fetchMerchantDetail } from '@/lib/data/repositories/merchant-detail-repository'; | |
| 5 | +import { | |
| 6 | + fetchMerchantProduct, | |
| 7 | + fetchMerchantProducts, | |
| 8 | +} from '@/lib/data/repositories/merchant-product-repository'; | |
| 9 | +import { MerchantProductForm } from '../_components/merchant-product-form'; | |
| 10 | + | |
| 11 | +export const metadata: Metadata = { title: '가맹몰 메뉴 수정' }; | |
| 12 | +export const dynamic = 'force-dynamic'; | |
| 13 | + | |
| 14 | +interface PageProps { | |
| 15 | + params: Promise<{ frcsNo: string; productSn: string }>; | |
| 16 | +} | |
| 17 | + | |
| 18 | +export default async function Page({ params }: PageProps) { | |
| 19 | + await verifySession(); | |
| 20 | + | |
| 21 | + const { frcsNo, productSn } = await params; | |
| 22 | + const [detail, product, products] = await Promise.all([ | |
| 23 | + fetchMerchantDetail(frcsNo), | |
| 24 | + fetchMerchantProduct(productSn), | |
| 25 | + fetchMerchantProducts(frcsNo), | |
| 26 | + ]); | |
| 27 | + if (!detail || !product || product.frcsNo !== frcsNo) { | |
| 28 | + notFound(); | |
| 29 | + } | |
| 30 | + | |
| 31 | + return <MerchantProductForm detail={detail} product={product} productCount={products.length} />; | |
| 32 | +} |
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/products/_components/merchant-product-form.module.scss
... | ... | @@ -0,0 +1,69 @@ |
| 1 | +@use "@fox/styles/abstracts" as fox; | |
| 2 | + | |
| 3 | +.form { | |
| 4 | + display: flex; | |
| 5 | + inline-size: 100%; | |
| 6 | + flex-direction: column; | |
| 7 | + gap: fox.gap(6); | |
| 8 | +} | |
| 9 | + | |
| 10 | +.section { | |
| 11 | + inline-size: 100%; | |
| 12 | +} | |
| 13 | + | |
| 14 | +// 기획 BO-MAL-004 — 왼쪽 이미지(240×180) · 오른쪽 입력. 창이 좁으면 아래로 접는다. | |
| 15 | +.layout { | |
| 16 | + display: flex; | |
| 17 | + flex-wrap: wrap; | |
| 18 | + align-items: flex-start; | |
| 19 | + gap: fox.gap(6); | |
| 20 | + inline-size: 100%; | |
| 21 | +} | |
| 22 | + | |
| 23 | +.image { | |
| 24 | + display: flex; | |
| 25 | + flex: 0 0 auto; | |
| 26 | + flex-direction: column; | |
| 27 | + gap: fox.gap(2); | |
| 28 | +} | |
| 29 | + | |
| 30 | +.caption { | |
| 31 | + font-size: fox.font-size(label-sm); | |
| 32 | + color: fox.color(font-neutral-subtle); | |
| 33 | +} | |
| 34 | + | |
| 35 | +.fields { | |
| 36 | + display: flex; | |
| 37 | + flex: 1 1 48rem; | |
| 38 | + min-inline-size: 0; | |
| 39 | + flex-direction: column; | |
| 40 | + gap: fox.gap(4); | |
| 41 | +} | |
| 42 | + | |
| 43 | +.grid { | |
| 44 | + display: flex; | |
| 45 | + flex-wrap: wrap; | |
| 46 | + align-items: flex-start; | |
| 47 | + gap: fox.gap(4); | |
| 48 | + inline-size: 100%; | |
| 49 | + | |
| 50 | + & + & { | |
| 51 | + margin-block-start: fox.gap(5); | |
| 52 | + } | |
| 53 | +} | |
| 54 | + | |
| 55 | +.field { | |
| 56 | + display: flex; | |
| 57 | + flex: 1 1 24rem; | |
| 58 | + min-inline-size: 0; | |
| 59 | + flex-direction: column; | |
| 60 | + gap: fox.gap(2); | |
| 61 | +} | |
| 62 | + | |
| 63 | +.fieldWide { | |
| 64 | + display: flex; | |
| 65 | + flex: 2 1 40rem; | |
| 66 | + min-inline-size: 0; | |
| 67 | + flex-direction: column; | |
| 68 | + gap: fox.gap(2); | |
| 69 | +} |
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/products/_components/merchant-product-form.tsx
... | ... | @@ -0,0 +1,263 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useRouter } from 'next/navigation'; | |
| 4 | +import { useActionState, useState } from 'react'; | |
| 5 | +import { FoxButton } from '@fox/core/components/fox-button'; | |
| 6 | +import { FoxButtonGroup } from '@fox/core/components/fox-button-group'; | |
| 7 | +import { FoxButtonPanel } from '@fox/core/components/fox-button-panel'; | |
| 8 | +import { FoxCard } from '@fox/core/components/fox-card'; | |
| 9 | +import { FoxDescriptionList } from '@fox/core/components/fox-description-list'; | |
| 10 | +import { FoxFileUpload } from '@fox/core/components/fox-file-upload'; | |
| 11 | +import { FoxHelperText } from '@fox/core/components/fox-helper-text'; | |
| 12 | +import { FoxInput } from '@fox/core/components/fox-input'; | |
| 13 | +import { FoxPageHeader } from '@fox/core/components/fox-page-header'; | |
| 14 | +import { FoxTextArea } from '@fox/core/components/fox-text-area'; | |
| 15 | +import { submitFormAction } from '@/app/_hooks/submit-form-action'; | |
| 16 | +import { useBlockingAction } from '@/app/_hooks/use-blocking-action'; | |
| 17 | +import { useFeedback } from '@/app/_hooks/use-feedback'; | |
| 18 | +import { FILE_MODULE, fileModuleAccept } from '@/lib/domain/file-module'; | |
| 19 | +import { formatOptionalValue, type MerchantDetail } from '@/lib/domain/merchant-detail'; | |
| 20 | +import { | |
| 21 | + MERCHANT_DETAILS_PATH, | |
| 22 | + merchantDetailPath, | |
| 23 | +} from '@/lib/domain/merchant-detail-query'; | |
| 24 | +import { | |
| 25 | + MAX_PRODUCT_BADGES, | |
| 26 | + MERCHANT_PRODUCT_BADGE_OPTIONS, | |
| 27 | + MERCHANT_PRODUCT_VISIBILITY_OPTIONS, | |
| 28 | + type MerchantProduct, | |
| 29 | +} from '@/lib/domain/merchant-product'; | |
| 30 | +import { | |
| 31 | + INITIAL_MERCHANT_PRODUCT_FORM_STATE, | |
| 32 | + PRODUCT_DESCRIPTION_MAX_LENGTH, | |
| 33 | + PRODUCT_NAME_MAX_LENGTH, | |
| 34 | + PRODUCT_PRICE_MAX, | |
| 35 | + PRODUCT_SORT_ORDER_MAX, | |
| 36 | +} from '@/lib/domain/merchant-product-form'; | |
| 37 | +import { deleteMerchantProductAction, saveMerchantProductAction } from '../../../_actions'; | |
| 38 | +import { MerchantChoiceChips } from '../../../_components/merchant-choice-chips'; | |
| 39 | +import styles from './merchant-product-form.module.scss'; | |
| 40 | + | |
| 41 | +interface MerchantProductFormProps { | |
| 42 | + detail: MerchantDetail; | |
| 43 | + product?: MerchantProduct; | |
| 44 | + productCount: number; | |
| 45 | +} | |
| 46 | + | |
| 47 | +/** | |
| 48 | + * 가맹몰 메뉴 등록·수정 — 기획 BO-MAL-004. 대상 업체는 읽기 전용이고 이 화면에서 바꾸지 않는다. | |
| 49 | + * 저장하면 업체 상세 화면으로 돌아간다. | |
| 50 | + */ | |
| 51 | +export function MerchantProductForm({ detail, product, productCount }: MerchantProductFormProps) { | |
| 52 | + const router = useRouter(); | |
| 53 | + const { showAlert, hideAlert, showToast } = useFeedback(); | |
| 54 | + const [isBusy, startAction] = useBlockingAction(); | |
| 55 | + const [state, formAction, isPending] = useActionState( | |
| 56 | + saveMerchantProductAction, | |
| 57 | + INITIAL_MERCHANT_PRODUCT_FORM_STATE | |
| 58 | + ); | |
| 59 | + const isEdit = product !== undefined; | |
| 60 | + const errors = state.status === 'error' ? (state.errors ?? {}) : {}; | |
| 61 | + const detailHref = merchantDetailPath(detail.id); | |
| 62 | + | |
| 63 | + const [badges, setBadges] = useState<string[]>(product?.badges ?? []); | |
| 64 | + const [visibility, setVisibility] = useState<string[]>([ | |
| 65 | + product?.isVisible === false ? 'N' : 'Y', | |
| 66 | + ]); | |
| 67 | + | |
| 68 | + function runDelete() { | |
| 69 | + if (!product) return; | |
| 70 | + hideAlert(); | |
| 71 | + startAction(async () => { | |
| 72 | + const result = await deleteMerchantProductAction(detail.id, product.id); | |
| 73 | + if (result.status === 'error') { | |
| 74 | + showToast({ variant: 'danger', message: result.message }); | |
| 75 | + return; | |
| 76 | + } | |
| 77 | + showToast({ variant: 'success', message: '메뉴를 삭제했습니다.' }); | |
| 78 | + router.push(detailHref); | |
| 79 | + }); | |
| 80 | + } | |
| 81 | + | |
| 82 | + function confirmDelete() { | |
| 83 | + if (!product) return; | |
| 84 | + showAlert({ | |
| 85 | + variant: 'danger', | |
| 86 | + title: '메뉴를 삭제하시겠습니까?', | |
| 87 | + message: `"${product.name}" 메뉴를 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`, | |
| 88 | + actions: ( | |
| 89 | + <FoxButtonGroup size="md"> | |
| 90 | + <FoxButton type="default" size="md" label="취소" onAction={hideAlert} /> | |
| 91 | + <FoxButton type="primary" size="md" label="삭제" onAction={runDelete} /> | |
| 92 | + </FoxButtonGroup> | |
| 93 | + ), | |
| 94 | + }); | |
| 95 | + } | |
| 96 | + | |
| 97 | + return ( | |
| 98 | + <> | |
| 99 | + <FoxPageHeader | |
| 100 | + title={`메뉴 ${isEdit ? '수정' : '등록'}`} | |
| 101 | + breadcrumb={[ | |
| 102 | + { label: '홈', href: '/' }, | |
| 103 | + { label: '시스템관리' }, | |
| 104 | + { label: '가맹몰 상세 관리', href: MERCHANT_DETAILS_PATH }, | |
| 105 | + { label: detail.name, href: detailHref }, | |
| 106 | + { label: isEdit ? '메뉴 수정' : '메뉴 등록' }, | |
| 107 | + ]} | |
| 108 | + /> | |
| 109 | + | |
| 110 | + <form onSubmit={(event) => submitFormAction(event, formAction)} className={styles.form}> | |
| 111 | + <input type="hidden" name="frcsNo" value={detail.id} /> | |
| 112 | + <input type="hidden" name="productSn" value={product?.id ?? ''} /> | |
| 113 | + | |
| 114 | + <FoxCard | |
| 115 | + size="lg" | |
| 116 | + title="대상 업체" | |
| 117 | + className={styles.section} | |
| 118 | + actions={ | |
| 119 | + <FoxButton | |
| 120 | + type="default" | |
| 121 | + size="sm" | |
| 122 | + label="업체 정보로 이동" | |
| 123 | + onAction={() => router.push(detailHref)} | |
| 124 | + /> | |
| 125 | + } | |
| 126 | + > | |
| 127 | + <FoxDescriptionList | |
| 128 | + items={[ | |
| 129 | + { key: 'name', term: '업체명', description: detail.name }, | |
| 130 | + { | |
| 131 | + key: 'category', | |
| 132 | + term: '카테고리', | |
| 133 | + description: formatOptionalValue(detail.categoryCode), | |
| 134 | + }, | |
| 135 | + { key: 'count', term: '등록 메뉴', description: `${productCount}건` }, | |
| 136 | + ]} | |
| 137 | + /> | |
| 138 | + </FoxCard> | |
| 139 | + | |
| 140 | + <FoxCard size="lg" title="메뉴 정보" className={styles.section}> | |
| 141 | + <div className={styles.layout}> | |
| 142 | + <div className={styles.image}> | |
| 143 | + <FoxFileUpload | |
| 144 | + mode="image" | |
| 145 | + name="imageFile" | |
| 146 | + accept={fileModuleAccept(FILE_MODULE.merchantImage)} | |
| 147 | + thumbnailPicker | |
| 148 | + selectLabel="메뉴 이미지 등록" | |
| 149 | + description="JPG · PNG / 5MB 이하" | |
| 150 | + thumbnailWidth={240} | |
| 151 | + thumbnailHeight={180} | |
| 152 | + defaultFiles={ | |
| 153 | + product?.imageUrl | |
| 154 | + ? [{ id: 'image', name: '메뉴 이미지', previewUrl: product.imageUrl }] | |
| 155 | + : undefined | |
| 156 | + } | |
| 157 | + /> | |
| 158 | + <input type="hidden" name="imageFileId" value={product?.imageFileId ?? ''} /> | |
| 159 | + <span className={styles.caption}>메뉴 이미지 (선택)</span> | |
| 160 | + {errors.image && <FoxHelperText type="danger" message={errors.image} />} | |
| 161 | + </div> | |
| 162 | + | |
| 163 | + <div className={styles.fields}> | |
| 164 | + <div className={styles.grid}> | |
| 165 | + <div className={styles.fieldWide}> | |
| 166 | + <FoxInput | |
| 167 | + size="md" | |
| 168 | + name="name" | |
| 169 | + label="메뉴명" | |
| 170 | + requirement="required" | |
| 171 | + placeholder="예) 패밀리 3~4인 세트" | |
| 172 | + defaultValue={product?.name ?? ''} | |
| 173 | + maxLength={PRODUCT_NAME_MAX_LENGTH} | |
| 174 | + invalid={Boolean(errors.name)} | |
| 175 | + message={errors.name} | |
| 176 | + /> | |
| 177 | + </div> | |
| 178 | + <div className={styles.field}> | |
| 179 | + <FoxInput | |
| 180 | + size="md" | |
| 181 | + name="price" | |
| 182 | + type="number" | |
| 183 | + inputMode="numeric" | |
| 184 | + min={0} | |
| 185 | + max={PRODUCT_PRICE_MAX} | |
| 186 | + label="가격" | |
| 187 | + requirement="required" | |
| 188 | + placeholder="숫자만 입력 (원)" | |
| 189 | + defaultValue={product?.price === null || product === undefined ? '' : String(product.price)} | |
| 190 | + invalid={Boolean(errors.price)} | |
| 191 | + message={errors.price} | |
| 192 | + /> | |
| 193 | + </div> | |
| 194 | + </div> | |
| 195 | + | |
| 196 | + <FoxTextArea | |
| 197 | + name="description" | |
| 198 | + label="설명" | |
| 199 | + rows={3} | |
| 200 | + placeholder="앱 메뉴 목록에 2~3줄로 노출된다." | |
| 201 | + defaultValue={product?.description ?? ''} | |
| 202 | + maxLength={PRODUCT_DESCRIPTION_MAX_LENGTH} | |
| 203 | + counter | |
| 204 | + invalid={Boolean(errors.description)} | |
| 205 | + message={errors.description} | |
| 206 | + /> | |
| 207 | + </div> | |
| 208 | + </div> | |
| 209 | + | |
| 210 | + <div className={styles.grid}> | |
| 211 | + <MerchantChoiceChips | |
| 212 | + label="배지" | |
| 213 | + name="badges" | |
| 214 | + optionalText="복수 선택" | |
| 215 | + options={MERCHANT_PRODUCT_BADGE_OPTIONS} | |
| 216 | + values={badges} | |
| 217 | + onChange={setBadges} | |
| 218 | + maxSelected={MAX_PRODUCT_BADGES} | |
| 219 | + error={errors.badges} | |
| 220 | + /> | |
| 221 | + <MerchantChoiceChips | |
| 222 | + label="노출 여부" | |
| 223 | + name="isVisible" | |
| 224 | + required | |
| 225 | + options={MERCHANT_PRODUCT_VISIBILITY_OPTIONS} | |
| 226 | + values={visibility} | |
| 227 | + onChange={setVisibility} | |
| 228 | + /> | |
| 229 | + <div className={styles.field}> | |
| 230 | + <FoxInput | |
| 231 | + size="md" | |
| 232 | + name="sortOrder" | |
| 233 | + type="number" | |
| 234 | + inputMode="numeric" | |
| 235 | + min={0} | |
| 236 | + max={PRODUCT_SORT_ORDER_MAX} | |
| 237 | + label="노출 순서" | |
| 238 | + placeholder="숫자 (낮을수록 상단)" | |
| 239 | + defaultValue={product?.sortOrder === null || product === undefined ? '' : String(product.sortOrder)} | |
| 240 | + invalid={Boolean(errors.sortOrder)} | |
| 241 | + message={errors.sortOrder} | |
| 242 | + /> | |
| 243 | + </div> | |
| 244 | + </div> | |
| 245 | + </FoxCard> | |
| 246 | + | |
| 247 | + {state.status === 'error' && state.message && ( | |
| 248 | + <FoxHelperText type="danger" message={state.message} /> | |
| 249 | + )} | |
| 250 | + | |
| 251 | + <FoxButtonPanel> | |
| 252 | + <FoxButtonGroup size="lg"> | |
| 253 | + {isEdit && ( | |
| 254 | + <FoxButton type="default" size="lg" label="삭제" disabled={isBusy} onAction={confirmDelete} /> | |
| 255 | + )} | |
| 256 | + <FoxButton type="default" size="lg" label="취소" onAction={() => router.push(detailHref)} /> | |
| 257 | + <FoxButton type="primary" size="lg" htmlType="submit" label="저장" loading={isPending} /> | |
| 258 | + </FoxButtonGroup> | |
| 259 | + </FoxButtonPanel> | |
| 260 | + </form> | |
| 261 | + </> | |
| 262 | + ); | |
| 263 | +} |
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/products/new/page.tsx
... | ... | @@ -0,0 +1,26 @@ |
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { notFound } from 'next/navigation'; | |
| 3 | +import { verifySession } from '@/lib/auth/dal'; | |
| 4 | +import { fetchMerchantDetail } from '@/lib/data/repositories/merchant-detail-repository'; | |
| 5 | +import { fetchMerchantProducts } from '@/lib/data/repositories/merchant-product-repository'; | |
| 6 | +import { MerchantProductForm } from '../_components/merchant-product-form'; | |
| 7 | + | |
| 8 | +export const metadata: Metadata = { title: '가맹몰 메뉴 등록' }; | |
| 9 | +export const dynamic = 'force-dynamic'; | |
| 10 | + | |
| 11 | +interface PageProps { | |
| 12 | + params: Promise<{ frcsNo: string }>; | |
| 13 | +} | |
| 14 | + | |
| 15 | +export default async function Page({ params }: PageProps) { | |
| 16 | + await verifySession(); | |
| 17 | + | |
| 18 | + const { frcsNo } = await params; | |
| 19 | + const detail = await fetchMerchantDetail(frcsNo); | |
| 20 | + if (!detail || !detail.hasDetail) { | |
| 21 | + notFound(); | |
| 22 | + } | |
| 23 | + | |
| 24 | + const products = await fetchMerchantProducts(frcsNo); | |
| 25 | + return <MerchantProductForm detail={detail} productCount={products.length} />; | |
| 26 | +} |
+++ app/(protected)/(basic)/system/merchant-details/_actions.ts
... | ... | @@ -0,0 +1,493 @@ |
| 1 | +'use server'; | |
| 2 | + | |
| 3 | +import { revalidatePath } from 'next/cache'; | |
| 4 | +import { redirect, unstable_rethrow } from 'next/navigation'; | |
| 5 | +import { verifySession } from '@/lib/auth/dal'; | |
| 6 | +import { BackendRequestError } from '@/lib/http/backend-fetch'; | |
| 7 | +import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository'; | |
| 8 | +import { | |
| 9 | + buildFileImageUrl, | |
| 10 | + uploadAttachment, | |
| 11 | + uploadAttachments, | |
| 12 | +} from '@/lib/data/repositories/file-repository'; | |
| 13 | +import { | |
| 14 | + createMerchantDetail, | |
| 15 | + deleteMerchantDetail, | |
| 16 | + fetchMerchantDetail, | |
| 17 | + updateMerchantDetail, | |
| 18 | +} from '@/lib/data/repositories/merchant-detail-repository'; | |
| 19 | +import { | |
| 20 | + createMerchantProduct, | |
| 21 | + deleteMerchantProduct, | |
| 22 | + fetchMerchantProduct, | |
| 23 | + updateMerchantProduct, | |
| 24 | +} from '@/lib/data/repositories/merchant-product-repository'; | |
| 25 | +import { CODE_GROUP } from '@/lib/domain/common-code'; | |
| 26 | +import { | |
| 27 | + FILE_MODULE, | |
| 28 | + MAX_ATTACHMENT_BYTES, | |
| 29 | + allowedExtensions, | |
| 30 | + isAllowedFileExtension, | |
| 31 | +} from '@/lib/domain/file-module'; | |
| 32 | +import { MAX_EXTRA_IMAGES, MAX_IMAGE_BYTES } from '@/lib/domain/merchant-detail'; | |
| 33 | +import { | |
| 34 | + validateMerchantDetail, | |
| 35 | + type MerchantDetailFormState, | |
| 36 | + type MerchantDetailValues, | |
| 37 | +} from '@/lib/domain/merchant-detail-form'; | |
| 38 | +import { | |
| 39 | + MERCHANT_DETAILS_PATH, | |
| 40 | + merchantDetailPath, | |
| 41 | +} from '@/lib/domain/merchant-detail-query'; | |
| 42 | +import { | |
| 43 | + MERCHANT_PRODUCT_BADGE_OPTIONS, | |
| 44 | + type MerchantProductBadge, | |
| 45 | +} from '@/lib/domain/merchant-product'; | |
| 46 | +import { | |
| 47 | + validateMerchantProduct, | |
| 48 | + type MerchantProductFormState, | |
| 49 | +} from '@/lib/domain/merchant-product-form'; | |
| 50 | + | |
| 51 | +/** | |
| 52 | + * 가맹몰 상세 Server Action — 상세(가맹점당 1건)와 메뉴(여러 건) 둘 다 여기서 다룬다. | |
| 53 | + * | |
| 54 | + * 이슈: 목록의 [비활성]/[노출]은 백엔드가 상세 전체를 돌려줘야 안전하다. `PUT`이 모든 컬럼을 | |
| 55 | + * 무조건 덮어써서, 단건 조회가 비어 있는 지금 값 없이 보내면 소개·영업시간·이미지가 지워진다. | |
| 56 | + * 그래서 조회 결과에 대표이미지가 없으면 목록 토글을 거부하고 수정 화면으로 안내한다. | |
| 57 | + * 단건 SELECT가 보강되면(보고함) 그대로 동작한다. | |
| 58 | + */ | |
| 59 | + | |
| 60 | +const SAVE_FAILED_MESSAGE = '저장하지 못했습니다. 잠시 후 다시 시도해 주세요.'; | |
| 61 | +const DELETE_FAILED_MESSAGE = '삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.'; | |
| 62 | +const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.'; | |
| 63 | +const DETAIL_NOT_LOADED_MESSAGE = | |
| 64 | + '백엔드가 상세 값을 돌려주지 않아 목록에서는 바꿀 수 없습니다. 수정 화면에서 바꿔 주세요.'; | |
| 65 | +const IMAGE_OVERSIZE_MESSAGE = `이미지는 장당 ${Math.floor(MAX_IMAGE_BYTES / (1024 * 1024))}MB까지 올릴 수 있습니다.`; | |
| 66 | +const IMAGES_TOO_MANY_MESSAGE = `추가 이미지는 최대 ${MAX_EXTRA_IMAGES}장까지입니다.`; | |
| 67 | +const REQUEST_OVERSIZE_MESSAGE = `한 번에 올리는 이미지의 합이 ${Math.floor(MAX_ATTACHMENT_BYTES / (1024 * 1024))}MB를 넘습니다. 나눠서 저장해 주세요.`; | |
| 68 | + | |
| 69 | +class InputError extends Error { | |
| 70 | + constructor(message: string) { | |
| 71 | + super(message); | |
| 72 | + this.name = 'InputError'; | |
| 73 | + } | |
| 74 | +} | |
| 75 | + | |
| 76 | +export type MerchantDetailActionResult = | |
| 77 | + | { status: 'success' } | |
| 78 | + | { status: 'error'; message: string }; | |
| 79 | + | |
| 80 | +function readString(formData: FormData, key: string): string { | |
| 81 | + const value = formData.get(key); | |
| 82 | + return typeof value === 'string' ? value : ''; | |
| 83 | +} | |
| 84 | + | |
| 85 | +function readOptionalInteger(formData: FormData, key: string): number | null { | |
| 86 | + const raw = readString(formData, key).trim(); | |
| 87 | + if (raw === '') return null; | |
| 88 | + const parsed = Number(raw); | |
| 89 | + return Number.isFinite(parsed) ? parsed : Number.NaN; | |
| 90 | +} | |
| 91 | + | |
| 92 | +function readFiles(formData: FormData, key: string): Array<File | null> { | |
| 93 | + return formData | |
| 94 | + .getAll(key) | |
| 95 | + .map((entry) => (entry instanceof File && entry.size > 0 ? entry : null)); | |
| 96 | +} | |
| 97 | + | |
| 98 | +function assertImage(file: File): void { | |
| 99 | + if (file.size > MAX_IMAGE_BYTES) { | |
| 100 | + throw new InputError(IMAGE_OVERSIZE_MESSAGE); | |
| 101 | + } | |
| 102 | + if (!isAllowedFileExtension(FILE_MODULE.merchantImage, file.name)) { | |
| 103 | + throw new InputError( | |
| 104 | + `이미지는 ${allowedExtensions(FILE_MODULE.merchantImage).join(', ')} 파일만 올릴 수 있습니다.` | |
| 105 | + ); | |
| 106 | + } | |
| 107 | +} | |
| 108 | + | |
| 109 | +function failureMessage(error: unknown, fallback: string): string { | |
| 110 | + const showable = error instanceof InputError || error instanceof BackendRequestError; | |
| 111 | + return showable && error.message ? error.message : fallback; | |
| 112 | +} | |
| 113 | + | |
| 114 | +// ── 상세 ────────────────────────────────────────────────────────────────────── | |
| 115 | + | |
| 116 | +async function resolveThumbnailId(formData: FormData): Promise<string> { | |
| 117 | + const file = readFiles(formData, 'thumbnailFile')[0] ?? null; | |
| 118 | + if (!file) { | |
| 119 | + return readString(formData, 'thumbnailFileId'); | |
| 120 | + } | |
| 121 | + assertImage(file); | |
| 122 | + return uploadAttachment(file, FILE_MODULE.merchantImage.id); | |
| 123 | +} | |
| 124 | + | |
| 125 | +/** 저장된 이미지 한 장을 다시 파일로 — 묶음을 새 순서로 다시 올릴 때 쓴다. */ | |
| 126 | +async function fetchSavedImage(fileId: string, fileSn: number): Promise<File> { | |
| 127 | + const url = buildFileImageUrl(fileId, fileSn); | |
| 128 | + if (!url) { | |
| 129 | + throw new InputError(INVALID_REQUEST_MESSAGE); | |
| 130 | + } | |
| 131 | + const response = await fetch(url, { cache: 'no-store' }); | |
| 132 | + if (!response.ok) { | |
| 133 | + throw new InputError('저장된 이미지를 다시 읽지 못했습니다. 잠시 후 다시 시도해 주세요.'); | |
| 134 | + } | |
| 135 | + const blob = await response.blob(); | |
| 136 | + const extension = blob.type.split('/')[1] || 'jpg'; | |
| 137 | + return new File([blob], `image-${fileSn}.${extension}`, { type: blob.type }); | |
| 138 | +} | |
| 139 | + | |
| 140 | +/** | |
| 141 | + * 추가 이미지 묶음. 백엔드가 한 `atchFileId` 아래 `fileSn` 1..n으로 채번하므로 순서·삭제·추가 | |
| 142 | + * 어느 것이든 바뀌면 묶음을 통째로 다시 올린다 — 저장된 장은 백엔드에서 다시 읽어 합친다. | |
| 143 | + * | |
| 144 | + * 폼: 칸마다 `imageSlot`(`saved:<fileSn>` | `new`)과 `imageFile`(같은 순서, 안 고른 칸은 빈 파일). | |
| 145 | + */ | |
| 146 | +async function resolveImageBundleId(formData: FormData): Promise<string> { | |
| 147 | + const retainedId = readString(formData, 'imageFileId'); | |
| 148 | + const slots = formData | |
| 149 | + .getAll('imageSlot') | |
| 150 | + .map((entry) => (typeof entry === 'string' ? entry : '')); | |
| 151 | + const files = readFiles(formData, 'imageFile'); | |
| 152 | + | |
| 153 | + if (slots.length > MAX_EXTRA_IMAGES) { | |
| 154 | + throw new InputError(IMAGES_TOO_MANY_MESSAGE); | |
| 155 | + } | |
| 156 | + | |
| 157 | + const savedSns = slots | |
| 158 | + .map((slot, index) => | |
| 159 | + files[index] === null && slot.startsWith('saved:') ? Number(slot.slice(6)) : null | |
| 160 | + ) | |
| 161 | + .filter((sn): sn is number => sn !== null); | |
| 162 | + const hasNewFile = files.some((file) => file !== null); | |
| 163 | + const untouched = | |
| 164 | + !hasNewFile && | |
| 165 | + savedSns.length === slots.length && | |
| 166 | + savedSns.every((sn, index) => sn === index + 1); | |
| 167 | + | |
| 168 | + if (slots.length === 0) { | |
| 169 | + return ''; | |
| 170 | + } | |
| 171 | + if (untouched) { | |
| 172 | + return retainedId; | |
| 173 | + } | |
| 174 | + | |
| 175 | + let newBytes = 0; | |
| 176 | + const bundle: File[] = []; | |
| 177 | + for (const [index, slot] of slots.entries()) { | |
| 178 | + const picked = files[index]; | |
| 179 | + if (picked) { | |
| 180 | + assertImage(picked); | |
| 181 | + newBytes += picked.size; | |
| 182 | + bundle.push(picked); | |
| 183 | + } else if (slot.startsWith('saved:') && retainedId) { | |
| 184 | + bundle.push(await fetchSavedImage(retainedId, Number(slot.slice(6)))); | |
| 185 | + } | |
| 186 | + } | |
| 187 | + if (newBytes > MAX_ATTACHMENT_BYTES) { | |
| 188 | + throw new InputError(REQUEST_OVERSIZE_MESSAGE); | |
| 189 | + } | |
| 190 | + if (bundle.length === 0) { | |
| 191 | + return ''; | |
| 192 | + } | |
| 193 | + return uploadAttachments(bundle, FILE_MODULE.merchantImage.id); | |
| 194 | +} | |
| 195 | + | |
| 196 | +function readDetailValues( | |
| 197 | + formData: FormData, | |
| 198 | + thumbnailFileId: string, | |
| 199 | + imageFileId: string | |
| 200 | +): MerchantDetailValues { | |
| 201 | + return { | |
| 202 | + frcsNo: readString(formData, 'frcsNo'), | |
| 203 | + categoryCode: readString(formData, 'categoryCode'), | |
| 204 | + description: readString(formData, 'description'), | |
| 205 | + openTime: readString(formData, 'openTime'), | |
| 206 | + closeTime: readString(formData, 'closeTime'), | |
| 207 | + closedDays: readString(formData, 'closedDays'), | |
| 208 | + isVisible: readString(formData, 'isVisible') !== 'N', | |
| 209 | + isVoucherUsable: readString(formData, 'isVoucherUsable') !== 'N', | |
| 210 | + sortOrder: readOptionalInteger(formData, 'sortOrder'), | |
| 211 | + thumbnailFileId, | |
| 212 | + imageFileId, | |
| 213 | + }; | |
| 214 | +} | |
| 215 | + | |
| 216 | +export async function saveMerchantDetailAction( | |
| 217 | + _prevState: MerchantDetailFormState, | |
| 218 | + formData: FormData | |
| 219 | +): Promise<MerchantDetailFormState> { | |
| 220 | + await verifySession(); | |
| 221 | + | |
| 222 | + const frcsNo = readString(formData, 'frcsNo'); | |
| 223 | + const isEdit = readString(formData, 'mode') === 'edit'; | |
| 224 | + if (!frcsNo) { | |
| 225 | + return { status: 'error', message: INVALID_REQUEST_MESSAGE }; | |
| 226 | + } | |
| 227 | + | |
| 228 | + let failed: MerchantDetailFormState | null = null; | |
| 229 | + try { | |
| 230 | + const [thumbnailFileId, imageFileId, categories] = await Promise.all([ | |
| 231 | + resolveThumbnailId(formData), | |
| 232 | + resolveImageBundleId(formData), | |
| 233 | + fetchCommonCodes(CODE_GROUP.merchantType), | |
| 234 | + ]); | |
| 235 | + | |
| 236 | + const validation = validateMerchantDetail( | |
| 237 | + readDetailValues(formData, thumbnailFileId, imageFileId), | |
| 238 | + categories | |
| 239 | + ); | |
| 240 | + if (!validation.ok) { | |
| 241 | + return { status: 'error', errors: validation.errors }; | |
| 242 | + } | |
| 243 | + | |
| 244 | + if (isEdit) { | |
| 245 | + await updateMerchantDetail(validation.values); | |
| 246 | + } else { | |
| 247 | + await createMerchantDetail(validation.values); | |
| 248 | + } | |
| 249 | + } catch (error) { | |
| 250 | + unstable_rethrow(error); | |
| 251 | + console.error('[merchant-details] 저장 실패', error); | |
| 252 | + failed = { status: 'error', message: failureMessage(error, SAVE_FAILED_MESSAGE) }; | |
| 253 | + } | |
| 254 | + if (failed) { | |
| 255 | + return failed; | |
| 256 | + } | |
| 257 | + | |
| 258 | + revalidatePath(MERCHANT_DETAILS_PATH); | |
| 259 | + revalidatePath(merchantDetailPath(frcsNo)); | |
| 260 | + redirect(MERCHANT_DETAILS_PATH); | |
| 261 | +} | |
| 262 | + | |
| 263 | +export async function deleteMerchantDetailAction( | |
| 264 | + frcsNo: string | |
| 265 | +): Promise<MerchantDetailActionResult> { | |
| 266 | + await verifySession(); | |
| 267 | + if (!frcsNo) { | |
| 268 | + return { status: 'error', message: INVALID_REQUEST_MESSAGE }; | |
| 269 | + } | |
| 270 | + | |
| 271 | + try { | |
| 272 | + await deleteMerchantDetail(frcsNo); | |
| 273 | + } catch (error) { | |
| 274 | + unstable_rethrow(error); | |
| 275 | + console.error('[merchant-details] 삭제 실패', error); | |
| 276 | + return { status: 'error', message: failureMessage(error, DELETE_FAILED_MESSAGE) }; | |
| 277 | + } | |
| 278 | + | |
| 279 | + revalidatePath(MERCHANT_DETAILS_PATH); | |
| 280 | + return { status: 'success' }; | |
| 281 | +} | |
| 282 | + | |
| 283 | +export async function deleteMerchantDetailsAction( | |
| 284 | + frcsNos: string[] | |
| 285 | +): Promise<MerchantDetailActionResult> { | |
| 286 | + await verifySession(); | |
| 287 | + const targets = frcsNos.filter((value) => typeof value === 'string' && value !== ''); | |
| 288 | + if (targets.length === 0) { | |
| 289 | + return { status: 'error', message: INVALID_REQUEST_MESSAGE }; | |
| 290 | + } | |
| 291 | + | |
| 292 | + try { | |
| 293 | + for (const frcsNo of targets) { | |
| 294 | + await deleteMerchantDetail(frcsNo); | |
| 295 | + } | |
| 296 | + } catch (error) { | |
| 297 | + unstable_rethrow(error); | |
| 298 | + console.error('[merchant-details] 일괄 삭제 실패', error); | |
| 299 | + return { status: 'error', message: failureMessage(error, DELETE_FAILED_MESSAGE) }; | |
| 300 | + } | |
| 301 | + | |
| 302 | + revalidatePath(MERCHANT_DETAILS_PATH); | |
| 303 | + return { status: 'success' }; | |
| 304 | +} | |
| 305 | + | |
| 306 | +async function toggleDetailVisibility(frcsNo: string, isVisible: boolean): Promise<void> { | |
| 307 | + const detail = await fetchMerchantDetail(frcsNo); | |
| 308 | + // 대표이미지는 필수라 상세가 있으면 반드시 온다 — 없으면 백엔드가 상세를 돌려주지 않은 것이다. | |
| 309 | + if (!detail || !detail.hasDetail || !detail.thumbnailFileId || !detail.categoryCode) { | |
| 310 | + throw new InputError(DETAIL_NOT_LOADED_MESSAGE); | |
| 311 | + } | |
| 312 | + await updateMerchantDetail({ | |
| 313 | + frcsNo, | |
| 314 | + categoryCode: detail.categoryCode, | |
| 315 | + description: detail.description ?? '', | |
| 316 | + openTime: detail.openTime ?? '', | |
| 317 | + closeTime: detail.closeTime ?? '', | |
| 318 | + closedDays: detail.closedDays ?? '', | |
| 319 | + isVisible, | |
| 320 | + isVoucherUsable: true, | |
| 321 | + sortOrder: detail.sortOrder, | |
| 322 | + thumbnailFileId: detail.thumbnailFileId, | |
| 323 | + imageFileId: detail.imageFileId ?? '', | |
| 324 | + }); | |
| 325 | +} | |
| 326 | + | |
| 327 | +export async function updateMerchantDetailVisibilityAction( | |
| 328 | + frcsNo: string, | |
| 329 | + isVisible: boolean | |
| 330 | +): Promise<MerchantDetailActionResult> { | |
| 331 | + await verifySession(); | |
| 332 | + if (!frcsNo) { | |
| 333 | + return { status: 'error', message: INVALID_REQUEST_MESSAGE }; | |
| 334 | + } | |
| 335 | + | |
| 336 | + try { | |
| 337 | + await toggleDetailVisibility(frcsNo, isVisible); | |
| 338 | + } catch (error) { | |
| 339 | + unstable_rethrow(error); | |
| 340 | + console.error('[merchant-details] 노출 변경 실패', error); | |
| 341 | + return { status: 'error', message: failureMessage(error, SAVE_FAILED_MESSAGE) }; | |
| 342 | + } | |
| 343 | + | |
| 344 | + revalidatePath(MERCHANT_DETAILS_PATH); | |
| 345 | + revalidatePath(merchantDetailPath(frcsNo)); | |
| 346 | + return { status: 'success' }; | |
| 347 | +} | |
| 348 | + | |
| 349 | +export async function deactivateMerchantDetailsAction( | |
| 350 | + frcsNos: string[] | |
| 351 | +): Promise<MerchantDetailActionResult> { | |
| 352 | + await verifySession(); | |
| 353 | + const targets = frcsNos.filter((value) => typeof value === 'string' && value !== ''); | |
| 354 | + if (targets.length === 0) { | |
| 355 | + return { status: 'error', message: INVALID_REQUEST_MESSAGE }; | |
| 356 | + } | |
| 357 | + | |
| 358 | + try { | |
| 359 | + for (const frcsNo of targets) { | |
| 360 | + await toggleDetailVisibility(frcsNo, false); | |
| 361 | + } | |
| 362 | + } catch (error) { | |
| 363 | + unstable_rethrow(error); | |
| 364 | + console.error('[merchant-details] 일괄 비활성 실패', error); | |
| 365 | + return { status: 'error', message: failureMessage(error, SAVE_FAILED_MESSAGE) }; | |
| 366 | + } | |
| 367 | + | |
| 368 | + revalidatePath(MERCHANT_DETAILS_PATH); | |
| 369 | + return { status: 'success' }; | |
| 370 | +} | |
| 371 | + | |
| 372 | +// ── 메뉴 ────────────────────────────────────────────────────────────────────── | |
| 373 | + | |
| 374 | +function readBadges(formData: FormData): MerchantProductBadge[] { | |
| 375 | + return formData | |
| 376 | + .getAll('badges') | |
| 377 | + .map((entry) => (typeof entry === 'string' ? entry : '')) | |
| 378 | + .filter((value): value is MerchantProductBadge => | |
| 379 | + MERCHANT_PRODUCT_BADGE_OPTIONS.some((option) => option.value === value) | |
| 380 | + ); | |
| 381 | +} | |
| 382 | + | |
| 383 | +async function resolveProductImageId(formData: FormData): Promise<string> { | |
| 384 | + const file = readFiles(formData, 'imageFile')[0] ?? null; | |
| 385 | + if (!file) { | |
| 386 | + return readString(formData, 'imageFileId'); | |
| 387 | + } | |
| 388 | + assertImage(file); | |
| 389 | + return uploadAttachment(file, FILE_MODULE.merchantImage.id); | |
| 390 | +} | |
| 391 | + | |
| 392 | +export async function saveMerchantProductAction( | |
| 393 | + _prevState: MerchantProductFormState, | |
| 394 | + formData: FormData | |
| 395 | +): Promise<MerchantProductFormState> { | |
| 396 | + await verifySession(); | |
| 397 | + | |
| 398 | + const frcsNo = readString(formData, 'frcsNo'); | |
| 399 | + const productSn = readString(formData, 'productSn'); | |
| 400 | + if (!frcsNo) { | |
| 401 | + return { status: 'error', message: INVALID_REQUEST_MESSAGE }; | |
| 402 | + } | |
| 403 | + | |
| 404 | + let failed: MerchantProductFormState | null = null; | |
| 405 | + try { | |
| 406 | + const imageFileId = await resolveProductImageId(formData); | |
| 407 | + const validation = validateMerchantProduct({ | |
| 408 | + frcsNo, | |
| 409 | + name: readString(formData, 'name'), | |
| 410 | + price: readOptionalInteger(formData, 'price'), | |
| 411 | + description: readString(formData, 'description'), | |
| 412 | + badges: readBadges(formData), | |
| 413 | + isVisible: readString(formData, 'isVisible') !== 'N', | |
| 414 | + sortOrder: readOptionalInteger(formData, 'sortOrder'), | |
| 415 | + imageFileId, | |
| 416 | + }); | |
| 417 | + if (!validation.ok) { | |
| 418 | + return { status: 'error', errors: validation.errors }; | |
| 419 | + } | |
| 420 | + | |
| 421 | + if (productSn) { | |
| 422 | + await updateMerchantProduct(productSn, validation.values); | |
| 423 | + } else { | |
| 424 | + await createMerchantProduct(validation.values); | |
| 425 | + } | |
| 426 | + } catch (error) { | |
| 427 | + unstable_rethrow(error); | |
| 428 | + console.error('[merchant-products] 저장 실패', error); | |
| 429 | + failed = { status: 'error', message: failureMessage(error, SAVE_FAILED_MESSAGE) }; | |
| 430 | + } | |
| 431 | + if (failed) { | |
| 432 | + return failed; | |
| 433 | + } | |
| 434 | + | |
| 435 | + revalidatePath(merchantDetailPath(frcsNo)); | |
| 436 | + redirect(merchantDetailPath(frcsNo)); | |
| 437 | +} | |
| 438 | + | |
| 439 | +export async function deleteMerchantProductAction( | |
| 440 | + frcsNo: string, | |
| 441 | + productSn: string | |
| 442 | +): Promise<MerchantDetailActionResult> { | |
| 443 | + await verifySession(); | |
| 444 | + if (!frcsNo || !productSn) { | |
| 445 | + return { status: 'error', message: INVALID_REQUEST_MESSAGE }; | |
| 446 | + } | |
| 447 | + | |
| 448 | + try { | |
| 449 | + await deleteMerchantProduct(productSn); | |
| 450 | + } catch (error) { | |
| 451 | + unstable_rethrow(error); | |
| 452 | + console.error('[merchant-products] 삭제 실패', error); | |
| 453 | + return { status: 'error', message: failureMessage(error, DELETE_FAILED_MESSAGE) }; | |
| 454 | + } | |
| 455 | + | |
| 456 | + revalidatePath(merchantDetailPath(frcsNo)); | |
| 457 | + return { status: 'success' }; | |
| 458 | +} | |
| 459 | + | |
| 460 | +export async function updateMerchantProductVisibilityAction( | |
| 461 | + frcsNo: string, | |
| 462 | + productSn: string, | |
| 463 | + isVisible: boolean | |
| 464 | +): Promise<MerchantDetailActionResult> { | |
| 465 | + await verifySession(); | |
| 466 | + if (!frcsNo || !productSn) { | |
| 467 | + return { status: 'error', message: INVALID_REQUEST_MESSAGE }; | |
| 468 | + } | |
| 469 | + | |
| 470 | + try { | |
| 471 | + const product = await fetchMerchantProduct(productSn); | |
| 472 | + if (!product) { | |
| 473 | + return { status: 'error', message: INVALID_REQUEST_MESSAGE }; | |
| 474 | + } | |
| 475 | + await updateMerchantProduct(productSn, { | |
| 476 | + frcsNo: product.frcsNo, | |
| 477 | + name: product.name, | |
| 478 | + price: product.price, | |
| 479 | + description: product.description ?? '', | |
| 480 | + badges: product.badges, | |
| 481 | + isVisible, | |
| 482 | + sortOrder: product.sortOrder, | |
| 483 | + imageFileId: product.imageFileId ?? '', | |
| 484 | + }); | |
| 485 | + } catch (error) { | |
| 486 | + unstable_rethrow(error); | |
| 487 | + console.error('[merchant-products] 노출 변경 실패', error); | |
| 488 | + return { status: 'error', message: failureMessage(error, SAVE_FAILED_MESSAGE) }; | |
| 489 | + } | |
| 490 | + | |
| 491 | + revalidatePath(merchantDetailPath(frcsNo)); | |
| 492 | + return { status: 'success' }; | |
| 493 | +} |
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-choice-chips.module.scss
... | ... | @@ -0,0 +1,7 @@ |
| 1 | +@use "@fox/styles/abstracts" as fox; | |
| 2 | + | |
| 3 | +.field { | |
| 4 | + display: flex; | |
| 5 | + flex-direction: column; | |
| 6 | + gap: fox.gap(3); | |
| 7 | +} |
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-choice-chips.tsx
... | ... | @@ -0,0 +1,95 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { FoxChip } from '@fox/core/components/fox-chip'; | |
| 4 | +import { FoxChipArea } from '@fox/core/components/fox-chip-area'; | |
| 5 | +import { FoxFormLabel } from '@fox/core/components/fox-form-label'; | |
| 6 | +import { FoxHelperText } from '@fox/core/components/fox-helper-text'; | |
| 7 | +import styles from './merchant-choice-chips.module.scss'; | |
| 8 | + | |
| 9 | +interface ChoiceOption { | |
| 10 | + value: string; | |
| 11 | + label: string; | |
| 12 | +} | |
| 13 | + | |
| 14 | +interface MerchantChoiceChipsProps { | |
| 15 | + label: string; | |
| 16 | + /** 폼에 실릴 이름. 단일 선택은 값 하나, 복수 선택은 고른 순서대로 같은 이름이 반복된다. */ | |
| 17 | + name: string; | |
| 18 | + options: readonly ChoiceOption[]; | |
| 19 | + values: readonly string[]; | |
| 20 | + onChange: (values: string[]) => void; | |
| 21 | + /** 넘기면 복수 선택이 되고 이 개수까지만 고를 수 있다. 없으면 단일 선택이다. */ | |
| 22 | + maxSelected?: number; | |
| 23 | + required?: boolean; | |
| 24 | + optionalText?: string; | |
| 25 | + error?: string; | |
| 26 | +} | |
| 27 | + | |
| 28 | +/** | |
| 29 | + * 기획 BO-MAL-002 ② · BO-MAL-004 ③ — 칩으로 고르는 선택지. 노출 상태·바우처 사용은 단일, | |
| 30 | + * 배지는 복수(잠정 최대 2)다. | |
| 31 | + * | |
| 32 | + * `FoxChip`의 check형은 `aria-pressed` 버튼이라 폼에 실리지 않는다 — 고른 값은 숨은 입력이 | |
| 33 | + * 나른다. 복수 선택은 고른 순서를 지켜 싣는다(배지는 그 순서로 앱에 노출된다). | |
| 34 | + */ | |
| 35 | +export function MerchantChoiceChips({ | |
| 36 | + label, | |
| 37 | + name, | |
| 38 | + options, | |
| 39 | + values, | |
| 40 | + onChange, | |
| 41 | + maxSelected, | |
| 42 | + required, | |
| 43 | + optionalText, | |
| 44 | + error, | |
| 45 | +}: MerchantChoiceChipsProps) { | |
| 46 | + const isMultiple = maxSelected !== undefined; | |
| 47 | + | |
| 48 | + function toggle(value: string, checked: boolean) { | |
| 49 | + if (!isMultiple) { | |
| 50 | + if (checked) onChange([value]); | |
| 51 | + return; | |
| 52 | + } | |
| 53 | + if (!checked) { | |
| 54 | + onChange(values.filter((item) => item !== value)); | |
| 55 | + return; | |
| 56 | + } | |
| 57 | + if (values.includes(value) || values.length >= maxSelected) { | |
| 58 | + return; | |
| 59 | + } | |
| 60 | + onChange([...values, value]); | |
| 61 | + } | |
| 62 | + | |
| 63 | + return ( | |
| 64 | + <div className={styles.field}> | |
| 65 | + <FoxFormLabel | |
| 66 | + as="span" | |
| 67 | + requirement={required ? 'required' : optionalText ? 'optional' : undefined} | |
| 68 | + optionalText={optionalText} | |
| 69 | + > | |
| 70 | + {label} | |
| 71 | + </FoxFormLabel> | |
| 72 | + <FoxChipArea size="md" label={label}> | |
| 73 | + {options.map((option) => { | |
| 74 | + const checked = values.includes(option.value); | |
| 75 | + const isFull = isMultiple && !checked && values.length >= maxSelected; | |
| 76 | + return ( | |
| 77 | + <FoxChip | |
| 78 | + key={option.value} | |
| 79 | + type="check" | |
| 80 | + size="md" | |
| 81 | + label={option.label} | |
| 82 | + checked={checked} | |
| 83 | + disabled={isFull} | |
| 84 | + onCheckedChange={(next) => toggle(option.value, next)} | |
| 85 | + /> | |
| 86 | + ); | |
| 87 | + })} | |
| 88 | + </FoxChipArea> | |
| 89 | + {values.map((value) => ( | |
| 90 | + <input key={value} type="hidden" name={name} value={value} /> | |
| 91 | + ))} | |
| 92 | + {error && <FoxHelperText type="danger" message={error} />} | |
| 93 | + </div> | |
| 94 | + ); | |
| 95 | +} |
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-list.module.scss
... | ... | @@ -0,0 +1,28 @@ |
| 1 | +@use "@fox/styles/abstracts" as fox; | |
| 2 | + | |
| 3 | +.filter { | |
| 4 | + display: flex; | |
| 5 | + flex-wrap: wrap; | |
| 6 | + align-items: center; | |
| 7 | + gap: fox.gap(3); | |
| 8 | +} | |
| 9 | + | |
| 10 | +.select { | |
| 11 | + inline-size: 16rem; | |
| 12 | +} | |
| 13 | + | |
| 14 | +.period { | |
| 15 | + display: flex; | |
| 16 | + align-items: center; | |
| 17 | + gap: fox.gap(2); | |
| 18 | +} | |
| 19 | + | |
| 20 | +// 목록 표의 대표이미지 — 기획 BO-MAL-001의 56×40 썸네일. 토큰 스케일이 96px에서 끝나 | |
| 21 | +// 딱 맞는 값이 없어 리터럴로 두고 근거를 남긴다(꾸미기아이템 썸네일과 같은 처리). | |
| 22 | +.thumbnail { | |
| 23 | + inline-size: 5.6rem; | |
| 24 | + block-size: 4rem; | |
| 25 | + border: fox.border(1) solid fox.color(border-neutral-subtler); | |
| 26 | + border-radius: fox.radius(2); | |
| 27 | + object-fit: cover; | |
| 28 | +} |
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-list.tsx
... | ... | @@ -0,0 +1,362 @@ |
| 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 { FoxButtonGroup } from '@fox/core/components/fox-button-group'; | |
| 7 | +import { FoxCheckbox } from '@fox/core/components/fox-checkbox'; | |
| 8 | +import { FoxInput } from '@fox/core/components/fox-input'; | |
| 9 | +import { | |
| 10 | + FoxListContainer, | |
| 11 | + type FoxListColumn, | |
| 12 | +} from '@fox/core/components/fox-list-container'; | |
| 13 | +import { FoxSelect } from '@fox/core/components/fox-select'; | |
| 14 | +import { FoxSelectText } from '@fox/core/components/fox-select-text'; | |
| 15 | +import { FoxStatusIndicator } from '@fox/core/components/fox-status-indicator'; | |
| 16 | +import { useBlockingAction } from '@/app/_hooks/use-blocking-action'; | |
| 17 | +import { useFeedback } from '@/app/_hooks/use-feedback'; | |
| 18 | +import { formatCommonCode, type CommonCode } from '@/lib/domain/common-code'; | |
| 19 | +import { | |
| 20 | + EMPTY_FIELD_PLACEHOLDER, | |
| 21 | + MERCHANT_DETAIL_VISIBILITY_OPTIONS, | |
| 22 | + formatMerchantDetailStatusLabel, | |
| 23 | + formatOptionalValue, | |
| 24 | + type MerchantDetailSummary, | |
| 25 | +} from '@/lib/domain/merchant-detail'; | |
| 26 | +import { | |
| 27 | + FILTER_ALL, | |
| 28 | + MERCHANT_DETAIL_PAGE_SIZE_OPTIONS, | |
| 29 | + buildMerchantDetailHref, | |
| 30 | + type MerchantDetailPageSize, | |
| 31 | + type MerchantDetailQuery, | |
| 32 | +} from '@/lib/domain/merchant-detail-query'; | |
| 33 | +import { formatPhoneNumber } from '@/lib/domain/phone-number'; | |
| 34 | +import { | |
| 35 | + deactivateMerchantDetailsAction, | |
| 36 | + deleteMerchantDetailsAction, | |
| 37 | +} from '../_actions'; | |
| 38 | +import { MerchantDetailRowActions } from './merchant-detail-row-actions'; | |
| 39 | +import styles from './merchant-detail-list.module.scss'; | |
| 40 | + | |
| 41 | +interface MerchantDetailListProps { | |
| 42 | + items: MerchantDetailSummary[]; | |
| 43 | + categories: CommonCode[]; | |
| 44 | + query: MerchantDetailQuery; | |
| 45 | + currentPage: number; | |
| 46 | + totalPages: number; | |
| 47 | + totalCount: number; | |
| 48 | +} | |
| 49 | + | |
| 50 | +/** | |
| 51 | + * 가맹몰 상세 목록 — 기획 BO-MAL-001을 「시스템관리 > 가맹몰 상세 관리」로 옮긴 것. | |
| 52 | + * | |
| 53 | + * 가맹점 전체가 오고 상세가 붙은 건만 상태·등록일이 있다. 기획의 [+ 업체 등록]은 없다 — | |
| 54 | + * 상세는 기존 가맹점에만 붙어 행의 [상세 등록]으로 들어간다(사용자 확정). | |
| 55 | + * | |
| 56 | + * 이슈: 메뉴 건수 열은 목록 응답에 없어 `-`다(보고함). 카테고리·상태도 백엔드가 아직 주지 | |
| 57 | + * 않아 대부분 `-`로 보인다. | |
| 58 | + */ | |
| 59 | +export function MerchantDetailList({ | |
| 60 | + items, | |
| 61 | + categories, | |
| 62 | + query, | |
| 63 | + currentPage, | |
| 64 | + totalPages, | |
| 65 | + totalCount, | |
| 66 | +}: MerchantDetailListProps) { | |
| 67 | + const router = useRouter(); | |
| 68 | + const { showAlert, hideAlert, showToast } = useFeedback(); | |
| 69 | + const [isBusy, startAction] = useBlockingAction(); | |
| 70 | + const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); | |
| 71 | + const [draft, setDraft] = useState({ | |
| 72 | + category: query.category, | |
| 73 | + visibility: query.visibility, | |
| 74 | + startDate: query.startDate, | |
| 75 | + endDate: query.endDate, | |
| 76 | + }); | |
| 77 | + | |
| 78 | + const selectableIds = items.filter((row) => row.hasDetail).map((row) => row.id); | |
| 79 | + const isAllSelected = | |
| 80 | + selectableIds.length > 0 && selectableIds.every((id) => selectedIds.has(id)); | |
| 81 | + | |
| 82 | + function go(patch: Partial<MerchantDetailQuery>) { | |
| 83 | + router.replace(buildMerchantDetailHref(query, { ...patch, page: 1 })); | |
| 84 | + } | |
| 85 | + | |
| 86 | + function toggleAll(checked: boolean) { | |
| 87 | + setSelectedIds(checked ? new Set(selectableIds) : new Set()); | |
| 88 | + } | |
| 89 | + | |
| 90 | + function toggleOne(id: string, checked: boolean) { | |
| 91 | + setSelectedIds((current) => { | |
| 92 | + const next = new Set(current); | |
| 93 | + if (checked) next.add(id); | |
| 94 | + else next.delete(id); | |
| 95 | + return next; | |
| 96 | + }); | |
| 97 | + } | |
| 98 | + | |
| 99 | + function runBulk( | |
| 100 | + run: (ids: string[]) => Promise<{ status: 'success' } | { status: 'error'; message: string }>, | |
| 101 | + successMessage: string | |
| 102 | + ) { | |
| 103 | + hideAlert(); | |
| 104 | + const ids = [...selectedIds]; | |
| 105 | + startAction(async () => { | |
| 106 | + const result = await run(ids); | |
| 107 | + showToast( | |
| 108 | + result.status === 'error' | |
| 109 | + ? { variant: 'danger', message: result.message } | |
| 110 | + : { variant: 'success', message: successMessage } | |
| 111 | + ); | |
| 112 | + if (result.status === 'success') setSelectedIds(new Set()); | |
| 113 | + }); | |
| 114 | + } | |
| 115 | + | |
| 116 | + function confirmBulkDelete() { | |
| 117 | + showAlert({ | |
| 118 | + variant: 'danger', | |
| 119 | + title: '선택한 상세를 삭제하시겠습니까?', | |
| 120 | + message: `${selectedIds.size}건의 상세 정보를 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`, | |
| 121 | + actions: ( | |
| 122 | + <FoxButtonGroup size="md"> | |
| 123 | + <FoxButton type="default" size="md" label="취소" onAction={hideAlert} /> | |
| 124 | + <FoxButton | |
| 125 | + type="primary" | |
| 126 | + size="md" | |
| 127 | + label="삭제" | |
| 128 | + onAction={() => runBulk(deleteMerchantDetailsAction, '삭제했습니다.')} | |
| 129 | + /> | |
| 130 | + </FoxButtonGroup> | |
| 131 | + ), | |
| 132 | + }); | |
| 133 | + } | |
| 134 | + | |
| 135 | + const columns: FoxListColumn<MerchantDetailSummary>[] = [ | |
| 136 | + { | |
| 137 | + key: 'select', | |
| 138 | + header: ( | |
| 139 | + <FoxCheckbox | |
| 140 | + size="md" | |
| 141 | + label="전체 선택" | |
| 142 | + hideLabel | |
| 143 | + checked={isAllSelected} | |
| 144 | + disabled={selectableIds.length === 0} | |
| 145 | + onChange={toggleAll} | |
| 146 | + /> | |
| 147 | + ), | |
| 148 | + width: 56, | |
| 149 | + align: 'center', | |
| 150 | + render: (row) => | |
| 151 | + row.hasDetail ? ( | |
| 152 | + <FoxCheckbox | |
| 153 | + size="md" | |
| 154 | + label={`${row.name} 선택`} | |
| 155 | + hideLabel | |
| 156 | + checked={selectedIds.has(row.id)} | |
| 157 | + onChange={(checked) => toggleOne(row.id, checked)} | |
| 158 | + /> | |
| 159 | + ) : null, | |
| 160 | + }, | |
| 161 | + { | |
| 162 | + key: 'no', | |
| 163 | + header: 'No', | |
| 164 | + width: 72, | |
| 165 | + align: 'center', | |
| 166 | + render: (_row, index) => (currentPage - 1) * query.pageSize + index + 1, | |
| 167 | + }, | |
| 168 | + { | |
| 169 | + key: 'thumbnail', | |
| 170 | + header: '대표이미지', | |
| 171 | + width: 110, | |
| 172 | + align: 'center', | |
| 173 | + render: (row) => | |
| 174 | + row.thumbnailUrl ? ( | |
| 175 | + // 백엔드의 공개 이미지 GET이라 브라우저가 직접 부른다. `next/image`는 백엔드 호스트를 | |
| 176 | + // next.config에 등록해야 해 여기서는 <img>로 둔다(꾸미기아이템과 같은 판단). | |
| 177 | + // eslint-disable-next-line @next/next/no-img-element | |
| 178 | + <img className={styles.thumbnail} src={row.thumbnailUrl} alt={`${row.name} 대표이미지`} /> | |
| 179 | + ) : ( | |
| 180 | + EMPTY_FIELD_PLACEHOLDER | |
| 181 | + ), | |
| 182 | + }, | |
| 183 | + { key: 'name', header: '업체명', width: 220, emphasis: true }, | |
| 184 | + { | |
| 185 | + key: 'category', | |
| 186 | + header: '카테고리', | |
| 187 | + width: 140, | |
| 188 | + render: (row) => | |
| 189 | + row.categoryCode ? formatCommonCode(categories, row.categoryCode) : EMPTY_FIELD_PLACEHOLDER, | |
| 190 | + }, | |
| 191 | + { | |
| 192 | + key: 'phoneNumber', | |
| 193 | + header: '연락처', | |
| 194 | + width: 150, | |
| 195 | + render: (row) => formatOptionalValue(formatPhoneNumber(row.phoneNumber)), | |
| 196 | + }, | |
| 197 | + { | |
| 198 | + key: 'address', | |
| 199 | + header: '주소', | |
| 200 | + width: 280, | |
| 201 | + render: (row) => formatOptionalValue(row.address), | |
| 202 | + }, | |
| 203 | + { | |
| 204 | + key: 'productCount', | |
| 205 | + header: '메뉴', | |
| 206 | + width: 72, | |
| 207 | + align: 'center', | |
| 208 | + // 목록 응답에 메뉴 건수가 없다(보고함). | |
| 209 | + render: () => EMPTY_FIELD_PLACEHOLDER, | |
| 210 | + }, | |
| 211 | + { | |
| 212 | + key: 'status', | |
| 213 | + header: '상태', | |
| 214 | + width: 110, | |
| 215 | + align: 'center', | |
| 216 | + render: (row) => | |
| 217 | + row.hasDetail && row.isVisible !== null ? ( | |
| 218 | + <FoxStatusIndicator | |
| 219 | + type={row.isVisible ? 'success' : 'danger'} | |
| 220 | + label={formatMerchantDetailStatusLabel(row)} | |
| 221 | + /> | |
| 222 | + ) : ( | |
| 223 | + formatMerchantDetailStatusLabel(row) | |
| 224 | + ), | |
| 225 | + }, | |
| 226 | + { | |
| 227 | + key: 'registeredAt', | |
| 228 | + header: '등록일', | |
| 229 | + width: 120, | |
| 230 | + align: 'center', | |
| 231 | + render: (row) => formatOptionalValue(row.registeredAt), | |
| 232 | + }, | |
| 233 | + { | |
| 234 | + key: 'actions', | |
| 235 | + header: '관리', | |
| 236 | + width: 220, | |
| 237 | + align: 'center', | |
| 238 | + render: (row) => <MerchantDetailRowActions row={row} />, | |
| 239 | + }, | |
| 240 | + ]; | |
| 241 | + | |
| 242 | + return ( | |
| 243 | + <FoxListContainer<MerchantDetailSummary> | |
| 244 | + title="가맹몰 상세 관리" | |
| 245 | + breadcrumb={[ | |
| 246 | + { label: '홈', href: '/' }, | |
| 247 | + { label: '시스템관리' }, | |
| 248 | + { label: '가맹몰 상세 관리' }, | |
| 249 | + ]} | |
| 250 | + caption="가맹몰 상세 목록" | |
| 251 | + columns={columns} | |
| 252 | + rows={items} | |
| 253 | + rowKey={(row) => row.id} | |
| 254 | + totalCount={totalCount} | |
| 255 | + emptyMessage="검색 결과가 없습니다." | |
| 256 | + sorts={ | |
| 257 | + <FoxSelectText | |
| 258 | + size="sm" | |
| 259 | + ariaLabel="페이지 크기" | |
| 260 | + options={MERCHANT_DETAIL_PAGE_SIZE_OPTIONS.map((size) => ({ | |
| 261 | + value: String(size), | |
| 262 | + label: `${size}개씩`, | |
| 263 | + }))} | |
| 264 | + value={String(query.pageSize)} | |
| 265 | + onValueChange={(value) => | |
| 266 | + go({ pageSize: Number(value) as MerchantDetailPageSize }) | |
| 267 | + } | |
| 268 | + /> | |
| 269 | + } | |
| 270 | + filter={ | |
| 271 | + <div className={styles.filter}> | |
| 272 | + <div className={styles.select}> | |
| 273 | + <FoxSelect | |
| 274 | + size="md" | |
| 275 | + ariaLabel="카테고리" | |
| 276 | + options={[ | |
| 277 | + { value: FILTER_ALL, label: '카테고리: 전체' }, | |
| 278 | + ...categories.map((code) => ({ value: code.code, label: code.label })), | |
| 279 | + ]} | |
| 280 | + value={draft.category} | |
| 281 | + onValueChange={(value) => { | |
| 282 | + setDraft((current) => ({ ...current, category: value })); | |
| 283 | + go({ category: value }); | |
| 284 | + }} | |
| 285 | + /> | |
| 286 | + </div> | |
| 287 | + <div className={styles.select}> | |
| 288 | + <FoxSelect | |
| 289 | + size="md" | |
| 290 | + ariaLabel="노출 상태" | |
| 291 | + options={[ | |
| 292 | + { value: FILTER_ALL, label: '노출 상태: 전체' }, | |
| 293 | + ...MERCHANT_DETAIL_VISIBILITY_OPTIONS.map((option) => ({ | |
| 294 | + value: option.value, | |
| 295 | + label: option.label, | |
| 296 | + })), | |
| 297 | + ]} | |
| 298 | + value={draft.visibility} | |
| 299 | + onValueChange={(value) => { | |
| 300 | + setDraft((current) => ({ ...current, visibility: value })); | |
| 301 | + go({ visibility: value }); | |
| 302 | + }} | |
| 303 | + /> | |
| 304 | + </div> | |
| 305 | + {/* 등록일 기간 — 백엔드가 아직 조건에 쓰지 않는다(보고함). 값은 그대로 보낸다. */} | |
| 306 | + <div className={styles.period}> | |
| 307 | + <FoxInput | |
| 308 | + size="md" | |
| 309 | + type="date" | |
| 310 | + aria-label="등록일 시작" | |
| 311 | + value={draft.startDate} | |
| 312 | + onChange={(value) => setDraft((current) => ({ ...current, startDate: value }))} | |
| 313 | + /> | |
| 314 | + <span aria-hidden="true">~</span> | |
| 315 | + <FoxInput | |
| 316 | + size="md" | |
| 317 | + type="date" | |
| 318 | + aria-label="등록일 종료" | |
| 319 | + value={draft.endDate} | |
| 320 | + onChange={(value) => setDraft((current) => ({ ...current, endDate: value }))} | |
| 321 | + /> | |
| 322 | + <FoxButton | |
| 323 | + type="default" | |
| 324 | + size="md" | |
| 325 | + label="기간 적용" | |
| 326 | + onAction={() => go({ startDate: draft.startDate, endDate: draft.endDate })} | |
| 327 | + /> | |
| 328 | + </div> | |
| 329 | + </div> | |
| 330 | + } | |
| 331 | + searchFields={[]} | |
| 332 | + keyword={query.keyword} | |
| 333 | + searchPlaceholder="업체명을 입력하세요." | |
| 334 | + toolbarActions={ | |
| 335 | + <> | |
| 336 | + <FoxButton | |
| 337 | + type="default" | |
| 338 | + size="md" | |
| 339 | + label="선택 비활성" | |
| 340 | + disabled={isBusy || selectedIds.size === 0} | |
| 341 | + onAction={() => runBulk(deactivateMerchantDetailsAction, '비활성으로 바꿨습니다.')} | |
| 342 | + /> | |
| 343 | + <FoxButton | |
| 344 | + type="default" | |
| 345 | + size="md" | |
| 346 | + label="선택 삭제" | |
| 347 | + disabled={isBusy || selectedIds.size === 0} | |
| 348 | + onAction={confirmBulkDelete} | |
| 349 | + /> | |
| 350 | + </> | |
| 351 | + } | |
| 352 | + page={currentPage} | |
| 353 | + totalPages={totalPages} | |
| 354 | + buildHref={(patch) => | |
| 355 | + buildMerchantDetailHref(query, { | |
| 356 | + ...(patch.keyword !== undefined ? { keyword: String(patch.keyword) } : {}), | |
| 357 | + ...(patch.page !== undefined ? { page: patch.page } : {}), | |
| 358 | + }) | |
| 359 | + } | |
| 360 | + /> | |
| 361 | + ); | |
| 362 | +} |
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-row-actions.tsx
... | ... | @@ -0,0 +1,103 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useRouter } from 'next/navigation'; | |
| 4 | +import { FoxButton } from '@fox/core/components/fox-button'; | |
| 5 | +import { FoxButtonGroup } from '@fox/core/components/fox-button-group'; | |
| 6 | +import { useBlockingAction } from '@/app/_hooks/use-blocking-action'; | |
| 7 | +import { useFeedback } from '@/app/_hooks/use-feedback'; | |
| 8 | +import type { MerchantDetailSummary } from '@/lib/domain/merchant-detail'; | |
| 9 | +import { merchantDetailPath } from '@/lib/domain/merchant-detail-query'; | |
| 10 | +import { | |
| 11 | + deleteMerchantDetailAction, | |
| 12 | + updateMerchantDetailVisibilityAction, | |
| 13 | +} from '../_actions'; | |
| 14 | + | |
| 15 | +interface MerchantDetailRowActionsProps { | |
| 16 | + row: MerchantDetailSummary; | |
| 17 | +} | |
| 18 | + | |
| 19 | +/** | |
| 20 | + * 기획 BO-MAL-001 관리 열 — [수정] [비활성|노출] [삭제]. 상세가 없는 가맹점은 [상세 등록] | |
| 21 | + * 하나뿐이다(상세는 기존 가맹점에만 붙는다 — 사용자 확정). | |
| 22 | + */ | |
| 23 | +export function MerchantDetailRowActions({ row }: MerchantDetailRowActionsProps) { | |
| 24 | + const router = useRouter(); | |
| 25 | + const { showAlert, hideAlert, showToast } = useFeedback(); | |
| 26 | + const [isBusy, startAction] = useBlockingAction(); | |
| 27 | + | |
| 28 | + function toggleVisibility() { | |
| 29 | + const next = row.isVisible === false; | |
| 30 | + startAction(async () => { | |
| 31 | + const result = await updateMerchantDetailVisibilityAction(row.id, next); | |
| 32 | + showToast( | |
| 33 | + result.status === 'error' | |
| 34 | + ? { variant: 'danger', message: result.message } | |
| 35 | + : { variant: 'success', message: next ? '노출로 바꿨습니다.' : '비활성으로 바꿨습니다.' } | |
| 36 | + ); | |
| 37 | + }); | |
| 38 | + } | |
| 39 | + | |
| 40 | + function runDelete() { | |
| 41 | + hideAlert(); | |
| 42 | + startAction(async () => { | |
| 43 | + const result = await deleteMerchantDetailAction(row.id); | |
| 44 | + showToast( | |
| 45 | + result.status === 'error' | |
| 46 | + ? { variant: 'danger', message: result.message } | |
| 47 | + : { variant: 'success', message: '삭제했습니다.' } | |
| 48 | + ); | |
| 49 | + }); | |
| 50 | + } | |
| 51 | + | |
| 52 | + function confirmDelete() { | |
| 53 | + showAlert({ | |
| 54 | + variant: 'danger', | |
| 55 | + title: '가맹몰 상세를 삭제하시겠습니까?', | |
| 56 | + message: `"${row.name}"의 상세 정보를 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`, | |
| 57 | + actions: ( | |
| 58 | + <FoxButtonGroup size="md"> | |
| 59 | + <FoxButton type="default" size="md" label="취소" onAction={hideAlert} /> | |
| 60 | + <FoxButton type="primary" size="md" label="삭제" onAction={runDelete} /> | |
| 61 | + </FoxButtonGroup> | |
| 62 | + ), | |
| 63 | + }); | |
| 64 | + } | |
| 65 | + | |
| 66 | + if (!row.hasDetail) { | |
| 67 | + return ( | |
| 68 | + <FoxButtonGroup size="sm" align="center"> | |
| 69 | + <FoxButton | |
| 70 | + type="secondary" | |
| 71 | + size="sm" | |
| 72 | + label="상세 등록" | |
| 73 | + onAction={() => router.push(merchantDetailPath(row.id))} | |
| 74 | + /> | |
| 75 | + </FoxButtonGroup> | |
| 76 | + ); | |
| 77 | + } | |
| 78 | + | |
| 79 | + return ( | |
| 80 | + <FoxButtonGroup size="sm" align="center"> | |
| 81 | + <FoxButton | |
| 82 | + type="default" | |
| 83 | + size="sm" | |
| 84 | + label="수정" | |
| 85 | + onAction={() => router.push(merchantDetailPath(row.id))} | |
| 86 | + /> | |
| 87 | + <FoxButton | |
| 88 | + type="default" | |
| 89 | + size="sm" | |
| 90 | + label={row.isVisible === false ? '노출' : '비활성'} | |
| 91 | + disabled={isBusy} | |
| 92 | + onAction={toggleVisibility} | |
| 93 | + /> | |
| 94 | + <FoxButton | |
| 95 | + type="default" | |
| 96 | + size="sm" | |
| 97 | + label="삭제" | |
| 98 | + disabled={isBusy} | |
| 99 | + onAction={confirmDelete} | |
| 100 | + /> | |
| 101 | + </FoxButtonGroup> | |
| 102 | + ); | |
| 103 | +} |
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-image-field.module.scss
... | ... | @@ -0,0 +1,67 @@ |
| 1 | +@use "@fox/styles/abstracts" as fox; | |
| 2 | + | |
| 3 | +.root { | |
| 4 | + display: flex; | |
| 5 | + flex-direction: column; | |
| 6 | + gap: fox.gap(3); | |
| 7 | +} | |
| 8 | + | |
| 9 | +.tiles { | |
| 10 | + display: flex; | |
| 11 | + flex-wrap: wrap; | |
| 12 | + align-items: flex-start; | |
| 13 | + gap: fox.gap(4); | |
| 14 | +} | |
| 15 | + | |
| 16 | +.thumbnail, | |
| 17 | +.slot { | |
| 18 | + display: flex; | |
| 19 | + flex-direction: column; | |
| 20 | + gap: fox.gap(2); | |
| 21 | +} | |
| 22 | + | |
| 23 | +.slotFooter { | |
| 24 | + display: flex; | |
| 25 | + align-items: center; | |
| 26 | + justify-content: space-between; | |
| 27 | + gap: fox.gap(2); | |
| 28 | +} | |
| 29 | + | |
| 30 | +.caption { | |
| 31 | + font-size: fox.font-size(label-sm); | |
| 32 | + color: fox.color(font-neutral-default); | |
| 33 | +} | |
| 34 | + | |
| 35 | +.required { | |
| 36 | + color: fox.color(font-system-danger); | |
| 37 | +} | |
| 38 | + | |
| 39 | +// 「+ 추가」 타일 — 기획 BO-MAL-002의 160×160 점선 상자. 크기는 옆 이미지 칸과 같아야 줄이 | |
| 40 | +// 맞는다(토큰 스케일이 96px에서 끝나 리터럴로 둔다, 꾸미기아이템 썸네일과 같은 처리). | |
| 41 | +.addTile { | |
| 42 | + display: flex; | |
| 43 | + inline-size: 16rem; | |
| 44 | + block-size: 16rem; | |
| 45 | + flex-direction: column; | |
| 46 | + align-items: center; | |
| 47 | + justify-content: center; | |
| 48 | + gap: fox.gap(2); | |
| 49 | + padding: 0; | |
| 50 | + margin: 0; | |
| 51 | + border: fox.border(1) dashed fox.color(border-neutral-subtle); | |
| 52 | + border-radius: fox.radius(3); | |
| 53 | + background: fox.color(surface-neutral-gray); | |
| 54 | + color: fox.color(font-neutral-default); | |
| 55 | + font-size: fox.font-size(label-md); | |
| 56 | + cursor: pointer; | |
| 57 | + appearance: none; | |
| 58 | + | |
| 59 | + &:hover { | |
| 60 | + background: fox.color(action-default-hover); | |
| 61 | + } | |
| 62 | +} | |
| 63 | + | |
| 64 | +.addHint { | |
| 65 | + font-size: fox.font-size(label-xsm); | |
| 66 | + color: fox.color(font-neutral-subtle); | |
| 67 | +} |
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-image-field.tsx
... | ... | @@ -0,0 +1,147 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useState } from 'react'; | |
| 4 | +import { FoxFileUpload } from '@fox/core/components/fox-file-upload'; | |
| 5 | +import { FoxHelperText } from '@fox/core/components/fox-helper-text'; | |
| 6 | +import { FoxSortableList } from '@fox/core/components/fox-sortable-list'; | |
| 7 | +import { FoxTextButton } from '@fox/core/components/fox-text-button'; | |
| 8 | +import { FILE_MODULE, fileModuleAccept } from '@/lib/domain/file-module'; | |
| 9 | +import { | |
| 10 | + MAX_EXTRA_IMAGES, | |
| 11 | + type MerchantImageSlot, | |
| 12 | +} from '@/lib/domain/merchant-detail'; | |
| 13 | +import styles from './merchant-image-field.module.scss'; | |
| 14 | + | |
| 15 | +interface MerchantImageFieldProps { | |
| 16 | + defaultThumbnailUrl: string | null; | |
| 17 | + retainedThumbnailId: string | null; | |
| 18 | + defaultImageUrls: readonly string[]; | |
| 19 | + retainedImageFileId: string | null; | |
| 20 | + thumbnailError?: string; | |
| 21 | + imagesError?: string; | |
| 22 | +} | |
| 23 | + | |
| 24 | +const ACCEPT = fileModuleAccept(FILE_MODULE.merchantImage); | |
| 25 | +const IMAGE_HINT = 'JPG · PNG / 5MB 이하'; | |
| 26 | + | |
| 27 | +/** | |
| 28 | + * 기획 BO-MAL-002 ⑤ — 대표 1장 필수 + 추가 최대 11장, 추가 이미지는 드래그로 순서를 바꾼다. | |
| 29 | + * | |
| 30 | + * 추가 이미지는 칸마다 `imageSlot`(`saved:<fileSn>` | `new`)과 `imageFile`을 같은 순서로 | |
| 31 | + * 싣는다. 액션은 이 둘을 자리로 짝지어 묶음을 다시 만든다 — 저장된 장은 백엔드에서 다시 읽고 | |
| 32 | + * 새 장은 그대로 올린다. 그래서 순서·삭제·추가 어느 것이든 폼 한 번으로 끝난다. | |
| 33 | + * | |
| 34 | + * 비제어 `FoxFileUpload`를 칸마다 두고 React key로 마운트를 관리한다(4컷만화와 같은 방식). | |
| 35 | + */ | |
| 36 | +export function MerchantImageField({ | |
| 37 | + defaultThumbnailUrl, | |
| 38 | + retainedThumbnailId, | |
| 39 | + defaultImageUrls, | |
| 40 | + retainedImageFileId, | |
| 41 | + thumbnailError, | |
| 42 | + imagesError, | |
| 43 | +}: MerchantImageFieldProps) { | |
| 44 | + const [slots, setSlots] = useState<MerchantImageSlot[]>(() => | |
| 45 | + defaultImageUrls.map((url, index) => ({ | |
| 46 | + key: `saved-${index + 1}`, | |
| 47 | + previewUrl: url, | |
| 48 | + fileSn: index + 1, | |
| 49 | + })) | |
| 50 | + ); | |
| 51 | + const [nextKey, setNextKey] = useState(0); | |
| 52 | + | |
| 53 | + function addSlot() { | |
| 54 | + if (slots.length >= MAX_EXTRA_IMAGES) return; | |
| 55 | + setSlots((current) => [ | |
| 56 | + ...current, | |
| 57 | + { key: `new-${nextKey}`, previewUrl: null, fileSn: null }, | |
| 58 | + ]); | |
| 59 | + setNextKey((current) => current + 1); | |
| 60 | + } | |
| 61 | + | |
| 62 | + function removeSlot(key: string) { | |
| 63 | + setSlots((current) => current.filter((slot) => slot.key !== key)); | |
| 64 | + } | |
| 65 | + | |
| 66 | + return ( | |
| 67 | + <div className={styles.root}> | |
| 68 | + <div className={styles.tiles}> | |
| 69 | + <div className={styles.thumbnail}> | |
| 70 | + <FoxFileUpload | |
| 71 | + mode="image" | |
| 72 | + name="thumbnailFile" | |
| 73 | + accept={ACCEPT} | |
| 74 | + thumbnailPicker | |
| 75 | + selectLabel="대표이미지 등록" | |
| 76 | + description={IMAGE_HINT} | |
| 77 | + thumbnailWidth={220} | |
| 78 | + thumbnailHeight={160} | |
| 79 | + defaultFiles={ | |
| 80 | + defaultThumbnailUrl | |
| 81 | + ? [{ id: 'thumbnail', name: '대표이미지', previewUrl: defaultThumbnailUrl }] | |
| 82 | + : undefined | |
| 83 | + } | |
| 84 | + /> | |
| 85 | + <input type="hidden" name="thumbnailFileId" value={retainedThumbnailId ?? ''} /> | |
| 86 | + <span className={styles.caption}> | |
| 87 | + 대표이미지 <span className={styles.required}>*</span> | |
| 88 | + </span> | |
| 89 | + {thumbnailError && <FoxHelperText type="danger" message={thumbnailError} />} | |
| 90 | + </div> | |
| 91 | + | |
| 92 | + <input type="hidden" name="imageFileId" value={retainedImageFileId ?? ''} /> | |
| 93 | + <FoxSortableList | |
| 94 | + items={slots} | |
| 95 | + getKey={(slot) => slot.key} | |
| 96 | + onReorder={setSlots} | |
| 97 | + ariaLabel="추가 이미지 순서" | |
| 98 | + renderItem={(slot, index) => ( | |
| 99 | + <div className={styles.slot}> | |
| 100 | + <FoxFileUpload | |
| 101 | + mode="image" | |
| 102 | + name="imageFile" | |
| 103 | + accept={ACCEPT} | |
| 104 | + thumbnailPicker | |
| 105 | + selectLabel={`추가 이미지 ${index + 1} 선택`} | |
| 106 | + thumbnailWidth={160} | |
| 107 | + thumbnailHeight={160} | |
| 108 | + defaultFiles={ | |
| 109 | + slot.previewUrl | |
| 110 | + ? [{ id: slot.key, name: `추가 이미지 ${index + 1}`, previewUrl: slot.previewUrl }] | |
| 111 | + : undefined | |
| 112 | + } | |
| 113 | + /> | |
| 114 | + <input | |
| 115 | + type="hidden" | |
| 116 | + name="imageSlot" | |
| 117 | + value={slot.fileSn === null ? 'new' : `saved:${slot.fileSn}`} | |
| 118 | + /> | |
| 119 | + <span className={styles.slotFooter}> | |
| 120 | + <span className={styles.caption}>추가 이미지 {index + 1}</span> | |
| 121 | + <FoxTextButton | |
| 122 | + size="md" | |
| 123 | + type="secondary" | |
| 124 | + leadingIcon={<i className="fox-ico fox-ico-Trash" aria-hidden="true" />} | |
| 125 | + label="삭제" | |
| 126 | + onAction={() => removeSlot(slot.key)} | |
| 127 | + /> | |
| 128 | + </span> | |
| 129 | + </div> | |
| 130 | + )} | |
| 131 | + /> | |
| 132 | + | |
| 133 | + {slots.length < MAX_EXTRA_IMAGES && ( | |
| 134 | + <button type="button" className={styles.addTile} onClick={addSlot}> | |
| 135 | + <i className="fox-ico fox-ico-Plus" aria-hidden="true" /> | |
| 136 | + <span>추가</span> | |
| 137 | + <span className={styles.addHint}> | |
| 138 | + {slots.length}/{MAX_EXTRA_IMAGES} | |
| 139 | + </span> | |
| 140 | + </button> | |
| 141 | + )} | |
| 142 | + </div> | |
| 143 | + | |
| 144 | + {imagesError && <FoxHelperText type="danger" message={imagesError} />} | |
| 145 | + </div> | |
| 146 | + ); | |
| 147 | +} |
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-product-row-actions.tsx
... | ... | @@ -0,0 +1,91 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useRouter } from 'next/navigation'; | |
| 4 | +import { FoxButton } from '@fox/core/components/fox-button'; | |
| 5 | +import { FoxButtonGroup } from '@fox/core/components/fox-button-group'; | |
| 6 | +import { useBlockingAction } from '@/app/_hooks/use-blocking-action'; | |
| 7 | +import { useFeedback } from '@/app/_hooks/use-feedback'; | |
| 8 | +import { merchantProductPath } from '@/lib/domain/merchant-detail-query'; | |
| 9 | +import type { MerchantProduct } from '@/lib/domain/merchant-product'; | |
| 10 | +import { | |
| 11 | + deleteMerchantProductAction, | |
| 12 | + updateMerchantProductVisibilityAction, | |
| 13 | +} from '../_actions'; | |
| 14 | + | |
| 15 | +interface MerchantProductRowActionsProps { | |
| 16 | + product: MerchantProduct; | |
| 17 | +} | |
| 18 | + | |
| 19 | +/** 기획 BO-MAL-002 ⑥ — 메뉴 행의 [수정] [숨김|노출] [삭제]. 수정은 메뉴 화면으로 간다. */ | |
| 20 | +export function MerchantProductRowActions({ product }: MerchantProductRowActionsProps) { | |
| 21 | + const router = useRouter(); | |
| 22 | + const { showAlert, hideAlert, showToast } = useFeedback(); | |
| 23 | + const [isBusy, startAction] = useBlockingAction(); | |
| 24 | + | |
| 25 | + function toggleVisibility() { | |
| 26 | + const next = !product.isVisible; | |
| 27 | + startAction(async () => { | |
| 28 | + const result = await updateMerchantProductVisibilityAction( | |
| 29 | + product.frcsNo, | |
| 30 | + product.id, | |
| 31 | + next | |
| 32 | + ); | |
| 33 | + showToast( | |
| 34 | + result.status === 'error' | |
| 35 | + ? { variant: 'danger', message: result.message } | |
| 36 | + : { variant: 'success', message: next ? '노출로 바꿨습니다.' : '숨겼습니다.' } | |
| 37 | + ); | |
| 38 | + }); | |
| 39 | + } | |
| 40 | + | |
| 41 | + function runDelete() { | |
| 42 | + hideAlert(); | |
| 43 | + startAction(async () => { | |
| 44 | + const result = await deleteMerchantProductAction(product.frcsNo, product.id); | |
| 45 | + showToast( | |
| 46 | + result.status === 'error' | |
| 47 | + ? { variant: 'danger', message: result.message } | |
| 48 | + : { variant: 'success', message: '메뉴를 삭제했습니다.' } | |
| 49 | + ); | |
| 50 | + }); | |
| 51 | + } | |
| 52 | + | |
| 53 | + function confirmDelete() { | |
| 54 | + showAlert({ | |
| 55 | + variant: 'danger', | |
| 56 | + title: '메뉴를 삭제하시겠습니까?', | |
| 57 | + message: `"${product.name}" 메뉴를 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`, | |
| 58 | + actions: ( | |
| 59 | + <FoxButtonGroup size="md"> | |
| 60 | + <FoxButton type="default" size="md" label="취소" onAction={hideAlert} /> | |
| 61 | + <FoxButton type="primary" size="md" label="삭제" onAction={runDelete} /> | |
| 62 | + </FoxButtonGroup> | |
| 63 | + ), | |
| 64 | + }); | |
| 65 | + } | |
| 66 | + | |
| 67 | + return ( | |
| 68 | + <FoxButtonGroup size="sm" align="center"> | |
| 69 | + <FoxButton | |
| 70 | + type="default" | |
| 71 | + size="sm" | |
| 72 | + label="수정" | |
| 73 | + onAction={() => router.push(merchantProductPath(product.frcsNo, product.id))} | |
| 74 | + /> | |
| 75 | + <FoxButton | |
| 76 | + type="default" | |
| 77 | + size="sm" | |
| 78 | + label={product.isVisible ? '숨김' : '노출'} | |
| 79 | + disabled={isBusy} | |
| 80 | + onAction={toggleVisibility} | |
| 81 | + /> | |
| 82 | + <FoxButton | |
| 83 | + type="default" | |
| 84 | + size="sm" | |
| 85 | + label="삭제" | |
| 86 | + disabled={isBusy} | |
| 87 | + onAction={confirmDelete} | |
| 88 | + /> | |
| 89 | + </FoxButtonGroup> | |
| 90 | + ); | |
| 91 | +} |
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-product-table.module.scss
... | ... | @@ -0,0 +1,53 @@ |
| 1 | +@use "@fox/styles/abstracts" as fox; | |
| 2 | + | |
| 3 | +.root { | |
| 4 | + display: flex; | |
| 5 | + flex-direction: column; | |
| 6 | + gap: fox.gap(4); | |
| 7 | +} | |
| 8 | + | |
| 9 | +.toolbar { | |
| 10 | + display: flex; | |
| 11 | + align-items: center; | |
| 12 | + justify-content: space-between; | |
| 13 | + gap: fox.gap(3); | |
| 14 | +} | |
| 15 | + | |
| 16 | +.count { | |
| 17 | + font-size: fox.font-size(label-md); | |
| 18 | + color: fox.color(font-neutral-default); | |
| 19 | +} | |
| 20 | + | |
| 21 | +.hint { | |
| 22 | + margin: 0; | |
| 23 | + font-size: fox.font-size(label-sm); | |
| 24 | + color: fox.color(font-neutral-subtle); | |
| 25 | +} | |
| 26 | + | |
| 27 | +.table { | |
| 28 | + inline-size: 100%; | |
| 29 | + overflow-x: auto; | |
| 30 | +} | |
| 31 | + | |
| 32 | +// 기획 BO-MAL-002 메뉴 표의 56×40 썸네일(목록 대표이미지와 같은 크기·같은 근거). | |
| 33 | +.thumbnail { | |
| 34 | + inline-size: 5.6rem; | |
| 35 | + block-size: 4rem; | |
| 36 | + border: fox.border(1) solid fox.color(border-neutral-subtler); | |
| 37 | + border-radius: fox.radius(2); | |
| 38 | + object-fit: cover; | |
| 39 | +} | |
| 40 | + | |
| 41 | +.description { | |
| 42 | + display: -webkit-box; | |
| 43 | + overflow: hidden; | |
| 44 | + -webkit-box-orient: vertical; | |
| 45 | + -webkit-line-clamp: 2; | |
| 46 | +} | |
| 47 | + | |
| 48 | +.badges { | |
| 49 | + display: inline-flex; | |
| 50 | + flex-wrap: wrap; | |
| 51 | + justify-content: center; | |
| 52 | + gap: fox.gap(2); | |
| 53 | +} |
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-product-table.tsx
... | ... | @@ -0,0 +1,124 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useRouter } from 'next/navigation'; | |
| 4 | +import { FoxBadge } from '@fox/core/components/fox-badge'; | |
| 5 | +import { FoxButton } from '@fox/core/components/fox-button'; | |
| 6 | +import { FoxStatusIndicator } from '@fox/core/components/fox-status-indicator'; | |
| 7 | +import { FoxTable } from '@fox/core/components/fox-table'; | |
| 8 | +import { FoxTableCell } from '@fox/core/components/fox-table-cell'; | |
| 9 | +import { FoxTableRow } from '@fox/core/components/fox-table-row'; | |
| 10 | +import { EMPTY_FIELD_PLACEHOLDER } from '@/lib/domain/merchant-detail'; | |
| 11 | +import { merchantProductNewPath } from '@/lib/domain/merchant-detail-query'; | |
| 12 | +import { | |
| 13 | + formatProductBadgeLabel, | |
| 14 | + formatProductPrice, | |
| 15 | + formatProductVisibilityLabel, | |
| 16 | + type MerchantProduct, | |
| 17 | +} from '@/lib/domain/merchant-product'; | |
| 18 | +import { MerchantProductRowActions } from './merchant-product-row-actions'; | |
| 19 | +import styles from './merchant-product-table.module.scss'; | |
| 20 | + | |
| 21 | +interface MerchantProductTableProps { | |
| 22 | + frcsNo: string; | |
| 23 | + products: MerchantProduct[]; | |
| 24 | + /** 상세가 아직 저장되지 않은 가맹점 — 백엔드가 메뉴 등록을 거부하므로 추가를 막는다. */ | |
| 25 | + canAdd: boolean; | |
| 26 | +} | |
| 27 | + | |
| 28 | +/** | |
| 29 | + * 기획 BO-MAL-002 ⑥ — 메뉴는 목록으로만 보이고 등록·수정은 별도 화면에서 한다. | |
| 30 | + * 목록 화면 뼈대(FoxListContainer)는 머리말·검색까지 딸려 와 카드 안에는 표 조각만 놓는다. | |
| 31 | + */ | |
| 32 | +export function MerchantProductTable({ frcsNo, products, canAdd }: MerchantProductTableProps) { | |
| 33 | + const router = useRouter(); | |
| 34 | + | |
| 35 | + return ( | |
| 36 | + <div className={styles.root}> | |
| 37 | + <div className={styles.toolbar}> | |
| 38 | + <span className={styles.count}>등록 메뉴 {products.length}건</span> | |
| 39 | + <FoxButton | |
| 40 | + type="primary" | |
| 41 | + size="md" | |
| 42 | + leadingIcon={<i className="fox-ico fox-ico-Plus" aria-hidden="true" />} | |
| 43 | + label="메뉴 추가" | |
| 44 | + disabled={!canAdd} | |
| 45 | + onAction={() => router.push(merchantProductNewPath(frcsNo))} | |
| 46 | + /> | |
| 47 | + </div> | |
| 48 | + {!canAdd && ( | |
| 49 | + <p className={styles.hint}>상세를 먼저 저장하면 메뉴를 추가할 수 있습니다.</p> | |
| 50 | + )} | |
| 51 | + | |
| 52 | + <div className={styles.table}> | |
| 53 | + <FoxTable type="list" caption="메뉴 목록"> | |
| 54 | + <FoxTableRow head> | |
| 55 | + <FoxTableCell head width={64} align="center">순서</FoxTableCell> | |
| 56 | + <FoxTableCell head width={96} align="center">이미지</FoxTableCell> | |
| 57 | + <FoxTableCell head width={240}>메뉴명</FoxTableCell> | |
| 58 | + <FoxTableCell head>설명</FoxTableCell> | |
| 59 | + <FoxTableCell head width={140} align="center">가격</FoxTableCell> | |
| 60 | + <FoxTableCell head width={180} align="center">배지</FoxTableCell> | |
| 61 | + <FoxTableCell head width={96} align="center">노출</FoxTableCell> | |
| 62 | + <FoxTableCell head width={220} align="center">관리</FoxTableCell> | |
| 63 | + </FoxTableRow> | |
| 64 | + | |
| 65 | + {products.length === 0 ? ( | |
| 66 | + <FoxTableRow> | |
| 67 | + <FoxTableCell colSpan={8} align="center"> | |
| 68 | + 등록된 메뉴가 없습니다. | |
| 69 | + </FoxTableCell> | |
| 70 | + </FoxTableRow> | |
| 71 | + ) : ( | |
| 72 | + products.map((product, index) => ( | |
| 73 | + <FoxTableRow key={product.id}> | |
| 74 | + <FoxTableCell align="center" label="순서"> | |
| 75 | + {product.sortOrder ?? index + 1} | |
| 76 | + </FoxTableCell> | |
| 77 | + <FoxTableCell align="center" label="이미지"> | |
| 78 | + {product.imageUrl ? ( | |
| 79 | + // 백엔드의 공개 이미지 GET — 목록 대표이미지와 같은 이유로 <img>를 쓴다. | |
| 80 | + // eslint-disable-next-line @next/next/no-img-element | |
| 81 | + <img className={styles.thumbnail} src={product.imageUrl} alt={`${product.name} 이미지`} /> | |
| 82 | + ) : ( | |
| 83 | + EMPTY_FIELD_PLACEHOLDER | |
| 84 | + )} | |
| 85 | + </FoxTableCell> | |
| 86 | + <FoxTableCell emphasis label="메뉴명">{product.name}</FoxTableCell> | |
| 87 | + <FoxTableCell label="설명"> | |
| 88 | + <span className={styles.description}> | |
| 89 | + {product.description ?? EMPTY_FIELD_PLACEHOLDER} | |
| 90 | + </span> | |
| 91 | + </FoxTableCell> | |
| 92 | + <FoxTableCell align="center" label="가격"> | |
| 93 | + {formatProductPrice(product.price)} | |
| 94 | + </FoxTableCell> | |
| 95 | + <FoxTableCell align="center" label="배지"> | |
| 96 | + {product.badges.length === 0 ? ( | |
| 97 | + EMPTY_FIELD_PLACEHOLDER | |
| 98 | + ) : ( | |
| 99 | + <span className={styles.badges}> | |
| 100 | + {product.badges.map((badge) => ( | |
| 101 | + <FoxBadge key={badge} type="pastel" color="neutral" size="sm"> | |
| 102 | + {formatProductBadgeLabel(badge)} | |
| 103 | + </FoxBadge> | |
| 104 | + ))} | |
| 105 | + </span> | |
| 106 | + )} | |
| 107 | + </FoxTableCell> | |
| 108 | + <FoxTableCell align="center" label="노출"> | |
| 109 | + <FoxStatusIndicator | |
| 110 | + type={product.isVisible ? 'success' : 'default'} | |
| 111 | + label={formatProductVisibilityLabel(product.isVisible)} | |
| 112 | + /> | |
| 113 | + </FoxTableCell> | |
| 114 | + <FoxTableCell align="center" label="관리"> | |
| 115 | + <MerchantProductRowActions product={product} /> | |
| 116 | + </FoxTableCell> | |
| 117 | + </FoxTableRow> | |
| 118 | + )) | |
| 119 | + )} | |
| 120 | + </FoxTable> | |
| 121 | + </div> | |
| 122 | + </div> | |
| 123 | + ); | |
| 124 | +} |
+++ app/(protected)/(basic)/system/merchant-details/page.tsx
... | ... | @@ -0,0 +1,38 @@ |
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { verifySession } from '@/lib/auth/dal'; | |
| 3 | +import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository'; | |
| 4 | +import { fetchMerchantDetails } from '@/lib/data/repositories/merchant-detail-repository'; | |
| 5 | +import { CODE_GROUP } from '@/lib/domain/common-code'; | |
| 6 | +import { parseMerchantDetailQuery } from '@/lib/domain/merchant-detail-query'; | |
| 7 | +import { MerchantDetailList } from './_components/merchant-detail-list'; | |
| 8 | + | |
| 9 | +export const metadata: Metadata = { title: '가맹몰 상세 관리' }; | |
| 10 | +export const dynamic = 'force-dynamic'; | |
| 11 | + | |
| 12 | +interface PageProps { | |
| 13 | + searchParams: Promise<Record<string, string | string[] | undefined>>; | |
| 14 | +} | |
| 15 | + | |
| 16 | +export default async function Page({ searchParams }: PageProps) { | |
| 17 | + await verifySession(); | |
| 18 | + | |
| 19 | + const query = parseMerchantDetailQuery(await searchParams); | |
| 20 | + const [{ items, totalCount }, categories] = await Promise.all([ | |
| 21 | + fetchMerchantDetails(query), | |
| 22 | + fetchCommonCodes(CODE_GROUP.merchantType), | |
| 23 | + ]); | |
| 24 | + | |
| 25 | + const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize)); | |
| 26 | + const currentPage = Math.min(query.page, totalPages); | |
| 27 | + | |
| 28 | + return ( | |
| 29 | + <MerchantDetailList | |
| 30 | + items={items} | |
| 31 | + categories={categories} | |
| 32 | + query={query} | |
| 33 | + currentPage={currentPage} | |
| 34 | + totalPages={totalPages} | |
| 35 | + totalCount={totalCount} | |
| 36 | + /> | |
| 37 | + ); | |
| 38 | +} |
+++ lib/data/repositories/merchant-detail-repository.ts
... | ... | @@ -0,0 +1,264 @@ |
| 1 | +import 'server-only'; | |
| 2 | +import { getSessionAccessToken } from '@/lib/auth/dal'; | |
| 3 | +import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch'; | |
| 4 | +import { buildFileImageUrl } from '@/lib/data/repositories/file-repository'; | |
| 5 | +import { | |
| 6 | + MAX_EXTRA_IMAGES, | |
| 7 | + type MerchantDetail, | |
| 8 | + type MerchantDetailSummary, | |
| 9 | +} from '@/lib/domain/merchant-detail'; | |
| 10 | +import type { MerchantDetailValues } from '@/lib/domain/merchant-detail-form'; | |
| 11 | +import type { MerchantDetailQuery } from '@/lib/domain/merchant-detail-query'; | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * 가맹몰 상세 Repository — edupay-backend origin/develop 15d409a·22f67cb 기준. | |
| 15 | + * | |
| 16 | + * ``` | |
| 17 | + * GET /api/v1/mngr/voc/detail/pagination TB_COM_VOUCHER ⟕ TB_COM_VOUCHER_DTL — 가맹점 전체 | |
| 18 | + * GET /api/v1/mngr/voc/detail/{frcsNo} | |
| 19 | + * POST /api/v1/mngr/voc/detail @RequestBody · 가맹점이 있어야 붙는다 | |
| 20 | + * PUT /api/v1/mngr/voc/detail/{frcsNo} @RequestBody | |
| 21 | + * DELETE /api/v1/mngr/voc/{frcsNo} 논리 삭제(DEL_YN) | |
| 22 | + * ``` | |
| 23 | + * | |
| 24 | + * ⚠️ 백엔드 결함(보고함, 미수정) — 화면은 시안대로 두고 값을 그대로 보낸다(사용자 확정): | |
| 25 | + * 1. `MngrVocDetailVo`에 `useYn`이 없어 노출 상태가 등록·수정에서 유실되고 조회에도 안 온다. | |
| 26 | + * 2. 단건 SELECT에 소개·카테고리·영업시간·휴무일·추가이미지·순서가 없어 수정 프리필이 빈다. | |
| 27 | + * 3. 목록에 카테고리 코드·이름이 없다. | |
| 28 | + * 4. `searchStartDt/EndDt`를 VO만 받고 WHERE에 쓰지 않는다. | |
| 29 | + * 5. 「바우처 사용」을 담을 필드가 없다 — `voucherUseYn`으로 보내되 버려진다. | |
| 30 | + */ | |
| 31 | + | |
| 32 | +const DETAIL_PATH = '/api/v1/mngr/voc/detail'; | |
| 33 | +const VOC_PATH = '/api/v1/mngr/voc'; | |
| 34 | + | |
| 35 | +function isRecord(value: unknown): value is Record<string, unknown> { | |
| 36 | + return value !== null && typeof value === 'object'; | |
| 37 | +} | |
| 38 | + | |
| 39 | +function readString(source: Record<string, unknown>, key: string): string | null { | |
| 40 | + const value = source[key]; | |
| 41 | + if (typeof value === 'string' && value !== '') return value; | |
| 42 | + if (typeof value === 'number') return String(value); | |
| 43 | + return null; | |
| 44 | +} | |
| 45 | + | |
| 46 | +function readNumber(source: Record<string, unknown>, key: string): number | null { | |
| 47 | + const value = source[key]; | |
| 48 | + if (typeof value === 'number' && Number.isFinite(value)) return value; | |
| 49 | + if (typeof value === 'string' && value.trim() !== '') { | |
| 50 | + const parsed = Number(value); | |
| 51 | + return Number.isFinite(parsed) ? parsed : null; | |
| 52 | + } | |
| 53 | + return null; | |
| 54 | +} | |
| 55 | + | |
| 56 | +function readYesNo(source: Record<string, unknown>, key: string): boolean | null { | |
| 57 | + const value = readString(source, key); | |
| 58 | + return value === null ? null : value.toUpperCase() === 'Y'; | |
| 59 | +} | |
| 60 | + | |
| 61 | +/** | |
| 62 | + * 등록일은 `d.FRST_REG_DT`가 별칭 없이 오므로 `frstRegDt`(Date → ISO 문자열 또는 epoch)로 | |
| 63 | + * 받는다. 다른 화면의 `frstRegDtStr`가 있으면 그것을 우선한다. | |
| 64 | + */ | |
| 65 | +function readDate(source: Record<string, unknown>, key: string): string | null { | |
| 66 | + const formatted = readString(source, `${key}Str`); | |
| 67 | + if (formatted) return formatted.slice(0, 10); | |
| 68 | + | |
| 69 | + const raw = source[key]; | |
| 70 | + const date = | |
| 71 | + typeof raw === 'number' | |
| 72 | + ? new Date(raw) | |
| 73 | + : typeof raw === 'string' && raw !== '' | |
| 74 | + ? new Date(raw) | |
| 75 | + : null; | |
| 76 | + if (date === null || Number.isNaN(date.getTime())) return null; | |
| 77 | + return date.toISOString().slice(0, 10); | |
| 78 | +} | |
| 79 | + | |
| 80 | +function toSummary(raw: unknown): MerchantDetailSummary[] { | |
| 81 | + if (!isRecord(raw)) return []; | |
| 82 | + const id = readString(raw, 'frcsNo'); | |
| 83 | + const name = readString(raw, 'frcsNm'); | |
| 84 | + if (!id || !name) return []; | |
| 85 | + | |
| 86 | + const registeredAt = readDate(raw, 'frstRegDt'); | |
| 87 | + return [ | |
| 88 | + { | |
| 89 | + id, | |
| 90 | + name, | |
| 91 | + phoneNumber: readString(raw, 'rprsvTelno'), | |
| 92 | + address: readString(raw, 'frcsPostAddr'), | |
| 93 | + addressDetail: readString(raw, 'frcsDaddr'), | |
| 94 | + thumbnailFileId: readString(raw, 'thumbAtchFileId'), | |
| 95 | + thumbnailUrl: buildFileImageUrl(readString(raw, 'thumbAtchFileId')), | |
| 96 | + categoryCode: readString(raw, 'frcsTypeCd'), | |
| 97 | + isVisible: readYesNo(raw, 'useYn'), | |
| 98 | + registeredAt, | |
| 99 | + // 상세 행은 LEFT JOIN이라 없으면 상세 컬럼이 전부 null이다 — 등록일이 그 표지다. | |
| 100 | + hasDetail: registeredAt !== null || readString(raw, 'thumbAtchFileId') !== null, | |
| 101 | + }, | |
| 102 | + ]; | |
| 103 | +} | |
| 104 | + | |
| 105 | +/** | |
| 106 | + * 묶음 안의 이미지 수를 알려 주는 API가 없다(보고함). fileSn이 1부터 빈틈없이 붙으므로 | |
| 107 | + * 없는 번호가 나올 때까지 HEAD로 더듬는다 — 없는 번호는 백엔드가 500으로 답한다. | |
| 108 | + */ | |
| 109 | +async function probeImageUrls(fileId: string | null): Promise<string[]> { | |
| 110 | + if (!fileId) return []; | |
| 111 | + const urls: string[] = []; | |
| 112 | + for (let fileSn = 1; fileSn <= MAX_EXTRA_IMAGES; fileSn += 1) { | |
| 113 | + const url = buildFileImageUrl(fileId, fileSn); | |
| 114 | + if (!url) break; | |
| 115 | + try { | |
| 116 | + const response = await fetch(url, { method: 'HEAD', cache: 'no-store' }); | |
| 117 | + if (!response.ok) break; | |
| 118 | + } catch { | |
| 119 | + break; | |
| 120 | + } | |
| 121 | + urls.push(url); | |
| 122 | + } | |
| 123 | + return urls; | |
| 124 | +} | |
| 125 | + | |
| 126 | +async function toDetail(raw: unknown): Promise<MerchantDetail | null> { | |
| 127 | + const [summary] = toSummary(raw); | |
| 128 | + if (!summary || !isRecord(raw)) return null; | |
| 129 | + | |
| 130 | + const imageFileId = readString(raw, 'atchFileId'); | |
| 131 | + return { | |
| 132 | + ...summary, | |
| 133 | + imageUrls: await probeImageUrls(imageFileId), | |
| 134 | + postalCode: readString(raw, 'frcsPostZip'), | |
| 135 | + description: readString(raw, 'frcsCn'), | |
| 136 | + openTime: readString(raw, 'startHour'), | |
| 137 | + closeTime: readString(raw, 'endHour'), | |
| 138 | + closedDays: readString(raw, 'offDayCn'), | |
| 139 | + imageFileId, | |
| 140 | + sortOrder: readNumber(raw, 'sortOrdr'), | |
| 141 | + registeredBy: readString(raw, 'frstRgtrNm'), | |
| 142 | + modifiedAt: readDate(raw, 'lastMdfcnDt'), | |
| 143 | + modifiedBy: readString(raw, 'lastMdfrNm'), | |
| 144 | + }; | |
| 145 | +} | |
| 146 | + | |
| 147 | +export async function fetchMerchantDetails( | |
| 148 | + query: MerchantDetailQuery | |
| 149 | +): Promise<{ items: MerchantDetailSummary[]; totalCount: number }> { | |
| 150 | + const accessToken = await getSessionAccessToken(); | |
| 151 | + | |
| 152 | + const result = await backendFetch<unknown>(`${DETAIL_PATH}/pagination`, { | |
| 153 | + method: 'GET', | |
| 154 | + query: { | |
| 155 | + ...(query.keyword ? { searchFrcsNm: query.keyword } : {}), | |
| 156 | + ...(query.category ? { searchFrcsTypeCd: query.category } : {}), | |
| 157 | + ...(query.visibility ? { searchUseYn: query.visibility } : {}), | |
| 158 | + ...(query.startDate ? { searchStartDt: query.startDate } : {}), | |
| 159 | + ...(query.endDate ? { searchEndDt: query.endDate } : {}), | |
| 160 | + pageIndex: query.page, | |
| 161 | + recordCountPerPage: query.pageSize, | |
| 162 | + }, | |
| 163 | + accessToken: accessToken ?? undefined, | |
| 164 | + cache: 'no-store', | |
| 165 | + canHaveNullData: true, | |
| 166 | + }); | |
| 167 | + | |
| 168 | + if (!result.ok) { | |
| 169 | + throw new BackendRequestError(result); | |
| 170 | + } | |
| 171 | + | |
| 172 | + const data = result.data; | |
| 173 | + if (data === null || data === undefined) { | |
| 174 | + return { items: [], totalCount: 0 }; | |
| 175 | + } | |
| 176 | + if (!isRecord(data) || !Array.isArray(data.list)) { | |
| 177 | + throw new Error('가맹몰 상세 목록 응답의 형식이 올바르지 않습니다.'); | |
| 178 | + } | |
| 179 | + | |
| 180 | + return { | |
| 181 | + items: data.list.flatMap(toSummary), | |
| 182 | + totalCount: | |
| 183 | + typeof data.totalCount === 'number' ? data.totalCount : data.list.length, | |
| 184 | + }; | |
| 185 | +} | |
| 186 | + | |
| 187 | +export async function fetchMerchantDetail( | |
| 188 | + frcsNo: string | |
| 189 | +): Promise<MerchantDetail | null> { | |
| 190 | + const accessToken = await getSessionAccessToken(); | |
| 191 | + | |
| 192 | + const result = await backendFetch<unknown>( | |
| 193 | + `${DETAIL_PATH}/${encodeURIComponent(frcsNo)}`, | |
| 194 | + { | |
| 195 | + method: 'GET', | |
| 196 | + accessToken: accessToken ?? undefined, | |
| 197 | + cache: 'no-store', | |
| 198 | + canHaveNullData: true, | |
| 199 | + } | |
| 200 | + ); | |
| 201 | + | |
| 202 | + if (!result.ok) { | |
| 203 | + throw new BackendRequestError(result); | |
| 204 | + } | |
| 205 | + return toDetail(result.data); | |
| 206 | +} | |
| 207 | + | |
| 208 | +function toWritePayload(values: MerchantDetailValues): Record<string, unknown> { | |
| 209 | + return { | |
| 210 | + frcsNo: values.frcsNo, | |
| 211 | + frcsTypeCd: values.categoryCode, | |
| 212 | + frcsCn: values.description, | |
| 213 | + startHour: values.openTime, | |
| 214 | + endHour: values.closeTime, | |
| 215 | + offDayCn: values.closedDays, | |
| 216 | + thumbAtchFileId: values.thumbnailFileId, | |
| 217 | + atchFileId: values.imageFileId || null, | |
| 218 | + sortOrdr: values.sortOrder, | |
| 219 | + useYn: values.isVisible ? 'Y' : 'N', | |
| 220 | + // 요청 VO에 없어 버려진다(⚠️ 5). 자리가 생기면 그대로 저장된다. | |
| 221 | + voucherUseYn: values.isVoucherUsable ? 'Y' : 'N', | |
| 222 | + }; | |
| 223 | +} | |
| 224 | + | |
| 225 | +async function sendWrite( | |
| 226 | + path: string, | |
| 227 | + method: 'POST' | 'PUT' | 'DELETE', | |
| 228 | + body?: unknown | |
| 229 | +): Promise<void> { | |
| 230 | + const accessToken = await getSessionAccessToken(); | |
| 231 | + | |
| 232 | + const result = await backendFetch<unknown>(path, { | |
| 233 | + method, | |
| 234 | + ...(body === undefined ? {} : { body }), | |
| 235 | + accessToken: accessToken ?? undefined, | |
| 236 | + cache: 'no-store', | |
| 237 | + canHaveNullData: true, | |
| 238 | + canHaveEmptyBody: true, | |
| 239 | + }); | |
| 240 | + | |
| 241 | + if (!result.ok) { | |
| 242 | + throw new BackendRequestError(result); | |
| 243 | + } | |
| 244 | +} | |
| 245 | + | |
| 246 | +export async function createMerchantDetail( | |
| 247 | + values: MerchantDetailValues | |
| 248 | +): Promise<void> { | |
| 249 | + await sendWrite(DETAIL_PATH, 'POST', toWritePayload(values)); | |
| 250 | +} | |
| 251 | + | |
| 252 | +export async function updateMerchantDetail( | |
| 253 | + values: MerchantDetailValues | |
| 254 | +): Promise<void> { | |
| 255 | + await sendWrite( | |
| 256 | + `${DETAIL_PATH}/${encodeURIComponent(values.frcsNo)}`, | |
| 257 | + 'PUT', | |
| 258 | + toWritePayload(values) | |
| 259 | + ); | |
| 260 | +} | |
| 261 | + | |
| 262 | +export async function deleteMerchantDetail(frcsNo: string): Promise<void> { | |
| 263 | + await sendWrite(`${VOC_PATH}/${encodeURIComponent(frcsNo)}`, 'DELETE'); | |
| 264 | +} |
+++ lib/data/repositories/merchant-product-repository.ts
... | ... | @@ -0,0 +1,164 @@ |
| 1 | +import 'server-only'; | |
| 2 | +import { getSessionAccessToken } from '@/lib/auth/dal'; | |
| 3 | +import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch'; | |
| 4 | +import { buildFileImageUrl } from '@/lib/data/repositories/file-repository'; | |
| 5 | +import { | |
| 6 | + joinProductBadges, | |
| 7 | + parseProductBadges, | |
| 8 | + type MerchantProduct, | |
| 9 | +} from '@/lib/domain/merchant-product'; | |
| 10 | +import type { MerchantProductValues } from '@/lib/domain/merchant-product-form'; | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * 가맹몰 메뉴 Repository — edupay-backend origin/develop 15d409a 기준. | |
| 14 | + * | |
| 15 | + * ``` | |
| 16 | + * GET /api/v1/mngr/voc/product/list/{frcsNo} 상세가 있는 가맹점의 메뉴 전체(페이징 없음) | |
| 17 | + * GET /api/v1/mngr/voc/product/{frcsProductSn} | |
| 18 | + * POST /api/v1/mngr/voc/product @RequestBody · 가맹점 상세가 있어야 한다 | |
| 19 | + * PUT /api/v1/mngr/voc/product/{frcsProductSn} @RequestBody | |
| 20 | + * DELETE /api/v1/mngr/voc/product/{frcsProductSn} 논리 삭제 | |
| 21 | + * ``` | |
| 22 | + */ | |
| 23 | + | |
| 24 | +const PRODUCT_PATH = '/api/v1/mngr/voc/product'; | |
| 25 | + | |
| 26 | +function isRecord(value: unknown): value is Record<string, unknown> { | |
| 27 | + return value !== null && typeof value === 'object'; | |
| 28 | +} | |
| 29 | + | |
| 30 | +function readString(source: Record<string, unknown>, key: string): string | null { | |
| 31 | + const value = source[key]; | |
| 32 | + if (typeof value === 'string' && value !== '') return value; | |
| 33 | + if (typeof value === 'number') return String(value); | |
| 34 | + return null; | |
| 35 | +} | |
| 36 | + | |
| 37 | +function readNumber(source: Record<string, unknown>, key: string): number | null { | |
| 38 | + const value = source[key]; | |
| 39 | + if (typeof value === 'number' && Number.isFinite(value)) return value; | |
| 40 | + if (typeof value === 'string' && value.trim() !== '') { | |
| 41 | + const parsed = Number(value); | |
| 42 | + return Number.isFinite(parsed) ? parsed : null; | |
| 43 | + } | |
| 44 | + return null; | |
| 45 | +} | |
| 46 | + | |
| 47 | +function toProduct(raw: unknown): MerchantProduct[] { | |
| 48 | + if (!isRecord(raw)) return []; | |
| 49 | + const id = readString(raw, 'frcsProductSn'); | |
| 50 | + const frcsNo = readString(raw, 'frcsNo'); | |
| 51 | + if (!id || !frcsNo) return []; | |
| 52 | + | |
| 53 | + return [ | |
| 54 | + { | |
| 55 | + id, | |
| 56 | + frcsNo, | |
| 57 | + name: readString(raw, 'frcsProductSj') ?? '', | |
| 58 | + price: readNumber(raw, 'frcsProductPrice'), | |
| 59 | + description: readString(raw, 'frcsProductCn'), | |
| 60 | + badges: parseProductBadges(readString(raw, 'frcsBadge')), | |
| 61 | + imageFileId: readString(raw, 'atchFileId'), | |
| 62 | + imageUrl: buildFileImageUrl(readString(raw, 'atchFileId')), | |
| 63 | + sortOrder: readNumber(raw, 'sortOrdr'), | |
| 64 | + isVisible: (readString(raw, 'useYn') ?? 'Y').toUpperCase() !== 'N', | |
| 65 | + }, | |
| 66 | + ]; | |
| 67 | +} | |
| 68 | + | |
| 69 | +export async function fetchMerchantProducts(frcsNo: string): Promise<MerchantProduct[]> { | |
| 70 | + const accessToken = await getSessionAccessToken(); | |
| 71 | + | |
| 72 | + const result = await backendFetch<unknown>( | |
| 73 | + `${PRODUCT_PATH}/list/${encodeURIComponent(frcsNo)}`, | |
| 74 | + { | |
| 75 | + method: 'GET', | |
| 76 | + accessToken: accessToken ?? undefined, | |
| 77 | + cache: 'no-store', | |
| 78 | + canHaveNullData: true, | |
| 79 | + } | |
| 80 | + ); | |
| 81 | + | |
| 82 | + if (!result.ok) { | |
| 83 | + throw new BackendRequestError(result); | |
| 84 | + } | |
| 85 | + return Array.isArray(result.data) ? result.data.flatMap(toProduct) : []; | |
| 86 | +} | |
| 87 | + | |
| 88 | +export async function fetchMerchantProduct( | |
| 89 | + productSn: string | |
| 90 | +): Promise<MerchantProduct | null> { | |
| 91 | + const accessToken = await getSessionAccessToken(); | |
| 92 | + | |
| 93 | + const result = await backendFetch<unknown>( | |
| 94 | + `${PRODUCT_PATH}/${encodeURIComponent(productSn)}`, | |
| 95 | + { | |
| 96 | + method: 'GET', | |
| 97 | + accessToken: accessToken ?? undefined, | |
| 98 | + cache: 'no-store', | |
| 99 | + canHaveNullData: true, | |
| 100 | + } | |
| 101 | + ); | |
| 102 | + | |
| 103 | + if (!result.ok) { | |
| 104 | + // 없는 메뉴는 404 봉투로 온다 — 화면이 notFound()로 넘기게 null을 준다. | |
| 105 | + if (result.code === 404) return null; | |
| 106 | + throw new BackendRequestError(result); | |
| 107 | + } | |
| 108 | + return toProduct(result.data)[0] ?? null; | |
| 109 | +} | |
| 110 | + | |
| 111 | +function toWritePayload(values: MerchantProductValues): Record<string, unknown> { | |
| 112 | + return { | |
| 113 | + frcsNo: values.frcsNo, | |
| 114 | + frcsProductSj: values.name, | |
| 115 | + frcsProductPrice: values.price, | |
| 116 | + frcsProductCn: values.description, | |
| 117 | + frcsBadge: joinProductBadges(values.badges), | |
| 118 | + atchFileId: values.imageFileId || null, | |
| 119 | + sortOrdr: values.sortOrder, | |
| 120 | + useYn: values.isVisible ? 'Y' : 'N', | |
| 121 | + }; | |
| 122 | +} | |
| 123 | + | |
| 124 | +async function sendWrite( | |
| 125 | + path: string, | |
| 126 | + method: 'POST' | 'PUT' | 'DELETE', | |
| 127 | + body?: unknown | |
| 128 | +): Promise<void> { | |
| 129 | + const accessToken = await getSessionAccessToken(); | |
| 130 | + | |
| 131 | + const result = await backendFetch<unknown>(path, { | |
| 132 | + method, | |
| 133 | + ...(body === undefined ? {} : { body }), | |
| 134 | + accessToken: accessToken ?? undefined, | |
| 135 | + cache: 'no-store', | |
| 136 | + canHaveNullData: true, | |
| 137 | + canHaveEmptyBody: true, | |
| 138 | + }); | |
| 139 | + | |
| 140 | + if (!result.ok) { | |
| 141 | + throw new BackendRequestError(result); | |
| 142 | + } | |
| 143 | +} | |
| 144 | + | |
| 145 | +export async function createMerchantProduct( | |
| 146 | + values: MerchantProductValues | |
| 147 | +): Promise<void> { | |
| 148 | + await sendWrite(PRODUCT_PATH, 'POST', toWritePayload(values)); | |
| 149 | +} | |
| 150 | + | |
| 151 | +export async function updateMerchantProduct( | |
| 152 | + productSn: string, | |
| 153 | + values: MerchantProductValues | |
| 154 | +): Promise<void> { | |
| 155 | + await sendWrite( | |
| 156 | + `${PRODUCT_PATH}/${encodeURIComponent(productSn)}`, | |
| 157 | + 'PUT', | |
| 158 | + toWritePayload(values) | |
| 159 | + ); | |
| 160 | +} | |
| 161 | + | |
| 162 | +export async function deleteMerchantProduct(productSn: string): Promise<void> { | |
| 163 | + await sendWrite(`${PRODUCT_PATH}/${encodeURIComponent(productSn)}`, 'DELETE'); | |
| 164 | +} |
--- lib/domain/common-code.ts
+++ lib/domain/common-code.ts
... | ... | @@ -39,6 +39,8 @@ |
| 39 | 39 |
O: 'QUIZ_ANSWER_O', |
| 40 | 40 |
X: 'QUIZ_ANSWER_X', |
| 41 | 41 |
}, |
| 42 |
+ /** 가맹점 상세의 카테고리 — 백엔드 `MngrVocDetailMapper`가 이 그룹과 조인한다. */ |
|
| 43 |
+ merchantType: 'COM_FRCS_TYPE_CD', |
|
| 42 | 44 |
} as const; |
| 43 | 45 |
|
| 44 | 46 |
/** 값이 없는 항목의 화면 표기. */ |
--- lib/domain/file-module.ts
+++ lib/domain/file-module.ts
... | ... | @@ -60,6 +60,18 @@ |
| 60 | 60 |
description: '사용자 아이템 저장소', |
| 61 | 61 |
extensions: ['jpg', 'png', 'gif', 'svg'], |
| 62 | 62 |
}, |
| 63 |
+ /** |
|
| 64 |
+ * 가맹점 상세·메뉴 이미지. |
|
| 65 |
+ * |
|
| 66 |
+ * TODO(백엔드): 저장소 설정 표에 바우처용 모듈이 없다. 없는 ID를 보내면 기본 저장소로 |
|
| 67 |
+ * 폴백해 동작은 하므로 잠정 이름을 두고 시작한다(사용자 확정) — 백엔드가 이 이름으로 |
|
| 68 |
+ * 등록하면 손댈 것이 없고, 다른 이름이면 여기 한 줄만 바꾼다. |
|
| 69 |
+ */ |
|
| 70 |
+ merchantImage: {
|
|
| 71 |
+ id: 'MODULE_VOUCHER', |
|
| 72 |
+ description: '가맹점 이미지 저장소(잠정)', |
|
| 73 |
+ extensions: ['jpg', 'jpeg', 'png'], |
|
| 74 |
+ }, |
|
| 63 | 75 |
menuIcon: {
|
| 64 | 76 |
id: 'MODULE_MENU_ICON', |
| 65 | 77 |
description: '메뉴 아이콘 저장소', |
+++ lib/domain/merchant-detail-form.ts
... | ... | @@ -0,0 +1,106 @@ |
| 1 | +import type { CommonCode } from '@/lib/domain/common-code'; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * 가맹몰 상세 등록·수정 입력 규칙 — 순수 검증만 담는다. | |
| 5 | + * | |
| 6 | + * 기획 BO-MAL-002 ①의 필수 항목 중 업체명·연락처·우편번호는 가맹점 원본에서 오는 읽기 전용 | |
| 7 | + * 값이라 여기서 검사하지 않는다. 남는 필수는 카테고리·노출 상태·바우처 사용·대표이미지다. | |
| 8 | + */ | |
| 9 | + | |
| 10 | +export const DESCRIPTION_MAX_LENGTH = 200; | |
| 11 | +export const CLOSED_DAYS_MAX_LENGTH = 100; | |
| 12 | +export const SORT_ORDER_MAX = 9999; | |
| 13 | +const TIME_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/; | |
| 14 | + | |
| 15 | +export type MerchantDetailValues = { | |
| 16 | + frcsNo: string; | |
| 17 | + categoryCode: string; | |
| 18 | + description: string; | |
| 19 | + openTime: string; | |
| 20 | + closeTime: string; | |
| 21 | + closedDays: string; | |
| 22 | + isVisible: boolean; | |
| 23 | + isVoucherUsable: boolean; | |
| 24 | + /** 빈 값이면 null — 기획 ⑦ 미입력 시 등록일 내림차순. */ | |
| 25 | + sortOrder: number | null; | |
| 26 | + thumbnailFileId: string; | |
| 27 | + imageFileId: string; | |
| 28 | +}; | |
| 29 | + | |
| 30 | +export type MerchantDetailFormErrors = Partial< | |
| 31 | + Record< | |
| 32 | + | 'categoryCode' | |
| 33 | + | 'description' | |
| 34 | + | 'openTime' | |
| 35 | + | 'closeTime' | |
| 36 | + | 'closedDays' | |
| 37 | + | 'sortOrder' | |
| 38 | + | 'thumbnail' | |
| 39 | + | 'images', | |
| 40 | + string | |
| 41 | + > | |
| 42 | +>; | |
| 43 | + | |
| 44 | +export type MerchantDetailFormState = | |
| 45 | + | { status: 'idle' } | |
| 46 | + | { status: 'error'; message?: string; errors?: MerchantDetailFormErrors } | |
| 47 | + | { status: 'success' }; | |
| 48 | + | |
| 49 | +export const INITIAL_MERCHANT_DETAIL_FORM_STATE: MerchantDetailFormState = { | |
| 50 | + status: 'idle', | |
| 51 | +}; | |
| 52 | + | |
| 53 | +type ValidationResult = | |
| 54 | + | { ok: true; values: MerchantDetailValues } | |
| 55 | + | { ok: false; errors: MerchantDetailFormErrors }; | |
| 56 | + | |
| 57 | +export function validateMerchantDetail( | |
| 58 | + values: MerchantDetailValues, | |
| 59 | + categories: readonly CommonCode[] | |
| 60 | +): ValidationResult { | |
| 61 | + const errors: MerchantDetailFormErrors = {}; | |
| 62 | + | |
| 63 | + if (!categories.some((code) => code.code === values.categoryCode)) { | |
| 64 | + errors.categoryCode = '카테고리를 선택해 주세요.'; | |
| 65 | + } | |
| 66 | + | |
| 67 | + const description = values.description.trim(); | |
| 68 | + if (description.length > DESCRIPTION_MAX_LENGTH) { | |
| 69 | + errors.description = `한줄 소개는 ${DESCRIPTION_MAX_LENGTH}자 이내로 입력해 주세요.`; | |
| 70 | + } | |
| 71 | + | |
| 72 | + const openTime = values.openTime.trim(); | |
| 73 | + const closeTime = values.closeTime.trim(); | |
| 74 | + if (openTime && !TIME_PATTERN.test(openTime)) { | |
| 75 | + errors.openTime = '영업 시작 시간 형식이 올바르지 않습니다.'; | |
| 76 | + } | |
| 77 | + if (closeTime && !TIME_PATTERN.test(closeTime)) { | |
| 78 | + errors.closeTime = '영업 종료 시간 형식이 올바르지 않습니다.'; | |
| 79 | + } | |
| 80 | + | |
| 81 | + const closedDays = values.closedDays.trim(); | |
| 82 | + if (closedDays.length > CLOSED_DAYS_MAX_LENGTH) { | |
| 83 | + errors.closedDays = `휴무일은 ${CLOSED_DAYS_MAX_LENGTH}자 이내로 입력해 주세요.`; | |
| 84 | + } | |
| 85 | + | |
| 86 | + if ( | |
| 87 | + values.sortOrder !== null && | |
| 88 | + (!Number.isInteger(values.sortOrder) || | |
| 89 | + values.sortOrder < 0 || | |
| 90 | + values.sortOrder > SORT_ORDER_MAX) | |
| 91 | + ) { | |
| 92 | + errors.sortOrder = `노출 순서는 0 이상 ${SORT_ORDER_MAX} 이하의 숫자로 입력해 주세요.`; | |
| 93 | + } | |
| 94 | + | |
| 95 | + if (!values.thumbnailFileId) { | |
| 96 | + errors.thumbnail = '대표이미지를 등록해 주세요.'; | |
| 97 | + } | |
| 98 | + | |
| 99 | + if (Object.keys(errors).length > 0) { | |
| 100 | + return { ok: false, errors }; | |
| 101 | + } | |
| 102 | + return { | |
| 103 | + ok: true, | |
| 104 | + values: { ...values, description, openTime, closeTime, closedDays }, | |
| 105 | + }; | |
| 106 | +} |
+++ lib/domain/merchant-detail-query.ts
... | ... | @@ -0,0 +1,99 @@ |
| 1 | +import { MERCHANT_DETAIL_VISIBILITY_OPTIONS } from '@/lib/domain/merchant-detail'; | |
| 2 | + | |
| 3 | +export const MERCHANT_DETAILS_PATH = '/system/merchant-details'; | |
| 4 | + | |
| 5 | +export function merchantDetailPath(frcsNo: string): string { | |
| 6 | + return `${MERCHANT_DETAILS_PATH}/${encodeURIComponent(frcsNo)}`; | |
| 7 | +} | |
| 8 | + | |
| 9 | +export function merchantProductNewPath(frcsNo: string): string { | |
| 10 | + return `${merchantDetailPath(frcsNo)}/products/new`; | |
| 11 | +} | |
| 12 | + | |
| 13 | +export function merchantProductPath(frcsNo: string, productSn: string): string { | |
| 14 | + return `${merchantDetailPath(frcsNo)}/products/${encodeURIComponent(productSn)}`; | |
| 15 | +} | |
| 16 | + | |
| 17 | +export const FILTER_ALL = ''; | |
| 18 | + | |
| 19 | +export const MERCHANT_DETAIL_PAGE_SIZE_OPTIONS = [10, 30, 50] as const; | |
| 20 | +export type MerchantDetailPageSize = | |
| 21 | + (typeof MERCHANT_DETAIL_PAGE_SIZE_OPTIONS)[number]; | |
| 22 | + | |
| 23 | +export const DEFAULT_MERCHANT_DETAIL_PAGE_SIZE: MerchantDetailPageSize = 10; | |
| 24 | +const DEFAULT_PAGE = 1; | |
| 25 | +const MAX_KEYWORD_LENGTH = 100; | |
| 26 | +const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; | |
| 27 | + | |
| 28 | +/** 기획 BO-MAL-001 ① — 업체명·카테고리·노출상태·등록일 네 조건을 AND로 결합한다. */ | |
| 29 | +export type MerchantDetailQuery = { | |
| 30 | + keyword: string; | |
| 31 | + category: string; | |
| 32 | + visibility: string; | |
| 33 | + startDate: string; | |
| 34 | + endDate: string; | |
| 35 | + page: number; | |
| 36 | + pageSize: MerchantDetailPageSize; | |
| 37 | +}; | |
| 38 | + | |
| 39 | +type RawSearchParams = Record<string, string | string[] | undefined>; | |
| 40 | + | |
| 41 | +function readParam(params: RawSearchParams, key: string): string { | |
| 42 | + const value = params[key]; | |
| 43 | + return (Array.isArray(value) ? value[0] : value) ?? ''; | |
| 44 | +} | |
| 45 | + | |
| 46 | +function readDate(params: RawSearchParams, key: string): string { | |
| 47 | + const raw = readParam(params, key); | |
| 48 | + return ISO_DATE.test(raw) ? raw : ''; | |
| 49 | +} | |
| 50 | + | |
| 51 | +function isPageSize(value: number): value is MerchantDetailPageSize { | |
| 52 | + return (MERCHANT_DETAIL_PAGE_SIZE_OPTIONS as readonly number[]).includes(value); | |
| 53 | +} | |
| 54 | + | |
| 55 | +export function parseMerchantDetailQuery( | |
| 56 | + searchParams: RawSearchParams | |
| 57 | +): MerchantDetailQuery { | |
| 58 | + const page = Number(readParam(searchParams, 'page')); | |
| 59 | + const pageSize = Number(readParam(searchParams, 'pageSize')); | |
| 60 | + const visibility = readParam(searchParams, 'visibility'); | |
| 61 | + | |
| 62 | + return { | |
| 63 | + keyword: readParam(searchParams, 'keyword').trim().slice(0, MAX_KEYWORD_LENGTH), | |
| 64 | + // 카테고리는 공통코드라 여기서 허용 목록을 알 수 없다 — 값 그대로 두고 서버가 거른다. | |
| 65 | + category: readParam(searchParams, 'category').trim().slice(0, MAX_KEYWORD_LENGTH), | |
| 66 | + visibility: MERCHANT_DETAIL_VISIBILITY_OPTIONS.some( | |
| 67 | + (option) => option.value === visibility | |
| 68 | + ) | |
| 69 | + ? visibility | |
| 70 | + : FILTER_ALL, | |
| 71 | + startDate: readDate(searchParams, 'startDate'), | |
| 72 | + endDate: readDate(searchParams, 'endDate'), | |
| 73 | + page: Number.isInteger(page) && page > 0 ? page : DEFAULT_PAGE, | |
| 74 | + pageSize: isPageSize(pageSize) ? pageSize : DEFAULT_MERCHANT_DETAIL_PAGE_SIZE, | |
| 75 | + }; | |
| 76 | +} | |
| 77 | + | |
| 78 | +export function buildMerchantDetailHref( | |
| 79 | + query: MerchantDetailQuery, | |
| 80 | + overrides: Partial<MerchantDetailQuery> = {} | |
| 81 | +): string { | |
| 82 | + const merged = { ...query, ...overrides }; | |
| 83 | + const params = new URLSearchParams(); | |
| 84 | + | |
| 85 | + if (merged.keyword) params.set('keyword', merged.keyword); | |
| 86 | + if (merged.category) params.set('category', merged.category); | |
| 87 | + if (merged.visibility) params.set('visibility', merged.visibility); | |
| 88 | + if (merged.startDate) params.set('startDate', merged.startDate); | |
| 89 | + if (merged.endDate) params.set('endDate', merged.endDate); | |
| 90 | + if (merged.pageSize !== DEFAULT_MERCHANT_DETAIL_PAGE_SIZE) { | |
| 91 | + params.set('pageSize', String(merged.pageSize)); | |
| 92 | + } | |
| 93 | + if (merged.page !== DEFAULT_PAGE) params.set('page', String(merged.page)); | |
| 94 | + | |
| 95 | + const queryString = params.toString(); | |
| 96 | + return queryString | |
| 97 | + ? `${MERCHANT_DETAILS_PATH}?${queryString}` | |
| 98 | + : MERCHANT_DETAILS_PATH; | |
| 99 | +} |
+++ lib/domain/merchant-detail.ts
... | ... | @@ -0,0 +1,96 @@ |
| 1 | +/** | |
| 2 | + * 가맹몰 상세 — 기존 가맹점(`TB_COM_VOUCHER`)에 1:1로 붙는 앱 노출용 정보(`TB_COM_VOUCHER_DTL`). | |
| 3 | + * | |
| 4 | + * 기획(BO-MAL-001~003 「지역상생몰 > 업체관리」)은 업체를 새로 만드는 화면이지만, 백엔드 | |
| 5 | + * `POST /api/v1/mngr/voc/detail`은 이미 있는 가맹점(`frcsNo`)에만 상세를 붙인다. 그래서 | |
| 6 | + * 「시스템관리 > 가맹몰 상세 관리」로 옮기며 업체명·연락처·주소는 가맹점 원본에서 읽기 전용으로 | |
| 7 | + * 가져오고, 상세 항목만 등록·수정한다(사용자 확정). | |
| 8 | + */ | |
| 9 | + | |
| 10 | +/** 목록 한 행. 가맹점 전체가 오며 상세 미등록 건은 `hasDetail`이 false다. */ | |
| 11 | +export type MerchantDetailSummary = { | |
| 12 | + /** 백엔드 `frcsNo` — 가맹점 코드이자 상세의 키. */ | |
| 13 | + id: string; | |
| 14 | + name: string; | |
| 15 | + phoneNumber: string | null; | |
| 16 | + address: string | null; | |
| 17 | + addressDetail: string | null; | |
| 18 | + /** 백엔드 `thumbAtchFileId` — 대표 이미지. */ | |
| 19 | + thumbnailFileId: string | null; | |
| 20 | + /** Repository가 만든 이미지 URL. 화면은 파일 id를 URL로 바꾸는 법을 모른다. */ | |
| 21 | + thumbnailUrl: string | null; | |
| 22 | + /** 백엔드 `frcsTypeCd` — 카테고리 코드. 목록 SQL에 아직 없어 늘 null이다(보고함). */ | |
| 23 | + categoryCode: string | null; | |
| 24 | + /** 백엔드 상세 `useYn`. VO에 필드가 없어 아직 오지 않는다(보고함). */ | |
| 25 | + isVisible: boolean | null; | |
| 26 | + /** 상세 등록일(`d.FRST_REG_DT`). 없으면 상세가 아직 없는 가맹점이다. */ | |
| 27 | + registeredAt: string | null; | |
| 28 | + /** 상세 행이 있는지 — 행 관리 버튼이 [상세 등록]과 [수정·비활성·삭제]로 갈린다. */ | |
| 29 | + hasDetail: boolean; | |
| 30 | +}; | |
| 31 | + | |
| 32 | +/** 등록·수정 화면이 받는 한 건. 상세가 없으면 상세 항목이 전부 비어 있다. */ | |
| 33 | +export type MerchantDetail = MerchantDetailSummary & { | |
| 34 | + postalCode: string | null; | |
| 35 | + /** 백엔드 `frcsCn` — 한줄 소개. */ | |
| 36 | + description: string | null; | |
| 37 | + /** 백엔드 `startHour` / `endHour` — "HH:mm". */ | |
| 38 | + openTime: string | null; | |
| 39 | + closeTime: string | null; | |
| 40 | + /** 백엔드 `offDayCn`. */ | |
| 41 | + closedDays: string | null; | |
| 42 | + /** 백엔드 `atchFileId` — 추가 이미지 묶음(한 id 아래 fileSn 1..n). */ | |
| 43 | + imageFileId: string | null; | |
| 44 | + /** 묶음 안의 이미지 URL, fileSn 순. 백엔드에 파일 수를 묻는 API가 없어 Repository가 더듬는다. */ | |
| 45 | + imageUrls: string[]; | |
| 46 | + sortOrder: number | null; | |
| 47 | + /** 백엔드 `frstRgtrNm` / `lastMdfrNm` — 수정 화면의 「등록 정보」 구역. */ | |
| 48 | + registeredBy: string | null; | |
| 49 | + modifiedAt: string | null; | |
| 50 | + modifiedBy: string | null; | |
| 51 | +}; | |
| 52 | + | |
| 53 | +export const EMPTY_FIELD_PLACEHOLDER = '-'; | |
| 54 | + | |
| 55 | +export function formatOptionalValue( | |
| 56 | + value: string | number | null | undefined | |
| 57 | +): string { | |
| 58 | + return value === null || value === undefined || value === '' | |
| 59 | + ? EMPTY_FIELD_PLACEHOLDER | |
| 60 | + : String(value); | |
| 61 | +} | |
| 62 | + | |
| 63 | +/** 기획 BO-MAL-001 ② — 노출 상태는 노출중·비활성 2종. */ | |
| 64 | +export const MERCHANT_DETAIL_VISIBILITY_OPTIONS = [ | |
| 65 | + { value: 'Y', label: '노출중' }, | |
| 66 | + { value: 'N', label: '비활성' }, | |
| 67 | +] as const; | |
| 68 | + | |
| 69 | +export function formatMerchantDetailStatusLabel(row: MerchantDetailSummary): string { | |
| 70 | + if (!row.hasDetail) { | |
| 71 | + return '미등록'; | |
| 72 | + } | |
| 73 | + if (row.isVisible === null) { | |
| 74 | + return EMPTY_FIELD_PLACEHOLDER; | |
| 75 | + } | |
| 76 | + return row.isVisible ? '노출중' : '비활성'; | |
| 77 | +} | |
| 78 | + | |
| 79 | +/** 기획 BO-MAL-002 ① — 바우처 사용 가능 여부. 백엔드에 담을 자리가 아직 없다(보고함). */ | |
| 80 | +export const MERCHANT_VOUCHER_USE_OPTIONS = [ | |
| 81 | + { value: 'Y', label: '사용 가능' }, | |
| 82 | + { value: 'N', label: '사용 불가' }, | |
| 83 | +] as const; | |
| 84 | + | |
| 85 | +/** 기획 BO-MAL-002 ⑤ — 대표 1장 + 추가 최대 11장(앱 슬라이더 최대 12장). */ | |
| 86 | +export const MAX_EXTRA_IMAGES = 11; | |
| 87 | +/** 기획 BO-MAL-002 ⑤ — 장당 5MB 이하. */ | |
| 88 | +export const MAX_IMAGE_BYTES = 5 * 1024 * 1024; | |
| 89 | + | |
| 90 | +/** 상세 이미지 한 장의 화면 표현. 저장된 것은 `fileSn`으로, 새로 고른 것은 파일로 온다. */ | |
| 91 | +export type MerchantImageSlot = { | |
| 92 | + key: string; | |
| 93 | + previewUrl: string | null; | |
| 94 | + /** 저장된 이미지면 묶음 안의 번호(1..n). 새 파일은 null. */ | |
| 95 | + fileSn: number | null; | |
| 96 | +}; |
+++ lib/domain/merchant-product-form.ts
... | ... | @@ -0,0 +1,88 @@ |
| 1 | +import { | |
| 2 | + MAX_PRODUCT_BADGES, | |
| 3 | + MERCHANT_PRODUCT_BADGE_OPTIONS, | |
| 4 | + type MerchantProductBadge, | |
| 5 | +} from '@/lib/domain/merchant-product'; | |
| 6 | + | |
| 7 | +/** 기획 BO-MAL-004 ② — 필수는 메뉴명·가격·노출 여부 셋. */ | |
| 8 | +export const PRODUCT_NAME_MAX_LENGTH = 100; | |
| 9 | +/** 기획 BO-MAL-004 ⑤ [의사결정 필요] 잠정값 — 설명 최대 글자수. */ | |
| 10 | +export const PRODUCT_DESCRIPTION_MAX_LENGTH = 120; | |
| 11 | +export const PRODUCT_PRICE_MAX = 99_999_999; | |
| 12 | +export const PRODUCT_SORT_ORDER_MAX = 9999; | |
| 13 | + | |
| 14 | +export type MerchantProductValues = { | |
| 15 | + frcsNo: string; | |
| 16 | + name: string; | |
| 17 | + price: number | null; | |
| 18 | + description: string; | |
| 19 | + badges: MerchantProductBadge[]; | |
| 20 | + isVisible: boolean; | |
| 21 | + sortOrder: number | null; | |
| 22 | + imageFileId: string; | |
| 23 | +}; | |
| 24 | + | |
| 25 | +export type MerchantProductFormErrors = Partial< | |
| 26 | + Record<'name' | 'price' | 'description' | 'badges' | 'sortOrder' | 'image', string> | |
| 27 | +>; | |
| 28 | + | |
| 29 | +export type MerchantProductFormState = | |
| 30 | + | { status: 'idle' } | |
| 31 | + | { status: 'error'; message?: string; errors?: MerchantProductFormErrors } | |
| 32 | + | { status: 'success' }; | |
| 33 | + | |
| 34 | +export const INITIAL_MERCHANT_PRODUCT_FORM_STATE: MerchantProductFormState = { | |
| 35 | + status: 'idle', | |
| 36 | +}; | |
| 37 | + | |
| 38 | +type ValidationResult = | |
| 39 | + | { ok: true; values: MerchantProductValues } | |
| 40 | + | { ok: false; errors: MerchantProductFormErrors }; | |
| 41 | + | |
| 42 | +export function validateMerchantProduct( | |
| 43 | + values: MerchantProductValues | |
| 44 | +): ValidationResult { | |
| 45 | + const errors: MerchantProductFormErrors = {}; | |
| 46 | + | |
| 47 | + const name = values.name.trim(); | |
| 48 | + if (!name) { | |
| 49 | + errors.name = '메뉴명을 입력해 주세요.'; | |
| 50 | + } else if (name.length > PRODUCT_NAME_MAX_LENGTH) { | |
| 51 | + errors.name = `메뉴명은 ${PRODUCT_NAME_MAX_LENGTH}자 이내로 입력해 주세요.`; | |
| 52 | + } | |
| 53 | + | |
| 54 | + if ( | |
| 55 | + values.price === null || | |
| 56 | + !Number.isInteger(values.price) || | |
| 57 | + values.price < 0 || | |
| 58 | + values.price > PRODUCT_PRICE_MAX | |
| 59 | + ) { | |
| 60 | + errors.price = '가격은 숫자만 입력해 주세요.'; | |
| 61 | + } | |
| 62 | + | |
| 63 | + const description = values.description.trim(); | |
| 64 | + if (description.length > PRODUCT_DESCRIPTION_MAX_LENGTH) { | |
| 65 | + errors.description = `설명은 ${PRODUCT_DESCRIPTION_MAX_LENGTH}자 이내로 입력해 주세요.`; | |
| 66 | + } | |
| 67 | + | |
| 68 | + const badges = values.badges.filter((badge) => | |
| 69 | + MERCHANT_PRODUCT_BADGE_OPTIONS.some((option) => option.value === badge) | |
| 70 | + ); | |
| 71 | + if (badges.length > MAX_PRODUCT_BADGES) { | |
| 72 | + errors.badges = `배지는 최대 ${MAX_PRODUCT_BADGES}개까지 고를 수 있습니다.`; | |
| 73 | + } | |
| 74 | + | |
| 75 | + if ( | |
| 76 | + values.sortOrder !== null && | |
| 77 | + (!Number.isInteger(values.sortOrder) || | |
| 78 | + values.sortOrder < 0 || | |
| 79 | + values.sortOrder > PRODUCT_SORT_ORDER_MAX) | |
| 80 | + ) { | |
| 81 | + errors.sortOrder = `노출 순서는 0 이상 ${PRODUCT_SORT_ORDER_MAX} 이하의 숫자로 입력해 주세요.`; | |
| 82 | + } | |
| 83 | + | |
| 84 | + if (Object.keys(errors).length > 0) { | |
| 85 | + return { ok: false, errors }; | |
| 86 | + } | |
| 87 | + return { ok: true, values: { ...values, name, description, badges } }; | |
| 88 | +} |
+++ lib/domain/merchant-product.ts
... | ... | @@ -0,0 +1,76 @@ |
| 1 | +/** | |
| 2 | + * 가맹몰 메뉴 — 백엔드 `TB_COM_VOUCHER_PRODUCT`(`/api/v1/mngr/voc/product`). | |
| 3 | + * 기획(BO-MAL-004)은 「메뉴」, 백엔드는 product라 부른다. 코드는 백엔드를 따르고 화면 문구만 | |
| 4 | + * 「메뉴」다 — 앱 메뉴관리(`system-menu`)와 이름이 겹치지 않게 하려는 뜻이다. | |
| 5 | + */ | |
| 6 | +export type MerchantProduct = { | |
| 7 | + /** 백엔드 `frcsProductSn`. */ | |
| 8 | + id: string; | |
| 9 | + frcsNo: string; | |
| 10 | + name: string; | |
| 11 | + price: number | null; | |
| 12 | + description: string | null; | |
| 13 | + /** 기획 BO-MAL-004 ③ — 선택한 순서대로. */ | |
| 14 | + badges: MerchantProductBadge[]; | |
| 15 | + imageFileId: string | null; | |
| 16 | + /** Repository가 만든 이미지 URL. */ | |
| 17 | + imageUrl: string | null; | |
| 18 | + sortOrder: number | null; | |
| 19 | + isVisible: boolean; | |
| 20 | +}; | |
| 21 | + | |
| 22 | +export const MERCHANT_PRODUCT_BADGE_OPTIONS = [ | |
| 23 | + { value: 'BEST', label: 'BEST' }, | |
| 24 | + { value: 'MAIN', label: '대표' }, | |
| 25 | + { value: 'NEW', label: '신규' }, | |
| 26 | + { value: 'SALE', label: '할인' }, | |
| 27 | +] as const; | |
| 28 | + | |
| 29 | +export type MerchantProductBadge = | |
| 30 | + (typeof MERCHANT_PRODUCT_BADGE_OPTIONS)[number]['value']; | |
| 31 | + | |
| 32 | +/** 기획 BO-MAL-004 ③ [의사결정 필요] 잠정값 — 배지 동시 선택 상한. */ | |
| 33 | +export const MAX_PRODUCT_BADGES = 2; | |
| 34 | + | |
| 35 | +const BADGE_SEPARATOR = ','; | |
| 36 | + | |
| 37 | +/** | |
| 38 | + * 배지 ↔ 백엔드 `frcsBadge`(문자열 하나). 앱·백엔드에 규약이 없어 여기서 정한다(사용자 확정): | |
| 39 | + * 코드를 쉼표로 잇고 순서를 보존한다. 예) `BEST,MAIN` | |
| 40 | + */ | |
| 41 | +export function parseProductBadges(raw: string | null): MerchantProductBadge[] { | |
| 42 | + if (!raw) { | |
| 43 | + return []; | |
| 44 | + } | |
| 45 | + return raw | |
| 46 | + .split(BADGE_SEPARATOR) | |
| 47 | + .map((token) => token.trim()) | |
| 48 | + .filter((token): token is MerchantProductBadge => | |
| 49 | + MERCHANT_PRODUCT_BADGE_OPTIONS.some((option) => option.value === token) | |
| 50 | + ); | |
| 51 | +} | |
| 52 | + | |
| 53 | +export function joinProductBadges(badges: readonly MerchantProductBadge[]): string { | |
| 54 | + return badges.join(BADGE_SEPARATOR); | |
| 55 | +} | |
| 56 | + | |
| 57 | +export function formatProductBadgeLabel(badge: MerchantProductBadge): string { | |
| 58 | + return ( | |
| 59 | + MERCHANT_PRODUCT_BADGE_OPTIONS.find((option) => option.value === badge)?.label ?? | |
| 60 | + badge | |
| 61 | + ); | |
| 62 | +} | |
| 63 | + | |
| 64 | +export const MERCHANT_PRODUCT_VISIBILITY_OPTIONS = [ | |
| 65 | + { value: 'Y', label: '노출' }, | |
| 66 | + { value: 'N', label: '숨김' }, | |
| 67 | +] as const; | |
| 68 | + | |
| 69 | +export function formatProductVisibilityLabel(isVisible: boolean): string { | |
| 70 | + return isVisible ? '노출' : '숨김'; | |
| 71 | +} | |
| 72 | + | |
| 73 | +/** 기획 BO-MAL-004 ② — 숫자만 받고 노출 시 세 자리 콤마와 '원'을 붙인다. */ | |
| 74 | +export function formatProductPrice(price: number | null): string { | |
| 75 | + return price === null ? '-' : `${price.toLocaleString('ko-KR')}원`; | |
| 76 | +} |
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?