feat: 가맹몰 상세 목록의 필터는 [적용]을 눌러야 반영된다
카테고리·노출 상태·등록일 기간은 고를 때마다 조회하던 것을 초안으로만 두고 [적용] 한 번에 보낸다(사용자 확정). 업체명 검색은 검색 상자가 그대로 바로 반영한다. 등록일 규칙 — 시작일은 오늘을 넘지 못하고 종료일은 시작일보다 앞설 수 없다. 피커의 max/min으로 막고, 어기는 값은 clampDateRange가 경계로 당긴다. URL 파싱도 같은 함수를 쓴다 — 주소로 직접 넣은 값도 규칙을 지킨다. 오늘 날짜는 page.tsx가 정해 prop으로 내린다. 클라이언트에서 계산하면 서버·브라우저의 시간대·자정 경계에서 hydration이 어긋난다. Co-Authored-By: Claude Opus 5
@06d1bbd86cbe6a1c516904634fba1c88ac9b7ca0
--- app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-list.tsx
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-list.tsx
... | ... | @@ -27,6 +27,7 @@ |
| 27 | 27 |
FILTER_ALL, |
| 28 | 28 |
MERCHANT_DETAIL_PAGE_SIZE_OPTIONS, |
| 29 | 29 |
buildMerchantDetailHref, |
| 30 |
+ clampDateRange, |
|
| 30 | 31 |
type MerchantDetailPageSize, |
| 31 | 32 |
type MerchantDetailQuery, |
| 32 | 33 |
} from '@/lib/domain/merchant-detail-query'; |
... | ... | @@ -45,6 +46,8 @@ |
| 45 | 46 |
currentPage: number; |
| 46 | 47 |
totalPages: number; |
| 47 | 48 |
totalCount: number; |
| 49 |
+ /** 서버가 정한 오늘(YYYY-MM-DD). 여기서 계산하면 서버·브라우저 시간대 차이로 hydration이 어긋난다. */ |
|
| 50 |
+ today: string; |
|
| 48 | 51 |
} |
| 49 | 52 |
|
| 50 | 53 |
/** |
... | ... | @@ -63,6 +66,7 @@ |
| 63 | 66 |
currentPage, |
| 64 | 67 |
totalPages, |
| 65 | 68 |
totalCount, |
| 69 |
+ today, |
|
| 66 | 70 |
}: MerchantDetailListProps) {
|
| 67 | 71 |
const router = useRouter(); |
| 68 | 72 |
const { showAlert, hideAlert, showToast } = useFeedback();
|
... | ... | @@ -81,6 +85,11 @@ |
| 81 | 85 |
|
| 82 | 86 |
function go(patch: Partial<MerchantDetailQuery>) {
|
| 83 | 87 |
router.replace(buildMerchantDetailHref(query, { ...patch, page: 1 }));
|
| 88 |
+ } |
|
| 89 |
+ |
|
| 90 |
+ /** 업체명 검색은 검색 상자가 바로 반영하고, 나머지 필터는 [적용]을 눌러야 간다(사용자 확정). */ |
|
| 91 |
+ function applyFilters() {
|
|
| 92 |
+ go({ ...draft, ...clampDateRange(draft.startDate, draft.endDate, today) });
|
|
| 84 | 93 |
} |
| 85 | 94 |
|
| 86 | 95 |
function toggleAll(checked: boolean) {
|
... | ... | @@ -278,10 +287,7 @@ |
| 278 | 287 |
...categories.map((code) => ({ value: code.code, label: code.label })),
|
| 279 | 288 |
]} |
| 280 | 289 |
value={draft.category}
|
| 281 |
- onValueChange={(value) => {
|
|
| 282 |
- setDraft((current) => ({ ...current, category: value }));
|
|
| 283 |
- go({ category: value });
|
|
| 284 |
- }} |
|
| 290 |
+ onValueChange={(value) => setDraft((current) => ({ ...current, category: value }))}
|
|
| 285 | 291 |
/> |
| 286 | 292 |
</div> |
| 287 | 293 |
<div className={styles.select}>
|
... | ... | @@ -296,10 +302,7 @@ |
| 296 | 302 |
})), |
| 297 | 303 |
]} |
| 298 | 304 |
value={draft.visibility}
|
| 299 |
- onValueChange={(value) => {
|
|
| 300 |
- setDraft((current) => ({ ...current, visibility: value }));
|
|
| 301 |
- go({ visibility: value });
|
|
| 302 |
- }} |
|
| 305 |
+ onValueChange={(value) => setDraft((current) => ({ ...current, visibility: value }))}
|
|
| 303 | 306 |
/> |
| 304 | 307 |
</div> |
| 305 | 308 |
{/* 등록일 기간 — 백엔드가 아직 조건에 쓰지 않는다(보고함). 값은 그대로 보낸다. */}
|
... | ... | @@ -308,24 +311,25 @@ |
| 308 | 311 |
size="md" |
| 309 | 312 |
type="date" |
| 310 | 313 |
aria-label="등록일 시작" |
| 314 |
+ max={today}
|
|
| 311 | 315 |
value={draft.startDate}
|
| 312 |
- onChange={(value) => setDraft((current) => ({ ...current, startDate: value }))}
|
|
| 316 |
+ onChange={(value) =>
|
|
| 317 |
+ setDraft((current) => ({ ...current, ...clampDateRange(value, current.endDate, today) }))
|
|
| 318 |
+ } |
|
| 313 | 319 |
/> |
| 314 | 320 |
<span aria-hidden="true">~</span> |
| 315 | 321 |
<FoxInput |
| 316 | 322 |
size="md" |
| 317 | 323 |
type="date" |
| 318 | 324 |
aria-label="등록일 종료" |
| 325 |
+ min={draft.startDate || undefined}
|
|
| 319 | 326 |
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 |
+ onChange={(value) =>
|
|
| 328 |
+ setDraft((current) => ({ ...current, ...clampDateRange(current.startDate, value, today) }))
|
|
| 329 |
+ } |
|
| 327 | 330 |
/> |
| 328 | 331 |
</div> |
| 332 |
+ <FoxButton type="secondary" size="md" label="적용" onAction={applyFilters} />
|
|
| 329 | 333 |
</div> |
| 330 | 334 |
} |
| 331 | 335 |
searchFields={[]}
|
--- app/(protected)/(basic)/system/merchant-details/page.tsx
+++ app/(protected)/(basic)/system/merchant-details/page.tsx
... | ... | @@ -3,7 +3,10 @@ |
| 3 | 3 |
import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository';
|
| 4 | 4 |
import { fetchMerchantDetails } from '@/lib/data/repositories/merchant-detail-repository';
|
| 5 | 5 |
import { CODE_GROUP } from '@/lib/domain/common-code';
|
| 6 |
-import { parseMerchantDetailQuery } from '@/lib/domain/merchant-detail-query';
|
|
| 6 |
+import {
|
|
| 7 |
+ parseMerchantDetailQuery, |
|
| 8 |
+ todayIsoDate, |
|
| 9 |
+} from '@/lib/domain/merchant-detail-query'; |
|
| 7 | 10 |
import { MerchantDetailList } from './_components/merchant-detail-list';
|
| 8 | 11 |
|
| 9 | 12 |
export const metadata: Metadata = { title: '가맹몰 상세 관리' };
|
... | ... | @@ -33,6 +36,7 @@ |
| 33 | 36 |
currentPage={currentPage}
|
| 34 | 37 |
totalPages={totalPages}
|
| 35 | 38 |
totalCount={totalCount}
|
| 39 |
+ today={todayIsoDate()}
|
|
| 36 | 40 |
/> |
| 37 | 41 |
); |
| 38 | 42 |
} |
--- lib/domain/merchant-detail-query.ts
+++ lib/domain/merchant-detail-query.ts
... | ... | @@ -25,6 +25,26 @@ |
| 25 | 25 |
const MAX_KEYWORD_LENGTH = 100; |
| 26 | 26 |
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
| 27 | 27 |
|
| 28 |
+/** 오늘 — 로컬 달력 기준 `YYYY-MM-DD`. 서버·브라우저가 같은 규칙으로 계산한다. */ |
|
| 29 |
+export function todayIsoDate(now: Date = new Date()): string {
|
|
| 30 |
+ const offsetMs = now.getTimezoneOffset() * 60 * 1000; |
|
| 31 |
+ return new Date(now.getTime() - offsetMs).toISOString().slice(0, 10); |
|
| 32 |
+} |
|
| 33 |
+ |
|
| 34 |
+/** |
|
| 35 |
+ * 등록일 기간 규칙(사용자 확정) — 시작일은 오늘을 넘지 못하고, 종료일은 시작일보다 앞설 수 없다. |
|
| 36 |
+ * 어기는 값은 경계로 당긴다. 화면의 피커 제한과 URL 파싱이 같은 함수를 쓴다. |
|
| 37 |
+ */ |
|
| 38 |
+export function clampDateRange( |
|
| 39 |
+ startDate: string, |
|
| 40 |
+ endDate: string, |
|
| 41 |
+ today: string = todayIsoDate() |
|
| 42 |
+): { startDate: string; endDate: string } {
|
|
| 43 |
+ const start = startDate && startDate > today ? today : startDate; |
|
| 44 |
+ const end = endDate && start && endDate < start ? start : endDate; |
|
| 45 |
+ return { startDate: start, endDate: end };
|
|
| 46 |
+} |
|
| 47 |
+ |
|
| 28 | 48 |
/** 기획 BO-MAL-001 ① — 업체명·카테고리·노출상태·등록일 네 조건을 AND로 결합한다. */ |
| 29 | 49 |
export type MerchantDetailQuery = {
|
| 30 | 50 |
keyword: string; |
... | ... | @@ -68,8 +88,7 @@ |
| 68 | 88 |
) |
| 69 | 89 |
? visibility |
| 70 | 90 |
: FILTER_ALL, |
| 71 |
- startDate: readDate(searchParams, 'startDate'), |
|
| 72 |
- endDate: readDate(searchParams, 'endDate'), |
|
| 91 |
+ ...clampDateRange(readDate(searchParams, 'startDate'), readDate(searchParams, 'endDate')), |
|
| 73 | 92 |
page: Number.isInteger(page) && page > 0 ? page : DEFAULT_PAGE, |
| 74 | 93 |
pageSize: isPageSize(pageSize) ? pageSize : DEFAULT_MERCHANT_DETAIL_PAGE_SIZE, |
| 75 | 94 |
}; |
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?