임동욱 임동욱 09-11
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
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-list.tsx
@@ -27,6 +27,7 @@
   FILTER_ALL,
   MERCHANT_DETAIL_PAGE_SIZE_OPTIONS,
   buildMerchantDetailHref,
+  clampDateRange,
   type MerchantDetailPageSize,
   type MerchantDetailQuery,
 } from '@/lib/domain/merchant-detail-query';
@@ -45,6 +46,8 @@
   currentPage: number;
   totalPages: number;
   totalCount: number;
+  /** 서버가 정한 오늘(YYYY-MM-DD). 여기서 계산하면 서버·브라우저 시간대 차이로 hydration이 어긋난다. */
+  today: string;
 }
 
 /**
@@ -63,6 +66,7 @@
   currentPage,
   totalPages,
   totalCount,
+  today,
 }: MerchantDetailListProps) {
   const router = useRouter();
   const { showAlert, hideAlert, showToast } = useFeedback();
@@ -81,6 +85,11 @@
 
   function go(patch: Partial<MerchantDetailQuery>) {
     router.replace(buildMerchantDetailHref(query, { ...patch, page: 1 }));
+  }
+
+  /** 업체명 검색은 검색 상자가 바로 반영하고, 나머지 필터는 [적용]을 눌러야 간다(사용자 확정). */
+  function applyFilters() {
+    go({ ...draft, ...clampDateRange(draft.startDate, draft.endDate, today) });
   }
 
   function toggleAll(checked: boolean) {
@@ -278,10 +287,7 @@
                 ...categories.map((code) => ({ value: code.code, label: code.label })),
               ]}
               value={draft.category}
-              onValueChange={(value) => {
-                setDraft((current) => ({ ...current, category: value }));
-                go({ category: value });
-              }}
+              onValueChange={(value) => setDraft((current) => ({ ...current, category: value }))}
             />
           </div>
           <div className={styles.select}>
@@ -296,10 +302,7 @@
                 })),
               ]}
               value={draft.visibility}
-              onValueChange={(value) => {
-                setDraft((current) => ({ ...current, visibility: value }));
-                go({ visibility: value });
-              }}
+              onValueChange={(value) => setDraft((current) => ({ ...current, visibility: value }))}
             />
           </div>
           {/* 등록일 기간 — 백엔드가 아직 조건에 쓰지 않는다(보고함). 값은 그대로 보낸다. */}
@@ -308,24 +311,25 @@
               size="md"
               type="date"
               aria-label="등록일 시작"
+              max={today}
               value={draft.startDate}
-              onChange={(value) => setDraft((current) => ({ ...current, startDate: value }))}
+              onChange={(value) =>
+                setDraft((current) => ({ ...current, ...clampDateRange(value, current.endDate, today) }))
+              }
             />
             <span aria-hidden="true">~</span>
             <FoxInput
               size="md"
               type="date"
               aria-label="등록일 종료"
+              min={draft.startDate || undefined}
               value={draft.endDate}
-              onChange={(value) => setDraft((current) => ({ ...current, endDate: value }))}
-            />
-            <FoxButton
-              type="default"
-              size="md"
-              label="기간 적용"
-              onAction={() => go({ startDate: draft.startDate, endDate: draft.endDate })}
+              onChange={(value) =>
+                setDraft((current) => ({ ...current, ...clampDateRange(current.startDate, value, today) }))
+              }
             />
           </div>
+          <FoxButton type="secondary" size="md" label="적용" onAction={applyFilters} />
         </div>
       }
       searchFields={[]}
app/(protected)/(basic)/system/merchant-details/page.tsx
--- app/(protected)/(basic)/system/merchant-details/page.tsx
+++ app/(protected)/(basic)/system/merchant-details/page.tsx
@@ -3,7 +3,10 @@
 import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository';
 import { fetchMerchantDetails } from '@/lib/data/repositories/merchant-detail-repository';
 import { CODE_GROUP } from '@/lib/domain/common-code';
-import { parseMerchantDetailQuery } from '@/lib/domain/merchant-detail-query';
+import {
+  parseMerchantDetailQuery,
+  todayIsoDate,
+} from '@/lib/domain/merchant-detail-query';
 import { MerchantDetailList } from './_components/merchant-detail-list';
 
 export const metadata: Metadata = { title: '가맹몰 상세 관리' };
@@ -33,6 +36,7 @@
       currentPage={currentPage}
       totalPages={totalPages}
       totalCount={totalCount}
+      today={todayIsoDate()}
     />
   );
 }
lib/domain/merchant-detail-query.ts
--- lib/domain/merchant-detail-query.ts
+++ lib/domain/merchant-detail-query.ts
@@ -25,6 +25,26 @@
 const MAX_KEYWORD_LENGTH = 100;
 const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
 
+/** 오늘 — 로컬 달력 기준 `YYYY-MM-DD`. 서버·브라우저가 같은 규칙으로 계산한다. */
+export function todayIsoDate(now: Date = new Date()): string {
+  const offsetMs = now.getTimezoneOffset() * 60 * 1000;
+  return new Date(now.getTime() - offsetMs).toISOString().slice(0, 10);
+}
+
+/**
+ * 등록일 기간 규칙(사용자 확정) — 시작일은 오늘을 넘지 못하고, 종료일은 시작일보다 앞설 수 없다.
+ * 어기는 값은 경계로 당긴다. 화면의 피커 제한과 URL 파싱이 같은 함수를 쓴다.
+ */
+export function clampDateRange(
+  startDate: string,
+  endDate: string,
+  today: string = todayIsoDate()
+): { startDate: string; endDate: string } {
+  const start = startDate && startDate > today ? today : startDate;
+  const end = endDate && start && endDate < start ? start : endDate;
+  return { startDate: start, endDate: end };
+}
+
 /** 기획 BO-MAL-001 ① — 업체명·카테고리·노출상태·등록일 네 조건을 AND로 결합한다. */
 export type MerchantDetailQuery = {
   keyword: string;
@@ -68,8 +88,7 @@
     )
       ? visibility
       : FILTER_ALL,
-    startDate: readDate(searchParams, 'startDate'),
-    endDate: readDate(searchParams, 'endDate'),
+    ...clampDateRange(readDate(searchParams, 'startDate'), readDate(searchParams, 'endDate')),
     page: Number.isInteger(page) && page > 0 ? page : DEFAULT_PAGE,
     pageSize: isPageSize(pageSize) ? pageSize : DEFAULT_MERCHANT_DETAIL_PAGE_SIZE,
   };
Add a comment
List