feat: 가맹몰 상세 목록에 상세가 없는 가맹점도 다시 보이게 한다
목록은 백엔드 페이지를 그대로 쓰고(전 페이지 훑기는 등록 화면의 가맹점 선택지에만 남긴다), 상세가 없는 행의 [수정]은 그 가맹점을 고정한 상세 화면으로 가서 거기서 처음 만든다 (사용자 확정). 등록 화면으로 되돌리던 리다이렉트와 그 경로 도우미는 지운다. Co-Authored-By: Claude Opus 5
@a60a1e96a9c2b5a8e27be6711153dfa4da168f39
--- app/(protected)/(basic)/system/merchant-details/[frcsNo]/_components/merchant-detail-form.tsx
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/_components/merchant-detail-form.tsx
... | ... | @@ -43,11 +43,13 @@ |
| 43 | 43 |
import styles from './merchant-detail-form.module.scss'; |
| 44 | 44 |
|
| 45 | 45 |
interface MerchantDetailFormProps {
|
| 46 |
- /** 수정 — 상세가 있는 가맹점. 없으면 등록 화면이다. */ |
|
| 46 |
+ /** 상세가 있는 가맹점 — 고친다. */ |
|
| 47 | 47 |
detail?: MerchantDetail; |
| 48 |
- /** 등록 — 아직 상세가 없는 가맹점 선택지. 업체명 자리에서 하나를 고른다. */ |
|
| 48 |
+ /** 상세가 아직 없는데 가맹점은 정해진 경우(행의 [수정]) — 그 가맹점에 처음 만든다. */ |
|
| 49 |
+ merchant?: MerchantDetailSummary; |
|
| 50 |
+ /** 가맹점도 정해지지 않은 경우([+ 업체 등록]) — 업체명 자리에서 여기서 하나를 고른다. */ |
|
| 49 | 51 |
candidates?: MerchantDetailSummary[]; |
| 50 |
- /** 등록 — 미리 골라 둘 가맹점(`?frcsNo=`). */ |
|
| 52 |
+ /** 선택지 중 미리 골라 둘 가맹점(`?frcsNo=`). */ |
|
| 51 | 53 |
initialFrcsNo?: string; |
| 52 | 54 |
products?: MerchantProduct[]; |
| 53 | 55 |
categories: CommonCode[]; |
... | ... | @@ -55,15 +57,16 @@ |
| 55 | 57 |
|
| 56 | 58 |
/** |
| 57 | 59 |
* 가맹몰 상세 — 기획 BO-MAL-002(등록)·003(수정)을 한 화면으로 합쳤다(사용자 확정). `detail`이 |
| 58 |
- * 있으면 상세가 이미 있는 가맹점이고, 없으면 업체명 자리에서 상세가 없는 가맹점을 골라 처음 만든다. |
|
| 59 |
- * 처음 저장하면 그 가맹점 주소로 옮겨 가되 화면 구성은 그대로라 바로 메뉴를 붙일 수 있고, |
|
| 60 |
- * 이후 저장은 그 자리에 머문다. |
|
| 60 |
+ * 있으면 상세가 이미 있는 가맹점, `merchant`면 상세가 없는 정해진 가맹점, 둘 다 없으면 업체명 |
|
| 61 |
+ * 자리에서 가맹점을 고른다. 처음 저장하면 그 가맹점 주소로 옮겨 가되 화면 구성은 그대로라 바로 |
|
| 62 |
+ * 메뉴를 붙일 수 있고, 이후 저장은 그 자리에 머문다. |
|
| 61 | 63 |
* |
| 62 | 64 |
* 업체명·연락처·우편번호·주소는 가맹점 원본이라 읽기 전용이고, 기획의 [주소 검색]은 가맹점 관리 |
| 63 | 65 |
* 화면의 것이라 여기서는 값을 바꾸지 않는다. |
| 64 | 66 |
*/ |
| 65 | 67 |
export function MerchantDetailForm({
|
| 66 | 68 |
detail, |
| 69 |
+ merchant: fixedMerchant, |
|
| 67 | 70 |
candidates = [], |
| 68 | 71 |
initialFrcsNo, |
| 69 | 72 |
products = [], |
... | ... | @@ -79,10 +82,11 @@ |
| 79 | 82 |
const isEdit = detail !== undefined; |
| 80 | 83 |
const errors = state.status === 'error' ? (state.errors ?? {}) : {};
|
| 81 | 84 |
|
| 82 |
- const [frcsNo, setFrcsNo] = useState(detail?.id ?? initialFrcsNo ?? ''); |
|
| 83 |
- // 읽기 전용 칸의 출처 — 수정은 상세, 등록은 고른 가맹점. |
|
| 85 |
+ // 가맹점이 정해져 있으면(상세가 있든 없든) 업체명은 읽기 전용이고, 아니면 여기서 고른다. |
|
| 86 |
+ const pinned: MerchantDetailSummary | null = detail ?? fixedMerchant ?? null; |
|
| 87 |
+ const [frcsNo, setFrcsNo] = useState(pinned?.id ?? initialFrcsNo ?? ''); |
|
| 84 | 88 |
const merchant: MerchantDetailSummary | null = |
| 85 |
- detail ?? candidates.find((row) => row.id === frcsNo) ?? null; |
|
| 89 |
+ pinned ?? candidates.find((row) => row.id === frcsNo) ?? null; |
|
| 86 | 90 |
const [categoryCode, setCategoryCode] = useState(detail?.categoryCode ?? ''); |
| 87 | 91 |
const [visibility, setVisibility] = useState<string[]>([ |
| 88 | 92 |
detail?.isVisible === false ? 'N' : 'Y', |
... | ... | @@ -166,8 +170,8 @@ |
| 166 | 170 |
<FoxCard size="lg" title="기본 정보" className={styles.section}>
|
| 167 | 171 |
<div className={styles.grid}>
|
| 168 | 172 |
<div className={styles.fieldWide}>
|
| 169 |
- {detail ? (
|
|
| 170 |
- <FoxInput size="md" label="업체명" requirement="required" value={detail.name} readOnly state="view" />
|
|
| 173 |
+ {pinned ? (
|
|
| 174 |
+ <FoxInput size="md" label="업체명" requirement="required" value={pinned.name} readOnly state="view" />
|
|
| 171 | 175 |
) : ( |
| 172 | 176 |
<> |
| 173 | 177 |
<FoxSelect |
--- app/(protected)/(basic)/system/merchant-details/[frcsNo]/page.tsx
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/page.tsx
... | ... | @@ -1,11 +1,10 @@ |
| 1 | 1 |
import type { Metadata } from 'next';
|
| 2 |
-import { notFound, redirect } from 'next/navigation';
|
|
| 2 |
+import { notFound } from 'next/navigation';
|
|
| 3 | 3 |
import { verifySession } from '@/lib/auth/dal';
|
| 4 | 4 |
import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository';
|
| 5 | 5 |
import { fetchMerchantDetail } from '@/lib/data/repositories/merchant-detail-repository';
|
| 6 | 6 |
import { fetchMerchantProducts } from '@/lib/data/repositories/merchant-product-repository';
|
| 7 | 7 |
import { CODE_GROUP } from '@/lib/domain/common-code';
|
| 8 |
-import { merchantDetailNewPath } from '@/lib/domain/merchant-detail-query';
|
|
| 9 | 8 |
import { MerchantDetailForm } from './_components/merchant-detail-form';
|
| 10 | 9 |
|
| 11 | 10 |
export const metadata: Metadata = { title: '가맹몰 상세 정보' };
|
... | ... | @@ -15,7 +14,7 @@ |
| 15 | 14 |
params: Promise<{ frcsNo: string }>;
|
| 16 | 15 |
} |
| 17 | 16 |
|
| 18 |
-/** 수정 화면 — 상세가 있는 가맹점만. 아직 없는 가맹점이면 등록 화면으로 보내 거기서 고르게 한다. */ |
|
| 17 |
+/** 가맹점 하나의 상세 화면 — 상세가 아직 없으면 그 가맹점을 고정한 채 처음 만드는 화면이 된다. */ |
|
| 19 | 18 |
export default async function Page({ params }: PageProps) {
|
| 20 | 19 |
await verifySession(); |
| 21 | 20 |
|
... | ... | @@ -24,14 +23,15 @@ |
| 24 | 23 |
if (!detail) {
|
| 25 | 24 |
notFound(); |
| 26 | 25 |
} |
| 27 |
- if (!detail.hasDetail) {
|
|
| 28 |
- redirect(merchantDetailNewPath(frcsNo)); |
|
| 29 |
- } |
|
| 30 | 26 |
|
| 27 |
+ // 상세가 없으면 백엔드 메뉴 목록도 비어 온다(상세와 INNER JOIN) — 부르지 않는다. |
|
| 31 | 28 |
const [products, categories] = await Promise.all([ |
| 32 |
- fetchMerchantProducts(frcsNo), |
|
| 29 |
+ detail.hasDetail ? fetchMerchantProducts(frcsNo) : Promise.resolve([]), |
|
| 33 | 30 |
fetchCommonCodes(CODE_GROUP.merchantType), |
| 34 | 31 |
]); |
| 35 | 32 |
|
| 33 |
+ if (!detail.hasDetail) {
|
|
| 34 |
+ return <MerchantDetailForm merchant={detail} categories={categories} />;
|
|
| 35 |
+ } |
|
| 36 | 36 |
return <MerchantDetailForm detail={detail} products={products} categories={categories} />;
|
| 37 | 37 |
} |
--- app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-list.tsx
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-list.tsx
... | ... | @@ -56,8 +56,9 @@ |
| 56 | 56 |
/** |
| 57 | 57 |
* 가맹몰 상세 목록 — 기획 BO-MAL-001을 「관리자관리 > 가맹몰 상세 관리」로 옮긴 것. |
| 58 | 58 |
* |
| 59 |
- * 상세가 등록된 가맹점만 선다(사용자 확정) — Repository가 가맹점 전체에서 걸러 준다. [+ 업체 등록]은 |
|
| 60 |
- * 등록 화면으로 가서 상세가 없는 가맹점을 고르고, 행의 [수정]은 수정 화면으로 간다. |
|
| 59 |
+ * 가맹점 전체가 서고 상세가 없는 건은 상태·등록일이 `-`다(사용자 확정). [+ 업체 등록]은 상세 |
|
| 60 |
+ * 화면에서 가맹점을 고르게 하고, 행의 [수정]은 그 가맹점의 상세 화면으로 간다 — 상세가 없으면 |
|
| 61 |
+ * 거기서 처음 만든다. |
|
| 61 | 62 |
* |
| 62 | 63 |
* 이슈: 메뉴 건수 열은 목록 응답에 없어 `-`다(보고함). 카테고리·상태도 백엔드가 아직 주지 |
| 63 | 64 |
* 않아 대부분 `-`로 보인다. |
--- app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-row-actions.tsx
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-row-actions.tsx
... | ... | @@ -17,8 +17,9 @@ |
| 17 | 17 |
} |
| 18 | 18 |
|
| 19 | 19 |
/** |
| 20 |
- * 기획 BO-MAL-001 관리 열 — [수정] [비활성|노출] [삭제]. 목록에는 상세가 있는 가맹점만 서므로 |
|
| 21 |
- * [수정]은 수정 화면으로 간다. |
|
| 20 |
+ * 기획 BO-MAL-001 관리 열 — 모든 행에 [수정] [비활성|노출] [삭제](시안대로, 사용자 확정). |
|
| 21 |
+ * [수정]은 그 가맹점의 상세 화면으로 가고(상세가 없으면 거기서 처음 만든다), 상세가 없는 행의 |
|
| 22 |
+ * [비활성]·[삭제]는 백엔드가 NOT_FOUND로 거절해 사유가 토스트로 올라온다. |
|
| 22 | 23 |
*/ |
| 23 | 24 |
export function MerchantDetailRowActions({ row }: MerchantDetailRowActionsProps) {
|
| 24 | 25 |
const router = useRouter(); |
--- lib/data/repositories/merchant-detail-repository.ts
+++ lib/data/repositories/merchant-detail-repository.ts
... | ... | @@ -15,7 +15,7 @@ |
| 15 | 15 |
* |
| 16 | 16 |
* ``` |
| 17 | 17 |
* GET /api/v1/mngr/voc/detail/pagination TB_COM_VOUCHER ⟕ TB_COM_VOUCHER_DTL — 가맹점 전체 |
| 18 |
- * (화면은 상세 있는 건만 목록에, 없는 건은 등록 선택지에) |
|
| 18 |
+ * (상세 없는 건은 hasDetail=false로 목록에도 선다) |
|
| 19 | 19 |
* GET /api/v1/mngr/voc/detail/{frcsNo}
|
| 20 | 20 |
* POST /api/v1/mngr/voc/detail @RequestBody · 가맹점이 있어야 붙는다 |
| 21 | 21 |
* PUT /api/v1/mngr/voc/detail/{frcsNo} @RequestBody
|
... | ... | @@ -196,7 +196,7 @@ |
| 196 | 196 |
}; |
| 197 | 197 |
} |
| 198 | 198 |
|
| 199 |
-// 백엔드 목록에는 "상세가 있는 건만" 조건이 없다(보고함). 그래서 전 페이지를 받아 여기서 거른다 — |
|
| 199 |
+// 등록 화면의 선택지("상세가 없는 가맹점")를 백엔드가 걸러 주지 않아 전 페이지를 받아 거른다 —
|
|
| 200 | 200 |
// 가맹점은 많아야 수백 건이라 한두 번의 요청이다. 상한을 넘으면 그 뒤는 잘린다. |
| 201 | 201 |
// TODO(백엔드): 상세 유무 조건이 생기면 이 훑기를 없애고 페이지 하나만 받는다. |
| 202 | 202 |
const SCAN_PAGE_SIZE = 200; |
... | ... | @@ -218,16 +218,12 @@ |
| 218 | 218 |
return items; |
| 219 | 219 |
} |
| 220 | 220 |
|
| 221 |
-/** 목록 — 상세가 있는 가맹점만, 화면 쪽 페이지로 잘라 준다(사용자 확정). */ |
|
| 221 |
+/** 목록 — 가맹점 전체가 오며 상세가 없는 건도 그대로 선다(사용자 확정). */ |
|
| 222 | 222 |
export async function fetchMerchantDetails( |
| 223 | 223 |
query: MerchantDetailQuery |
| 224 | 224 |
): Promise<{ items: MerchantDetailSummary[]; totalCount: number }> {
|
| 225 |
- const registered = (await fetchAllSummaries(query)).filter((row) => row.hasDetail); |
|
| 226 |
- const start = (query.page - 1) * query.pageSize; |
|
| 227 |
- return {
|
|
| 228 |
- items: registered.slice(start, start + query.pageSize), |
|
| 229 |
- totalCount: registered.length, |
|
| 230 |
- }; |
|
| 225 |
+ const { items, totalCount } = await fetchSummaryPage(query, query.page, query.pageSize);
|
|
| 226 |
+ return { items, totalCount };
|
|
| 231 | 227 |
} |
| 232 | 228 |
|
| 233 | 229 |
/** 등록 화면의 업체 선택지 — 아직 상세가 없는 가맹점 전부. */ |
--- lib/domain/merchant-detail-query.ts
+++ lib/domain/merchant-detail-query.ts
... | ... | @@ -7,13 +7,6 @@ |
| 7 | 7 |
return `${MERCHANT_DETAILS_PATH}/${encodeURIComponent(frcsNo)}`;
|
| 8 | 8 |
} |
| 9 | 9 |
|
| 10 |
-/** 등록 화면. `frcsNo`를 주면 그 가맹점을 미리 골라 둔다. */ |
|
| 11 |
-export function merchantDetailNewPath(frcsNo?: string): string {
|
|
| 12 |
- return frcsNo |
|
| 13 |
- ? `${MERCHANT_DETAIL_NEW_PATH}?frcsNo=${encodeURIComponent(frcsNo)}`
|
|
| 14 |
- : MERCHANT_DETAIL_NEW_PATH; |
|
| 15 |
-} |
|
| 16 |
- |
|
| 17 | 10 |
export function merchantProductNewPath(frcsNo: string): string {
|
| 18 | 11 |
return `${merchantDetailPath(frcsNo)}/products/new`;
|
| 19 | 12 |
} |
--- lib/domain/merchant-detail.ts
+++ lib/domain/merchant-detail.ts
... | ... | @@ -7,10 +7,7 @@ |
| 7 | 7 |
* 가져오고, 상세 항목만 등록·수정한다(사용자 확정). |
| 8 | 8 |
*/ |
| 9 | 9 |
|
| 10 |
-/** |
|
| 11 |
- * 가맹점 한 건 + 상세 유무. 백엔드 목록은 가맹점 전체를 주지만 화면의 목록에는 상세가 있는 |
|
| 12 |
- * 건만 서고(사용자 확정), 상세가 없는 건은 등록 화면의 업체 선택지가 된다. |
|
| 13 |
- */ |
|
| 10 |
+/** 목록 한 행 — 가맹점 전체가 오며 상세가 없는 건은 `hasDetail`이 false다(그대로 목록에 선다). */ |
|
| 14 | 11 |
export type MerchantDetailSummary = {
|
| 15 | 12 |
/** 백엔드 `frcsNo` — 가맹점 코드이자 상세의 키. */ |
| 16 | 13 |
id: string; |
... | ... | @@ -28,7 +25,7 @@ |
| 28 | 25 |
isVisible: boolean | null; |
| 29 | 26 |
/** 상세 등록일(`d.FRST_REG_DT`). 없으면 상세가 아직 없는 가맹점이다. */ |
| 30 | 27 |
registeredAt: string | null; |
| 31 |
- /** 상세 행이 있는지 — 목록에 서는지, 등록 화면의 선택지가 되는지를 가른다. */ |
|
| 28 |
+ /** 상세 행이 있는지 — 일괄 비활성·삭제 대상인지, 메뉴를 붙일 수 있는지를 가른다. */ |
|
| 32 | 29 |
hasDetail: boolean; |
| 33 | 30 |
}; |
| 34 | 31 |
|
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?