feat: 가맹몰 상세 등록과 수정을 한 화면으로 합친다
제목·경로 표시·구성을 「가맹몰 상세 정보」 하나로 두고, 저장 액션은 리다이렉트 대신 결과를 돌려준다. 처음 저장하면 토스트를 띄우고 그 가맹점 주소로만 옮겨 가 화면은 그대로이고 (바로 메뉴 추가 가능), 이후 저장은 그 자리에서 새로고침한다(사용자 확정). 이미지 칸은 저장으로 파일 id가 바뀌면 다시 마운트해 서버 값과 맞춘다. Co-Authored-By: Claude Opus 5
@f27562e22940af8805d47b6b849f3a6baafa7e42
--- app/(protected)/(basic)/system/merchant-details/[frcsNo]/_components/merchant-detail-form.tsx
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/_components/merchant-detail-form.tsx
... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 | 3 |
import { useRouter } from 'next/navigation';
|
| 4 |
-import { useActionState, useState } from 'react';
|
|
| 4 |
+import { useActionState, useEffect, useState } from 'react';
|
|
| 5 | 5 |
import { FoxButton } from '@fox/core/components/fox-button';
|
| 6 | 6 |
import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
|
| 7 | 7 |
import { FoxButtonPanel } from '@fox/core/components/fox-button-panel';
|
... | ... | @@ -29,7 +29,7 @@ |
| 29 | 29 |
INITIAL_MERCHANT_DETAIL_FORM_STATE, |
| 30 | 30 |
SORT_ORDER_MAX, |
| 31 | 31 |
} from '@/lib/domain/merchant-detail-form'; |
| 32 |
-import { MERCHANT_DETAILS_PATH } from '@/lib/domain/merchant-detail-query';
|
|
| 32 |
+import { MERCHANT_DETAILS_PATH, merchantDetailPath } from '@/lib/domain/merchant-detail-query';
|
|
| 33 | 33 |
import type { MerchantProduct } from '@/lib/domain/merchant-product';
|
| 34 | 34 |
import { formatPhoneNumber } from '@/lib/domain/phone-number';
|
| 35 | 35 |
import {
|
... | ... | @@ -54,11 +54,13 @@ |
| 54 | 54 |
} |
| 55 | 55 |
|
| 56 | 56 |
/** |
| 57 |
- * 가맹몰 상세 등록·수정 — 기획 BO-MAL-002(등록)·003(수정). `detail`이 있으면 수정, 없으면 등록이다. |
|
| 57 |
+ * 가맹몰 상세 — 기획 BO-MAL-002(등록)·003(수정)을 한 화면으로 합쳤다(사용자 확정). `detail`이 |
|
| 58 |
+ * 있으면 상세가 이미 있는 가맹점이고, 없으면 업체명 자리에서 상세가 없는 가맹점을 골라 처음 만든다. |
|
| 59 |
+ * 처음 저장하면 그 가맹점 주소로 옮겨 가되 화면 구성은 그대로라 바로 메뉴를 붙일 수 있고, |
|
| 60 |
+ * 이후 저장은 그 자리에 머문다. |
|
| 58 | 61 |
* |
| 59 |
- * 상세는 기존 가맹점에만 붙으므로 등록 화면은 업체명 자리에서 상세가 없는 가맹점을 고른다 |
|
| 60 |
- * (사용자 확정). 업체명·연락처·우편번호·주소는 가맹점 원본이라 읽기 전용이고, 기획의 [주소 검색]은 |
|
| 61 |
- * 가맹점 관리 화면의 것이라 여기서는 값을 바꾸지 않는다. |
|
| 62 |
+ * 업체명·연락처·우편번호·주소는 가맹점 원본이라 읽기 전용이고, 기획의 [주소 검색]은 가맹점 관리 |
|
| 63 |
+ * 화면의 것이라 여기서는 값을 바꾸지 않는다. |
|
| 62 | 64 |
*/ |
| 63 | 65 |
export function MerchantDetailForm({
|
| 64 | 66 |
detail, |
... | ... | @@ -86,6 +88,17 @@ |
| 86 | 88 |
detail?.isVisible === false ? 'N' : 'Y', |
| 87 | 89 |
]); |
| 88 | 90 |
const [voucherUse, setVoucherUse] = useState<string[]>(['Y']); |
| 91 |
+ |
|
| 92 |
+ useEffect(() => {
|
|
| 93 |
+ if (state.status !== 'success') return; |
|
| 94 |
+ if (state.created) {
|
|
| 95 |
+ showToast({ variant: 'success', message: '등록했습니다. 이어서 메뉴를 추가할 수 있습니다.' });
|
|
| 96 |
+ router.replace(merchantDetailPath(state.frcsNo)); |
|
| 97 |
+ } else {
|
|
| 98 |
+ showToast({ variant: 'success', message: '저장했습니다.' });
|
|
| 99 |
+ router.refresh(); |
|
| 100 |
+ } |
|
| 101 |
+ }, [state, router, showToast]); |
|
| 89 | 102 |
|
| 90 | 103 |
function toggleVisibility() {
|
| 91 | 104 |
if (!detail) return; |
... | ... | @@ -132,17 +145,13 @@ |
| 132 | 145 |
return ( |
| 133 | 146 |
<> |
| 134 | 147 |
<FoxPageHeader |
| 135 |
- title={`가맹몰 상세 ${isEdit ? '수정' : '등록'}`}
|
|
| 136 |
- description={
|
|
| 137 |
- isEdit |
|
| 138 |
- ? '등록된 업체 정보를 수정한다. 저장 즉시 앱에 반영된다' |
|
| 139 |
- : '지역상생몰에 노출할 업체를 등록한다' |
|
| 140 |
- } |
|
| 148 |
+ title="가맹몰 상세 정보" |
|
| 149 |
+ description="지역상생몰에 노출할 업체의 상세를 등록·수정한다. 저장 즉시 앱에 반영된다" |
|
| 141 | 150 |
breadcrumb={[
|
| 142 | 151 |
{ label: '홈', href: '/' },
|
| 143 | 152 |
{ label: '관리자관리' },
|
| 144 | 153 |
{ label: '가맹몰 상세 관리', href: MERCHANT_DETAILS_PATH },
|
| 145 |
- { label: isEdit ? '수정' : '등록' },
|
|
| 154 |
+ { label: '상세 정보' },
|
|
| 146 | 155 |
]} |
| 147 | 156 |
/> |
| 148 | 157 |
|
... | ... | @@ -309,7 +318,9 @@ |
| 309 | 318 |
description="대표 1장 필수 · 추가 최대 11장 (앱 슬라이더 최대 12장)" |
| 310 | 319 |
className={styles.section}
|
| 311 | 320 |
> |
| 321 |
+ {/* 저장으로 파일 id가 바뀌면 다시 마운트해 칸 상태를 서버 값으로 되돌린다. */}
|
|
| 312 | 322 |
<MerchantImageField |
| 323 |
+ key={`${detail?.thumbnailFileId ?? ''}/${detail?.imageFileId ?? ''}`}
|
|
| 313 | 324 |
defaultThumbnailUrl={detail?.thumbnailUrl ?? null}
|
| 314 | 325 |
retainedThumbnailId={detail?.thumbnailFileId ?? null}
|
| 315 | 326 |
defaultImageUrls={detail?.imageUrls ?? []}
|
--- app/(protected)/(basic)/system/merchant-details/[frcsNo]/page.tsx
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/page.tsx
... | ... | @@ -8,7 +8,7 @@ |
| 8 | 8 |
import { merchantDetailNewPath } from '@/lib/domain/merchant-detail-query';
|
| 9 | 9 |
import { MerchantDetailForm } from './_components/merchant-detail-form';
|
| 10 | 10 |
|
| 11 |
-export const metadata: Metadata = { title: '가맹몰 상세 수정' };
|
|
| 11 |
+export const metadata: Metadata = { title: '가맹몰 상세 정보' };
|
|
| 12 | 12 |
export const dynamic = 'force-dynamic'; |
| 13 | 13 |
|
| 14 | 14 |
interface PageProps {
|
--- app/(protected)/(basic)/system/merchant-details/_actions.ts
+++ app/(protected)/(basic)/system/merchant-details/_actions.ts
... | ... | @@ -257,10 +257,8 @@ |
| 257 | 257 |
|
| 258 | 258 |
revalidatePath(MERCHANT_DETAILS_PATH); |
| 259 | 259 |
revalidatePath(merchantDetailPath(frcsNo)); |
| 260 |
- // 등록 직후엔 같은 화면(수정 모드)에 남긴다 — 메뉴는 상세 행이 있어야 등록·조회되므로 |
|
| 261 |
- // (백엔드 목록 SQL이 상세와 INNER JOIN) 여기서 바로 [+ 메뉴 추가]로 이어지게 한다. |
|
| 262 |
- // 수정은 다른 화면처럼 목록으로 돌아간다. |
|
| 263 |
- redirect(isEdit ? MERCHANT_DETAILS_PATH : merchantDetailPath(frcsNo)); |
|
| 260 |
+ // 등록·수정이 한 화면이라 여기서 옮기지 않는다 — 화면이 결과를 보고 머물거나 주소만 바꾼다. |
|
| 261 |
+ return { status: 'success', frcsNo, created: !isEdit };
|
|
| 264 | 262 |
} |
| 265 | 263 |
|
| 266 | 264 |
export async function deleteMerchantDetailAction( |
--- app/(protected)/(basic)/system/merchant-details/_components/merchant-product-table.tsx
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-product-table.tsx
... | ... | @@ -48,7 +48,7 @@ |
| 48 | 48 |
</div> |
| 49 | 49 |
{!canAdd && (
|
| 50 | 50 |
<p className={styles.hint}>
|
| 51 |
- 아래 [등록]으로 상세를 먼저 저장하면 이어지는 수정 화면에서 메뉴를 추가할 수 있습니다. |
|
| 51 |
+ 아래 [등록]으로 상세를 먼저 저장하면 이 화면에서 바로 메뉴를 추가할 수 있습니다. |
|
| 52 | 52 |
</p> |
| 53 | 53 |
)} |
| 54 | 54 |
|
--- app/(protected)/(basic)/system/merchant-details/new/page.tsx
+++ app/(protected)/(basic)/system/merchant-details/new/page.tsx
... | ... | @@ -5,7 +5,7 @@ |
| 5 | 5 |
import { CODE_GROUP } from '@/lib/domain/common-code';
|
| 6 | 6 |
import { MerchantDetailForm } from '../[frcsNo]/_components/merchant-detail-form';
|
| 7 | 7 |
|
| 8 |
-export const metadata: Metadata = { title: '가맹몰 상세 등록' };
|
|
| 8 |
+export const metadata: Metadata = { title: '가맹몰 상세 정보' };
|
|
| 9 | 9 |
export const dynamic = 'force-dynamic'; |
| 10 | 10 |
|
| 11 | 11 |
interface PageProps {
|
--- lib/domain/merchant-detail-form.ts
+++ lib/domain/merchant-detail-form.ts
... | ... | @@ -45,7 +45,8 @@ |
| 45 | 45 |
export type MerchantDetailFormState = |
| 46 | 46 |
| { status: 'idle' }
|
| 47 | 47 |
| { status: 'error'; message?: string; errors?: MerchantDetailFormErrors }
|
| 48 |
- | { status: 'success' };
|
|
| 48 |
+ /** `created`가 참이면 이번 저장으로 상세 행이 처음 생겼다 — 화면이 그 가맹점 주소로 옮겨 간다. */ |
|
| 49 |
+ | { status: 'success'; frcsNo: string; created: boolean };
|
|
| 49 | 50 |
|
| 50 | 51 |
export const INITIAL_MERCHANT_DETAIL_FORM_STATE: MerchantDetailFormState = {
|
| 51 | 52 |
status: 'idle', |
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?