feat: 가맹몰 상세 목록에 상세가 없는 가맹점도 다시 세운다
백엔드(feature/voc) 목록은 상세 있는 가맹점만 주므로 Repository가 가맹점 목록에 상세 목록을 덧입혀 합치고, 검색·카테고리·노출·등록일 기간·페이지를 프론트에서 처리한다(사용자 확정). 상세 없는 행의 [수정]은 가맹점 목록에서 그 건을 찾아 고정한 채 처음 만드는 화면을 연다. 상세 응답에 업체명이 빠지는 결함은 가맹점 목록의 이름으로 메운다. Co-Authored-By: Claude Opus 5
@dacbc0a064b27e2a2970383603b50c7a248a6a9f
--- 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,9 +43,11 @@ |
| 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 | 52 |
products?: MerchantProduct[]; |
| 51 | 53 |
categories: CommonCode[]; |
... | ... | @@ -53,15 +55,16 @@ |
| 53 | 55 |
|
| 54 | 56 |
/** |
| 55 | 57 |
* 가맹몰 상세 — 기획 BO-MAL-002(등록)·003(수정)을 한 화면으로 합쳤다(사용자 확정). `detail`이 있으면 |
| 56 |
- * 고치고, 없으면 업체명 자리에서 상세가 없는 가맹점을 골라 처음 만든다 — 백엔드 목록·단건이 상세 있는 |
|
| 57 |
- * 가맹점만 다루므로(feature/voc) 등록은 이 선택으로만 들어온다. 첫 저장 뒤에는 그 가맹점 주소로 옮겨 |
|
| 58 |
- * 가되 화면 구성은 그대로라 바로 메뉴를 붙일 수 있고, 이후 저장은 그 자리에 머문다. |
|
| 58 |
+ * 고치고, `merchant`면 그 가맹점에 처음 만들고, 둘 다 없으면 업체명 자리에서 가맹점을 골라 만든다. |
|
| 59 |
+ * 첫 저장 뒤에는 그 가맹점 주소로 옮겨 가되 화면 구성은 그대로라 바로 메뉴를 붙일 수 있고, 이후 |
|
| 60 |
+ * 저장은 그 자리에 머문다. |
|
| 59 | 61 |
* |
| 60 | 62 |
* 업체명·연락처·우편번호·주소는 가맹점 원본이라 읽기 전용이다. 기획의 [주소 검색]은 가맹점 관리 |
| 61 | 63 |
* 화면의 것이라 여기서는 값을 바꾸지 않는다. |
| 62 | 64 |
*/ |
| 63 | 65 |
export function MerchantDetailForm({
|
| 64 | 66 |
detail, |
| 67 |
+ merchant: fixedMerchant, |
|
| 65 | 68 |
candidates = [], |
| 66 | 69 |
products = [], |
| 67 | 70 |
categories, |
... | ... | @@ -76,10 +79,11 @@ |
| 76 | 79 |
const isEdit = detail !== undefined; |
| 77 | 80 |
const errors = state.status === 'error' ? (state.errors ?? {}) : {};
|
| 78 | 81 |
|
| 79 |
- const [frcsNo, setFrcsNo] = useState(detail?.id ?? ''); |
|
| 80 |
- // 읽기 전용 칸의 출처 — 수정은 상세, 등록은 고른 가맹점. |
|
| 82 |
+ // 가맹점이 정해져 있으면(상세가 있든 없든) 업체명은 읽기 전용이고, 아니면 여기서 고른다. |
|
| 83 |
+ const pinned: MerchantDetailSummary | null = detail ?? fixedMerchant ?? null; |
|
| 84 |
+ const [frcsNo, setFrcsNo] = useState(pinned?.id ?? ''); |
|
| 81 | 85 |
const merchant: MerchantDetailSummary | null = |
| 82 |
- detail ?? candidates.find((row) => row.id === frcsNo) ?? null; |
|
| 86 |
+ pinned ?? candidates.find((row) => row.id === frcsNo) ?? null; |
|
| 83 | 87 |
const [categoryCode, setCategoryCode] = useState(detail?.categoryCode ?? ''); |
| 84 | 88 |
const [visibility, setVisibility] = useState<string[]>([ |
| 85 | 89 |
detail?.isVisible === false ? 'N' : 'Y', |
... | ... | @@ -163,8 +167,8 @@ |
| 163 | 167 |
<FoxCard size="lg" title="기본 정보" className={styles.section}>
|
| 164 | 168 |
<div className={styles.grid}>
|
| 165 | 169 |
<div className={styles.fieldWide}>
|
| 166 |
- {detail ? (
|
|
| 167 |
- <FoxInput size="md" label="업체명" requirement="required" value={detail.name} readOnly state="view" />
|
|
| 170 |
+ {pinned ? (
|
|
| 171 |
+ <FoxInput size="md" label="업체명" requirement="required" value={pinned.name} readOnly state="view" />
|
|
| 168 | 172 |
) : ( |
| 169 | 173 |
<> |
| 170 | 174 |
<FoxSelect |
--- app/(protected)/(basic)/system/merchant-details/[frcsNo]/page.tsx
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/page.tsx
... | ... | @@ -2,7 +2,10 @@ |
| 2 | 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 |
-import { fetchMerchantDetail } from '@/lib/data/repositories/merchant-detail-repository';
|
|
| 5 |
+import {
|
|
| 6 |
+ fetchMerchantDetail, |
|
| 7 |
+ fetchMerchantSummary, |
|
| 8 |
+} from '@/lib/data/repositories/merchant-detail-repository'; |
|
| 6 | 9 |
import { fetchMerchantProducts } from '@/lib/data/repositories/merchant-product-repository';
|
| 7 | 10 |
import { CODE_GROUP } from '@/lib/domain/common-code';
|
| 8 | 11 |
import { MerchantDetailForm } from './_components/merchant-detail-form';
|
... | ... | @@ -15,22 +18,35 @@ |
| 15 | 18 |
} |
| 16 | 19 |
|
| 17 | 20 |
/** |
| 18 |
- * 상세가 있는 가맹점의 상세 화면. 상세가 없으면 백엔드(feature/voc)가 NOT_FOUND를 주므로 404다 — |
|
| 19 |
- * develop 백엔드는 가맹점 행을 그대로 주므로 `hasDetail`로 한 번 더 거른다(수정이 빈 UPDATE가 되지 않게). |
|
| 21 |
+ * 가맹점 하나의 상세 화면. 상세가 있으면 고치고, 없으면(백엔드 단건이 NOT_FOUND — develop은 빈 가맹점 |
|
| 22 |
+ * 행을 주므로 `hasDetail`로 한 번 더 거른다) 가맹점 목록에서 찾아 그 가맹점을 고정한 채 처음 만든다. |
|
| 20 | 23 |
*/ |
| 21 | 24 |
export default async function Page({ params }: PageProps) {
|
| 22 | 25 |
await verifySession(); |
| 23 | 26 |
|
| 24 | 27 |
const { frcsNo } = await params;
|
| 28 |
+ const categoriesPromise = fetchCommonCodes(CODE_GROUP.merchantType); |
|
| 25 | 29 |
const detail = await fetchMerchantDetail(frcsNo); |
| 30 |
+ |
|
| 26 | 31 |
if (!detail || !detail.hasDetail) {
|
| 27 |
- notFound(); |
|
| 32 |
+ const merchant = await fetchMerchantSummary(frcsNo); |
|
| 33 |
+ if (!merchant) {
|
|
| 34 |
+ notFound(); |
|
| 35 |
+ } |
|
| 36 |
+ return <MerchantDetailForm merchant={merchant} categories={await categoriesPromise} />;
|
|
| 28 | 37 |
} |
| 29 | 38 |
|
| 30 |
- const [products, categories] = await Promise.all([ |
|
| 39 |
+ const [products, categories, merchant] = await Promise.all([ |
|
| 31 | 40 |
fetchMerchantProducts(frcsNo), |
| 32 |
- fetchCommonCodes(CODE_GROUP.merchantType), |
|
| 41 |
+ categoriesPromise, |
|
| 42 |
+ // 상세 응답에 업체명이 빠지는 백엔드 결함 — 코드만 왔으면 가맹점 목록에서 이름을 채운다. |
|
| 43 |
+ detail.name === detail.id ? fetchMerchantSummary(frcsNo) : Promise.resolve(null), |
|
| 33 | 44 |
]); |
| 34 |
- |
|
| 35 |
- return <MerchantDetailForm detail={detail} products={products} categories={categories} />;
|
|
| 45 |
+ return ( |
|
| 46 |
+ <MerchantDetailForm |
|
| 47 |
+ detail={merchant ? { ...detail, name: merchant.name } : detail}
|
|
| 48 |
+ products={products}
|
|
| 49 |
+ categories={categories}
|
|
| 50 |
+ /> |
|
| 51 |
+ ); |
|
| 36 | 52 |
} |
--- 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 |
- * 백엔드(feature/voc)가 상세가 있는 가맹점만 주므로 모든 행이 상세 행이다. [+ 업체 등록]은 상세 화면 |
|
| 60 |
- * (등록)으로 가서 상세가 없는 가맹점을 고르고, 행의 [수정]은 그 가맹점의 상세 화면으로 간다. |
|
| 59 |
+ * 가맹점 전체가 서고 상세가 없는 건은 상태·등록일이 `-`다(사용자 확정) — Repository가 가맹점 목록에 |
|
| 60 |
+ * 상세를 덧입혀 준다. [+ 업체 등록]은 가맹점을 고르는 등록 화면으로, 행의 [수정]은 그 가맹점의 |
|
| 61 |
+ * 상세 화면으로 간다(상세가 없으면 거기서 처음 만든다). |
|
| 61 | 62 |
* |
| 62 | 63 |
* 이슈: 메뉴 건수 열은 목록 응답에 없어 `-`다(보고함). 카테고리·상태도 백엔드가 아직 주지 |
| 63 | 64 |
* 않아 대부분 `-`로 보인다. |
... | ... | @@ -317,7 +318,7 @@ |
| 317 | 318 |
onValueChange={(value) => setDraft((current) => ({ ...current, visibility: value }))}
|
| 318 | 319 |
/> |
| 319 | 320 |
</div> |
| 320 |
- {/* 등록일 기간 — 백엔드가 아직 조건에 쓰지 않는다(보고함). 값은 그대로 보낸다. */}
|
|
| 321 |
+ {/* 등록일 기간 — 백엔드가 조건에 쓰지 않아 Repository가 등록일로 거른다. */}
|
|
| 321 | 322 |
<div className={styles.period}>
|
| 322 | 323 |
<FoxInput |
| 323 | 324 |
size="md" |
--- app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-row-actions.tsx
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-row-actions.tsx
... | ... | @@ -16,7 +16,11 @@ |
| 16 | 16 |
row: MerchantDetailSummary; |
| 17 | 17 |
} |
| 18 | 18 |
|
| 19 |
-/** 기획 BO-MAL-001 관리 열 — [수정] [비활성|노출] [삭제]. 목록 행은 모두 상세가 있는 가맹점이다. */ |
|
| 19 |
+/** |
|
| 20 |
+ * 기획 BO-MAL-001 관리 열 — 모든 행에 [수정] [비활성|노출] [삭제](시안대로, 사용자 확정). [수정]은 그 |
|
| 21 |
+ * 가맹점의 상세 화면으로 가고(상세가 없으면 거기서 처음 만든다), 상세가 없는 행의 [비활성]·[삭제]는 |
|
| 22 |
+ * 백엔드가 NOT_FOUND로 거절해 사유가 토스트로 올라온다. |
|
| 23 |
+ */ |
|
| 20 | 24 |
export function MerchantDetailRowActions({ row }: MerchantDetailRowActionsProps) {
|
| 21 | 25 |
const router = useRouter(); |
| 22 | 26 |
const { showAlert, hideAlert, showToast } = useFeedback();
|
--- lib/data/repositories/merchant-detail-repository.ts
+++ lib/data/repositories/merchant-detail-repository.ts
... | ... | @@ -19,7 +19,7 @@ |
| 19 | 19 |
* POST /api/v1/mngr/voc/detail @RequestBody · 상세가 있으면 409, 가맹점이 없으면 404 |
| 20 | 20 |
* PUT /api/v1/mngr/voc/detail/{frcsNo} @RequestBody · 전 컬럼 덮어씀
|
| 21 | 21 |
* DELETE /api/v1/mngr/voc/{frcsNo} 논리 삭제(DEL_YN)
|
| 22 |
- * GET /api/v1/mngr/voc/pagination 가맹점 전체 — 등록 화면의 선택지에만 쓴다 |
|
| 22 |
+ * GET /api/v1/mngr/voc/pagination 가맹점 전체 — 목록 합치기·등록 선택지·상세 없는 단건에 쓴다 |
|
| 23 | 23 |
* ``` |
| 24 | 24 |
* |
| 25 | 25 |
* 목록·단건이 소개·카테고리 코드·영업시간·휴무일·추가이미지·순서·우편번호·등록자(`frstRgtrNmStr`)· |
... | ... | @@ -180,44 +180,57 @@ |
| 180 | 180 |
}; |
| 181 | 181 |
} |
| 182 | 182 |
|
| 183 |
+/** |
|
| 184 |
+ * 목록 — 가맹점 전체에 상세를 덧입혀 상세가 없는 가맹점도 세운다(사용자 확정). 백엔드 목록은 상세가 |
|
| 185 |
+ * 있는 것만 주므로 가맹점 목록과 상세 목록을 모두 받아 합치고, 검색·기간·페이지는 여기서 처리한다. |
|
| 186 |
+ * 부수 효과로 백엔드가 쓰지 않던 등록일 기간 조건과 응답에 빠진 업체명(결함 1)이 여기서 채워진다. |
|
| 187 |
+ */ |
|
| 183 | 188 |
export async function fetchMerchantDetails( |
| 184 | 189 |
query: MerchantDetailQuery |
| 185 | 190 |
): Promise<{ items: MerchantDetailSummary[]; totalCount: number }> {
|
| 186 |
- const accessToken = await getSessionAccessToken(); |
|
| 187 |
- |
|
| 188 |
- const result = await backendFetch<unknown>(`${DETAIL_PATH}/pagination`, {
|
|
| 189 |
- method: 'GET', |
|
| 190 |
- query: {
|
|
| 191 |
- ...(query.keyword ? { searchFrcsNm: query.keyword } : {}),
|
|
| 192 |
- ...(query.category ? { searchFrcsTypeCd: query.category } : {}),
|
|
| 193 |
- ...(query.visibility ? { searchUseYn: query.visibility } : {}),
|
|
| 194 |
- ...(query.startDate ? { searchStartDt: query.startDate } : {}),
|
|
| 195 |
- ...(query.endDate ? { searchEndDt: query.endDate } : {}),
|
|
| 196 |
- pageIndex: query.page, |
|
| 197 |
- recordCountPerPage: query.pageSize, |
|
| 198 |
- }, |
|
| 199 |
- accessToken: accessToken ?? undefined, |
|
| 200 |
- cache: 'no-store', |
|
| 201 |
- canHaveNullData: true, |
|
| 191 |
+ const merged = await fetchMergedSummaries(); |
|
| 192 |
+ const keyword = query.keyword.trim().toLowerCase(); |
|
| 193 |
+ const filtered = merged.filter((row) => {
|
|
| 194 |
+ if (keyword && !row.name.toLowerCase().includes(keyword)) return false; |
|
| 195 |
+ if (query.category && row.categoryCode !== query.category) return false; |
|
| 196 |
+ if (query.visibility && row.isVisible !== (query.visibility === 'Y')) return false; |
|
| 197 |
+ if (query.startDate && (row.registeredAt === null || row.registeredAt < query.startDate)) return false; |
|
| 198 |
+ if (query.endDate && (row.registeredAt === null || row.registeredAt > query.endDate)) return false; |
|
| 199 |
+ return true; |
|
| 202 | 200 |
}); |
| 203 |
- |
|
| 204 |
- if (!result.ok) {
|
|
| 205 |
- throw new BackendRequestError(result); |
|
| 206 |
- } |
|
| 207 |
- |
|
| 208 |
- const data = result.data; |
|
| 209 |
- if (data === null || data === undefined) {
|
|
| 210 |
- return { items: [], totalCount: 0 };
|
|
| 211 |
- } |
|
| 212 |
- if (!isRecord(data) || !Array.isArray(data.list)) {
|
|
| 213 |
- throw new Error('가맹몰 상세 목록 응답의 형식이 올바르지 않습니다.');
|
|
| 214 |
- } |
|
| 215 |
- |
|
| 201 |
+ const start = (query.page - 1) * query.pageSize; |
|
| 216 | 202 |
return {
|
| 217 |
- items: data.list.flatMap(toSummary), |
|
| 218 |
- totalCount: |
|
| 219 |
- typeof data.totalCount === 'number' ? data.totalCount : data.list.length, |
|
| 203 |
+ items: filtered.slice(start, start + query.pageSize), |
|
| 204 |
+ totalCount: filtered.length, |
|
| 220 | 205 |
}; |
| 206 |
+} |
|
| 207 |
+ |
|
| 208 |
+/** 상세 있는 가맹점을 등록일 내림차순으로 먼저, 나머지 가맹점을 코드 내림차순으로 뒤에 둔다. */ |
|
| 209 |
+function compareSummaryOrder(a: MerchantDetailSummary, b: MerchantDetailSummary): number {
|
|
| 210 |
+ if (a.hasDetail !== b.hasDetail) return a.hasDetail ? -1 : 1; |
|
| 211 |
+ const dateA = a.registeredAt ?? ''; |
|
| 212 |
+ const dateB = b.registeredAt ?? ''; |
|
| 213 |
+ if (dateA !== dateB) return dateA < dateB ? 1 : -1; |
|
| 214 |
+ return b.id.localeCompare(a.id, undefined, { numeric: true });
|
|
| 215 |
+} |
|
| 216 |
+ |
|
| 217 |
+async function fetchMergedSummaries(): Promise<MerchantDetailSummary[]> {
|
|
| 218 |
+ const [merchants, details] = await Promise.all([ |
|
| 219 |
+ fetchAllPages(`${VOC_PATH}/pagination`, toCandidate),
|
|
| 220 |
+ fetchAllPages(`${DETAIL_PATH}/pagination`, toSummary),
|
|
| 221 |
+ ]); |
|
| 222 |
+ const byId = new Map(merchants.map((row) => [row.id, row])); |
|
| 223 |
+ for (const detail of details) {
|
|
| 224 |
+ // develop 백엔드(LEFT JOIN)는 상세 없는 가맹점도 섞어 준다 — 상세 있는 행만 덧입힌다. |
|
| 225 |
+ if (!detail.hasDetail) continue; |
|
| 226 |
+ const merchant = byId.get(detail.id); |
|
| 227 |
+ byId.set(detail.id, {
|
|
| 228 |
+ ...detail, |
|
| 229 |
+ // 결함 1 — 상세 응답에 업체명이 없으면 가맹점 목록의 이름을 쓴다. |
|
| 230 |
+ name: detail.name === detail.id && merchant ? merchant.name : detail.name, |
|
| 231 |
+ }); |
|
| 232 |
+ } |
|
| 233 |
+ return [...byId.values()].sort(compareSummaryOrder); |
|
| 221 | 234 |
} |
| 222 | 235 |
|
| 223 | 236 |
export async function fetchMerchantDetail( |
... | ... | @@ -283,18 +296,17 @@ |
| 283 | 296 |
return rows; |
| 284 | 297 |
} |
| 285 | 298 |
|
| 286 |
-/** |
|
| 287 |
- * 등록 화면의 업체 선택지 — 가맹점 전체에서 이미 상세가 있는 것을 뺀다. 백엔드에 "상세 없는 가맹점" |
|
| 288 |
- * 조회가 없어 두 목록을 훑어 차집합을 만든다. |
|
| 289 |
- */ |
|
| 299 |
+/** 등록 화면의 업체 선택지 — 아직 상세가 없는 가맹점 전부. */ |
|
| 290 | 300 |
export async function fetchMerchantDetailCandidates(): Promise<MerchantDetailSummary[]> {
|
| 291 |
- const [merchants, registered] = await Promise.all([ |
|
| 292 |
- fetchAllPages(`${VOC_PATH}/pagination`, toCandidate),
|
|
| 293 |
- fetchAllPages(`${DETAIL_PATH}/pagination`, toSummary),
|
|
| 294 |
- ]); |
|
| 295 |
- // develop 백엔드(LEFT JOIN)는 상세 없는 가맹점도 목록에 섞어 주므로 상세가 있는 것만 뺀다. |
|
| 296 |
- const registeredIds = new Set(registered.filter((row) => row.hasDetail).map((row) => row.id)); |
|
| 297 |
- return merchants.filter((row) => !registeredIds.has(row.id)); |
|
| 301 |
+ return (await fetchMergedSummaries()).filter((row) => !row.hasDetail); |
|
| 302 |
+} |
|
| 303 |
+ |
|
| 304 |
+/** 가맹점 한 건(상세 없이) — 가맹점 단건 API가 없어 목록에서 찾는다. */ |
|
| 305 |
+export async function fetchMerchantSummary( |
|
| 306 |
+ frcsNo: string |
|
| 307 |
+): Promise<MerchantDetailSummary | null> {
|
|
| 308 |
+ const merchants = await fetchAllPages(`${VOC_PATH}/pagination`, toCandidate);
|
|
| 309 |
+ return merchants.find((row) => row.id === frcsNo) ?? null; |
|
| 298 | 310 |
} |
| 299 | 311 |
|
| 300 | 312 |
function toWritePayload(values: MerchantDetailValues): Record<string, unknown> {
|
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?