[refactor] negodata/front: 최저가 탐색 모달 B2B 재설계 + 완료 판정 버그 수정

완료 판정 버그(로딩이 안 멈추던 원인 중 하나)
  폴링이 crawl_end_time 을 문자열로 비교했다. 서버는 마이크로초 6자리(.755705Z),
  JS toISOString() 은 밀리초 3자리(.755Z)라 자릿수가 달라 사전순이 시간순과 어긋난다
  ('.' < 'Z'). 게다가 클라이언트 시각을 서버 타임스탬프의 기준점으로 써서
  브라우저 시계가 조금만 앞서도 어떤 결과도 기준을 넘지 못했다.
  → 요청 직전 서버가 준 최신 수집 시각을 상품별 기준선으로 읽고 epoch ms 로 비교.

UI 재설계 (소비자 앱풍 → B2B 실무 툴)
  - 상태 3분리: confirm | searching | done. 검색 중에 완료 결론을 섞지 않는다
  - 표가 주인공: [선택 | 상품 | 기존 최저가 | 네이버 | 쿠팡 | 결과], 컴팩트 행, tabular-nums
  - 결과 열은 상태어(탐색 성공 / 변동 없음 / 탐색 실패 / 탐색 중)
  - 진행 카드: 표시등 + mm:ss 경과 + 진행바 + N/M, 셀 단위 스켈레톤으로 진행 위치 표시
  - radius 8/6/4, shadow 최소화, CTA 우측 정렬(풀와이드 금지), 강조는 indigo-800 한 톤
  - 완료 후 자동으로 닫지 않는다(결과 확인). 선택 해제는 닫을 때로 미룸

선택 재검색
  체크박스로 대상을 고르고, 검색이 끝나면 갱신되지 않은 행만 자동 선택된다
  (이미 갱신된 상품에 크롤 비용을 다시 쓰지 않도록). 재검색은 force=true 로 나가
  네거티브 캐시를 우회한다.

접근성 (스킬 기준 + 대비 실측)
  - 보조 텍스트 muted-foreground 4.88:1, indigo-800 9.93:1 (기존 1.92:1 FAIL 해소)
  - 상태는 색 단독이 아니라 텍스트/굵기/sr-only 병행
  - role=status aria-live, 탈출구 3중(X·ESC·오버레이), 버튼 높이 통일

api/generated 는 orval 재생성분. resTargetBreakdown 변경은 이전 백엔드 수정과의
스펙 동기화(이번 작업과 무관하지만 재생성으로 함께 정리됨).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
민헌 2026-07-28 11:56:00 +09:00
parent 0cefe315c4
commit 5e24741395
8 changed files with 459 additions and 184 deletions

View File

@ -37,7 +37,8 @@ import type {
ResItemImage,
ResItemList,
ResLowestPriceResult,
ResLowestPriceTrigger
ResLowestPriceTrigger,
TriggerLowestPriceParams
} from '.././model';
import { customFetch } from '../../mutator/custom-fetch';
@ -649,12 +650,14 @@ export const useDeleteItem = <TError = void | HTTPValidationError,
*/
export const triggerLowestPrice = (
itemId: string,
params?: TriggerLowestPriceParams,
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResLowestPriceTrigger>(
{url: `/v1/item/${itemId}/lowest-price`, method: 'POST', signal
{url: `/v1/item/${itemId}/lowest-price`, method: 'POST',
params, signal
},
options);
}
@ -662,8 +665,8 @@ export const triggerLowestPrice = (
export const getTriggerLowestPriceMutationOptions = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof triggerLowestPrice>>, TError,{itemId: string}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof triggerLowestPrice>>, TError,{itemId: string}, TContext> => {
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof triggerLowestPrice>>, TError,{itemId: string;params?: TriggerLowestPriceParams}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof triggerLowestPrice>>, TError,{itemId: string;params?: TriggerLowestPriceParams}, TContext> => {
const mutationKey = ['triggerLowestPrice'];
const {mutation: mutationOptions, request: requestOptions} = options ?
@ -675,10 +678,10 @@ const {mutation: mutationOptions, request: requestOptions} = options ?
const mutationFn: MutationFunction<Awaited<ReturnType<typeof triggerLowestPrice>>, {itemId: string}> = (props) => {
const {itemId} = props ?? {};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof triggerLowestPrice>>, {itemId: string;params?: TriggerLowestPriceParams}> = (props) => {
const {itemId,params} = props ?? {};
return triggerLowestPrice(itemId,requestOptions)
return triggerLowestPrice(itemId,params,requestOptions)
}
@ -694,11 +697,11 @@ const {mutation: mutationOptions, request: requestOptions} = options ?
* @summary 최저가 수집 요청
*/
export const useTriggerLowestPrice = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof triggerLowestPrice>>, TError,{itemId: string}, TContext>, request?: SecondParameter<typeof customFetch>}
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof triggerLowestPrice>>, TError,{itemId: string;params?: TriggerLowestPriceParams}, TContext>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof triggerLowestPrice>>,
TError,
{itemId: string},
{itemId: string;params?: TriggerLowestPriceParams},
TContext
> => {

View File

@ -84,6 +84,8 @@ export * from './listRequestsParams';
export * from './listSuppliersParams';
export * from './listUsersParams';
export * from './lowestPriceEntry';
export * from './lowestPriceEntryByMall';
export * from './lowestPriceEntryByMallAnyOfItem';
export * from './lowestPriceEntryCrawlEndTime';
export * from './lowestPriceEntryFailReason';
export * from './lowestPriceEntryLpName';
@ -392,7 +394,6 @@ export * from './resTargetBreakdownMdPrice';
export * from './resTargetBreakdownMsg';
export * from './resTargetBreakdownPurchase';
export * from './resTargetBreakdownSelling';
export * from './resTargetBreakdownTargetPriceMode';
export * from './resWebPacketProtocol';
export * from './resWebPacketProtocolMsg';
export * from './sessionData';
@ -437,6 +438,7 @@ export * from './supplierItemDataItemCode';
export * from './supplierItemDataItemManufacturer';
export * from './supplierItemDataUpdatedAt';
export * from './targetCandidate';
export * from './triggerLowestPriceParams';
export * from './userRole';
export * from './userStatus';
export * from './validationError';

View File

@ -8,6 +8,7 @@ import type { LowestPriceEntryLpPrice } from './lowestPriceEntryLpPrice';
import type { LowestPriceEntryFailReason } from './lowestPriceEntryFailReason';
import type { LowestPriceEntryLpName } from './lowestPriceEntryLpName';
import type { LowestPriceEntryLpUrl } from './lowestPriceEntryLpUrl';
import type { LowestPriceEntryByMall } from './lowestPriceEntryByMall';
import type { LowestPriceEntryCrawlEndTime } from './lowestPriceEntryCrawlEndTime';
/**
@ -20,5 +21,6 @@ export interface LowestPriceEntry {
fail_reason?: LowestPriceEntryFailReason;
lp_name?: LowestPriceEntryLpName;
lp_url?: LowestPriceEntryLpUrl;
by_mall?: LowestPriceEntryByMall;
crawl_end_time?: LowestPriceEntryCrawlEndTime;
}

View File

@ -0,0 +1,9 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { LowestPriceEntryByMallAnyOfItem } from './lowestPriceEntryByMallAnyOfItem';
export type LowestPriceEntryByMall = LowestPriceEntryByMallAnyOfItem[] | null;

View File

@ -5,4 +5,4 @@
* OpenAPI spec version: 0.1.0
*/
export type ResTargetBreakdownTargetPriceMode = string | null;
export type LowestPriceEntryByMallAnyOfItem = { [key: string]: unknown };

View File

@ -11,7 +11,6 @@ import type { ResTargetBreakdownInternetLowest } from './resTargetBreakdownInter
import type { ResTargetBreakdownPurchase } from './resTargetBreakdownPurchase';
import type { ResTargetBreakdownSelling } from './resTargetBreakdownSelling';
import type { TargetCandidate } from './targetCandidate';
import type { ResTargetBreakdownTargetPriceMode } from './resTargetBreakdownTargetPriceMode';
import type { ResTargetBreakdownChosenBasis } from './resTargetBreakdownChosenBasis';
import type { ResTargetBreakdownAnchoringPrice } from './resTargetBreakdownAnchoringPrice';
@ -28,7 +27,6 @@ export interface ResTargetBreakdown {
margin?: number;
anchoring_value?: number;
candidates?: TargetCandidate[];
target_price_mode?: ResTargetBreakdownTargetPriceMode;
hidden_price_fields?: string[];
chosen_basis?: ResTargetBreakdownChosenBasis;
target_price?: number;

View File

@ -0,0 +1,13 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type TriggerLowestPriceParams = {
/**
* LPS 네거티브 캐시(24h not_found)를 무시하고 재검색. 사용자가 '다시 검색'을 누른 경우만 true
*/
force?: boolean;
};

View File

@ -1,251 +1,499 @@
import { useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Globe, X, AlertCircle, Loader2, Cpu, RefreshCw } from 'lucide-react';
import { Clock, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { showToast } from '@/lib/notify';
import { Typography } from '@/components/ui/typography';
import { Button } from '@/components/ui/button';
import { Typography } from '@/components/ui/typography';
import { useScrollLock } from '@/lib/useScrollLock';
import { triggerLowestPrice, getLowestPrice } from '@/api/generated/item/item';
import type { LowestPriceEntryByMall } from '@/api/generated/model/lowestPriceEntryByMall';
import type { Product } from '../types';
type PriceUpdateModalProps = {
open: boolean;
products: Product[];
selectedIds: string[];
onDone: () => void; // 완료 시 선택 해제
onDone: () => void; // 선택 해제 — 결과를 계속 보여줘야 해서 창을 닫을 때 호출한다
onClose: () => void;
};
const POLL_INTERVAL_MS = 5_000; // GET lowest-price 폴링 간격(서버가 조회 시 lps_db 증분 동기화를 겸함)
const POLL_TIMEOUT_MS = 300_000; // 상품당 수십 초 × 순차 처리 감안한 전체 상한(5분)
// 인터넷 최저가 실시간 수집 모달 — LPS 연동.
// 강조색 — 채도 낮춘 인디고 한 톤만 쓴다(절감액·주 버튼). 대비: #3730a3 on white 9.93:1.
// ⚠️ 앱 전역 --primary(#5e6ad2)보다 어두운 계열이라 이 모달에서만 국소 적용한다.
const ACCENT_TEXT = 'text-indigo-800 dark:text-indigo-300';
const ACCENT_BTN =
'bg-indigo-800 text-white hover:bg-indigo-900 dark:bg-indigo-300 dark:text-indigo-950 dark:hover:bg-indigo-200';
// 표에 열로 세우는 검색 소스. by_mall 에는 매칭된 몰만 담겨 오므로, 여기 없는 소스는 그 회차에 빈손이었다는 뜻.
// (오픈마켓 폴백은 기본 OFF — 켜지면 이 목록과 아래 ROW_GRID 열 개수를 함께 넓힌다.)
const SOURCES = [
{ key: 'naver', label: '네이버' },
{ key: 'coupang', label: '쿠팡' },
] as const;
// 몰별 최저가 = by_mall 을 source 로 묶어 최저가만 남긴 것. 값이 없으면 그 몰은 못 찾은 것.
type MallPrices = Record<string, number>;
const toMallPrices = (byMall: LowestPriceEntryByMall): MallPrices => {
const out: MallPrices = {};
for (const entry of byMall ?? []) {
const source = String(entry.source ?? '').toLowerCase();
const price = Number(entry.price);
if (!source || !Number.isFinite(price)) continue;
if (out[source] === undefined || price < out[source]) out[source] = price;
}
return out;
};
// 상품 1건의 처리 상태(화면 전체 상태와 구분해 ItemState 로 둔다).
type ItemState =
| { kind: 'pending' } // 접수됨 — 결과 대기
| { kind: 'done'; price: number; malls: MallPrices }
| { kind: 'notfound'; malls: MallPrices } // 동일 상품 판정 실패(기존 값 유지)
| { kind: 'failed'; reason?: string } // 요청 자체가 접수되지 않음 — 재시도 대상
| { kind: 'timeout' } // 폴링 상한 초과 — 서버는 계속 검색 중
| { kind: 'stopped' }; // 사용자가 지켜보기를 중단 — 서버는 계속 검색 중
// 표 정렬 — 헤더와 본문 행이 같은 그리드를 써야 열이 어긋나지 않는다.
// [상품 | 기존 최저가 | 네이버 | 쿠팡 | 결과]
// ⚠️ Tailwind JIT 는 소스에 리터럴로 적힌 클래스만 생성한다 — SOURCES 를 늘리면
// 여기 열 개수(5rem 반복)도 함께 손으로 맞춰야 한다(템플릿 문자열 금지).
const ROW_GRID = 'grid grid-cols-[1.5rem_minmax(0,1fr)_5.5rem_5rem_5rem_6.5rem] gap-x-3 px-3';
const NUM = 'text-right tabular-nums'; // 금액 열은 등폭 숫자로 자릿수를 맞춘다
// 표 체크박스 — DataTable 과 같은 규격을 쓴다(앱 전역 일관성)
const CHECKBOX = 'h-3.5 w-3.5 rounded border-border text-primary focus:ring-primary cursor-pointer accent-primary';
// 경과 시간 mm:ss — 등폭 숫자와 맞물려 자릿수가 흔들리지 않는다(1분 넘어가도 폭 유지).
const fmtClock = (sec: number) =>
`${String(Math.floor(sec / 60)).padStart(2, '0')}:${String(sec % 60).padStart(2, '0')}`;
const fmtPrice = (v: number | undefined) => (v && v > 0 ? v.toLocaleString() : '–');
// 수집 시각을 epoch ms 로. 파싱 불가/누락이면 0(= 어떤 기준선도 넘지 못함).
const crawlMs = (iso: string | null | undefined) => {
const t = iso ? Date.parse(iso) : NaN;
return Number.isNaN(t) ? 0 : t;
};
const latestCrawlMs = (entries: { crawl_end_time?: string | null }[] | undefined) =>
(entries ?? []).reduce((max, e) => Math.max(max, crawlMs(e.crawl_end_time)), 0);
// 인터넷 최저가 검색 모달 — LPS 연동.
// 흐름: 선택 상품마다 POST(수집 요청, 큐 접수) → GET 폴링(요청 시각 이후의 수집 이력이 생기면 완료).
// 폴링이 시간을 초과해도 서버 검색은 계속되고, 5분 주기 동기화 배치가 결과를 자동 반영한다.
// 폴링이 시간을 초과하거나 사용자가 중단해도 서버 검색은 계속되고, 5분 주기 동기화 배치가 결과를 자동 반영한다.
export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose }: PriceUpdateModalProps) {
useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금
const queryClient = useQueryClient();
const [isCrawling, setIsCrawling] = useState(false);
const [crawlingProgress, setCrawlingProgress] = useState(0);
const [crawlerLogs, setCrawlerLogs] = useState<string[]>([]);
const cancelledRef = useRef(false); // 닫기 시 폴링 루프 중단(서버 검색은 계속)
const [isSearching, setIsSearching] = useState(false);
const [items, setItems] = useState<Record<string, ItemState>>({});
const [elapsedSec, setElapsedSec] = useState(0); // 경과 시간 — "멈춘 건지 오래 걸리는 건지" 판단 근거
// 검색 시작 시점의 대상 스냅샷. 결과를 계속 띄워둬야 하는데 부모의 selectedIds 는
// 창을 닫을 때 비워지므로, 표는 이 스냅샷을 그린다.
const [targetIds, setTargetIds] = useState<string[]>([]);
// 재검색 대상 선택. null = 아직 손대지 않음(전체 선택으로 간주).
// 검색이 끝나면 '성공하지 못한 행'만 남겨 두어, 이미 갱신된 상품에 크롤 비용을 다시 쓰지 않게 한다.
const [checkedIds, setCheckedIds] = useState<Set<string> | null>(null);
const cancelledRef = useRef(false); // 중단/닫기 시 폴링 루프 탈출(서버 검색은 계속)
const closeRef = useRef<() => void>(() => {}); // ESC 핸들러가 최신 닫기 로직을 보게 하는 통로
useEffect(() => {
if (!isSearching) return;
const timer = setInterval(() => setElapsedSec((s) => s + 1), 1_000);
return () => clearInterval(timer);
}, [isSearching]);
// ESC 로 닫기 — X 버튼/푸터 닫기와 함께 탈출구 3중 확보
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeRef.current();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open]);
if (!open) return null;
const pushLog = (line: string) => setCrawlerLogs((prev) => [...prev, line]);
const nameOf = (id: string) => products.find((p) => p.item_id === id)?.name || id;
const rows = targetIds.length > 0 ? targetIds : selectedIds;
// 화면 전체 상태 — 한 화면에는 한 상태만 선언한다(검색 중에 완료 결론을 섞지 않는다).
const status: 'confirm' | 'searching' | 'done' =
targetIds.length === 0 ? 'confirm' : isSearching ? 'searching' : 'done';
const handleStartCrawling = async () => {
if (selectedIds.length === 0) {
toast.error('업데이트할 상품을 1개 이상 선택해 주십시오.');
return;
}
const checked = checkedIds ?? new Set(rows);
const allChecked = rows.length > 0 && rows.every((id) => checked.has(id));
const toggleOne = (id: string, on: boolean) =>
setCheckedIds(() => {
const next = new Set(checked);
if (on) next.add(id);
else next.delete(id);
return next;
});
const toggleAll = (on: boolean) => setCheckedIds(on ? new Set(rows) : new Set());
const setItem = (id: string, s: ItemState) => setItems((prev) => ({ ...prev, [id]: s }));
const productOf = (id: string) => products.find((p) => p.item_id === id);
const nameOf = (id: string) => productOf(id)?.name || id;
const prevPriceOf = (id: string) => Number(productOf(id)?.internet_lowest_price ?? 0);
const settled = rows.filter((id) => items[id] && items[id].kind !== 'pending').length;
const failedIds = rows.filter((id) => items[id]?.kind === 'failed');
const failReason = failedIds
.map((id) => (items[id] as { kind: 'failed'; reason?: string }).reason)
.find(Boolean);
// 한 행의 절감액 — 기존 최저가보다 싸게 찾았을 때만 값이 생긴다.
const savingOf = (id: string) => {
const s = items[id];
if (s?.kind !== 'done') return 0;
const prev = prevPriceOf(id);
return prev > 0 && s.price < prev ? prev - s.price : 0;
};
const updatedIds = rows.filter((id) => savingOf(id) > 0);
const totalSaved = updatedIds.reduce((sum, id) => sum + savingOf(id), 0);
const runningIds = rows.filter((id) => ['timeout', 'stopped'].includes(items[id]?.kind ?? ''));
// 상태 블록·보조 안내는 내용이 있을 때만 그린다 — 빈 요소가 여백만 차지하지 않도록.
const hasStatusBlock =
status === 'searching' || (status === 'done' && (totalSaved > 0 || failedIds.length > 0));
const note =
status === 'confirm'
? '상품당 수십 초가 소요됩니다. 미발견 시 기존 최저가가 유지됩니다.'
: status === 'searching'
? '창을 닫아도 검색은 계속됩니다.'
: failReason
? `탐색 실패 사유: ${failReason}`
: runningIds.length > 0
? `${runningIds.length}개는 서버에서 검색 중입니다. 완료되면 자동 반영됩니다.`
: '';
const handleClose = () => {
cancelledRef.current = true; // 진행 중이면 폴링만 중단(서버 검색은 계속 → 주기 동기화로 반영)
if (targetIds.length > 0) onDone(); // 한 번이라도 돌렸으면 선택 해제
onClose();
};
closeRef.current = handleClose;
// 지켜보기 중단 — 서버 검색까지 취소하는 API 는 없으므로, 화면 감시만 멈추고 그 사실을 문구로 밝힌다.
const handleStop = () => {
cancelledRef.current = true;
setIsSearching(false);
setItems((prev) => {
const next = { ...prev };
rows.forEach((id) => {
if (next[id]?.kind === 'pending') next[id] = { kind: 'stopped' };
});
return next;
});
};
// 주어진 상품들만 검색한다. 재시도에서도 그대로 쓰므로 나머지 행의 결과는 건드리지 않는다.
// force=true 면 LPS 네거티브 캐시(24h not_found)를 무시하고 실제로 다시 크롤한다.
const runSearch = async (ids: string[], force = false) => {
cancelledRef.current = false;
setIsCrawling(true);
setCrawlingProgress(5);
setCrawlerLogs([
'[System] 인터넷 최저가 검색(LPS) 요청 접수 중...',
`[Target] 선택된 ${selectedIds.length}개 상품`,
]);
const startedAt = new Date().toISOString(); // 이 시각 이후의 수집 이력만 "이번 요청 결과"로 인정
setIsSearching(true);
setElapsedSec(0);
setItems((prev) => {
const next = { ...prev };
ids.forEach((id) => delete next[id]);
return next;
});
// "이번 요청 결과"의 판정 기준선 — 상품별로 '요청 직전의 최신 수집 시각'을 서버 값에서 읽어 둔다.
// 클라이언트 시각(new Date())을 기준으로 쓰면 브라우저 시계가 서버보다 조금만 앞서도
// 어떤 결과도 기준을 넘지 못해 영영 대기한다. 서버 값끼리 비교해 시계 의존을 없앤다.
const baselineOf = new Map<string, number>();
// 1) 상품별 수집 요청(POST) — 실패/중복은 로그로 구분하고 계속 진행
// 1) 상품별 검색 요청(POST) — 실패/중복은 상태로 구분하고 계속 진행
const pending = new Set<string>();
for (const id of selectedIds) {
for (const id of ids) {
try {
const r = await triggerLowestPrice(id);
if (r.status === 'queued') {
const before = await getLowestPrice(id);
baselineOf.set(id, latestCrawlMs(before.results));
} catch {
baselineOf.set(id, Date.now()); // 기준선을 못 읽으면 과거 이력을 결과로 오인하지 않도록 보수적으로
}
try {
const r = await triggerLowestPrice(id, force ? { force: true } : undefined);
// 'duplicated' = 이미 진행 중 → 결과는 폴링으로 같이 받는다
if (r.status === 'queued' || r.status === 'duplicated') {
pending.add(id);
pushLog(`[접수] '${nameOf(id)}' 검색 큐 등록`);
} else if (r.status === 'duplicated') {
pending.add(id); // 이미 진행 중 → 결과는 폴링으로 같이 받는다
pushLog(`[진행중] '${nameOf(id)}' 이미 검색이 진행 중 — 결과 대기에 합류`);
setItem(id, { kind: 'pending' });
} else {
pushLog(`[불가] '${nameOf(id)}' ${r.message || '검색 서비스 연결 불가'}`);
// 서버가 준 사유를 버리지 않는다 — 실패 원인을 화면에서 읽을 수 있어야 한다
setItem(id, { kind: 'failed', reason: r.message || '검색 서비스에 연결하지 못했습니다' });
}
} catch {
pushLog(`[오류] '${nameOf(id)}' 요청 실패`);
setItem(id, { kind: 'failed', reason: '요청을 보내지 못했습니다(네트워크 오류)' });
}
}
if (pending.size === 0) {
showToast('접수된 상품이 없습니다. 검색 서비스 상태를 확인해 주세요.', 'error');
setIsCrawling(false);
setCrawlingProgress(0);
setIsSearching(false);
return;
}
setCrawlingProgress(15);
pushLog(`[Search] ${pending.size}개 상품 크롤링 진행 — 네이버·쿠팡 수집 및 AI 동일상품 판정...`);
// 2) 폴링 — GET 이 서버측 증분 동기화를 겸함. 요청 시각 이후 이력이 생긴 상품부터 완료 처리.
const total = pending.size;
const succeeded = new Set<string>(); // 실제로 값이 내려간 행 — 끝나면 선택에서 빼 준다
let found = 0;
let notFound = 0;
const deadline = Date.now() + POLL_TIMEOUT_MS;
while (pending.size > 0 && Date.now() < deadline && !cancelledRef.current) {
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
for (const id of [...pending]) {
try {
const r = await getLowestPrice(id);
const fresh = (r.results ?? []).find((e) => (e.crawl_end_time ?? '') >= startedAt);
// 문자열 비교 금지 — 서버는 마이크로초(.755705Z), JS 는 밀리초(.755Z)라 자릿수가 달라
// 사전순 비교가 시간순과 어긋난다('.' < 'Z'). 반드시 epoch ms 로 환산해 비교한다.
const baseline = baselineOf.get(id) ?? 0;
const fresh = (r.results ?? []).find((e) => crawlMs(e.crawl_end_time) > baseline);
if (!fresh) continue;
pending.delete(id);
const malls = toMallPrices(fresh.by_mall ?? null);
if (fresh.success_yn && fresh.lp_price != null) {
found += 1;
pushLog(`[완료] '${nameOf(id)}' 최저가 ${fresh.lp_price.toLocaleString()}원 반영`);
const prev = prevPriceOf(id);
if (prev <= 0 || fresh.lp_price < prev) succeeded.add(id); // 값이 실제로 갱신된 행
// by_mall 이 비어 오는 옛 이력 대비 — 최소한 대표 최저가는 보이도록 폴백을 채운다.
if (Object.keys(malls).length === 0) malls.etc = fresh.lp_price;
setItem(id, { kind: 'done', price: fresh.lp_price, malls });
} else {
notFound += 1;
pushLog(`[미발견] '${nameOf(id)}' 동일 상품을 찾지 못함(기존 값 유지)`);
setItem(id, { kind: 'notfound', malls });
}
} catch {
/* 일시 오류는 다음 tick 재시도 */
}
setCrawlingProgress(15 + Math.round(((total - pending.size) / total) * 85));
}
}
// 3) 마무리 — 목록 갱신(테이블 인터넷 최저가 컬럼 반영) 후 종료
const timedOut = pending.size > 0 && !cancelledRef.current;
if (timedOut) {
pushLog(`[대기초과] ${pending.size}개 상품은 아직 검색 중 — 완료되면 주기 동기화로 자동 반영됩니다.`);
}
// 3) 마무리 — 목록만 갱신하고 창은 그대로 둔다(결과를 바로 확인할 수 있어야 하므로 자동으로 닫지 않는다).
if (pending.size > 0 && !cancelledRef.current) pending.forEach((id) => setItem(id, { kind: 'timeout' }));
await queryClient.invalidateQueries({ queryKey: ['/v1/item/list'] });
setCrawlingProgress(100);
// 다음 '다시 검색'의 기본 대상 = 이번에 갱신되지 않은 행. 이미 갱신된 상품엔 크롤 비용을 다시 쓰지 않는다.
setCheckedIds(new Set(ids.filter((id) => !succeeded.has(id))));
if (!cancelledRef.current) {
showToast(
`최저가 수집 완료: 반영 ${found} · 미발견 ${notFound}${timedOut ? ` · 검색중 ${pending.size}(자동 반영 예정)` : ''}`,
found > 0 ? 'success' : 'info',
setIsSearching(false);
if (found === 0) showToast('더 낮은 가격을 찾지 못했습니다.', 'info');
}
};
const handleStart = () => {
if (checked.size === 0) {
toast.error('검색할 상품을 1개 이상 선택해 주십시오.');
return;
}
setTargetIds([...rows]); // 표는 대상 전체를 계속 보여주고, 검색은 체크된 행만 돈다
setItems({});
void runSearch([...checked]);
};
// 몰 셀 — 검색 중에는 셀 단위 스피너로 진행 위치를 보여주고, 끝나면 금액을 찍는다.
const renderMallCell = (id: string, source: string) => {
const s = items[id];
if (!s) return <span className="text-muted-foreground">–</span>;
if (s.kind === 'pending') {
// 작은 회전 아이콘은 표 안에서 어수선하게 읽힌다. 값이 들어올 자리를 잡아 두는
// 스켈레톤 막대로 대신한다(레이아웃 이동도 없음).
return (
<span className="inline-flex w-full justify-end">
<span className="h-3 w-10 animate-pulse rounded-[3px] bg-muted" aria-hidden />
<span className="sr-only">검색 중</span>
</span>
);
}
onDone();
setIsCrawling(false);
onClose();
setCrawlingProgress(0);
setCrawlerLogs([]);
const malls = s.kind === 'done' || s.kind === 'notfound' ? s.malls : {};
const prices = Object.values(malls);
const best = prices.length > 0 ? Math.min(...prices) : null;
const price = malls[source];
if (price === undefined) return <span className="text-muted-foreground">–</span>;
const isBest = price === best;
return (
<span className={isBest ? 'font-semibold text-foreground' : 'text-muted-foreground'}>
{price.toLocaleString()}
{isBest && <span className="sr-only"> (최저가)</span>}
</span>
);
};
// 결과 열 — 완료 상태에서만 각 행의 '처리 결과 상태'를 적는다(검색 중에는 비워 둔다).
// 금액이 아니라 상태어를 쓴다: 가격 자체는 왼쪽 몰 열에 이미 있고, 이 열은 "그래서 어떻게 됐나"를 답한다.
const renderResultCell = (id: string) => {
const s = items[id];
if (status !== 'done' || !s) return <span className="text-muted-foreground">–</span>;
if (savingOf(id) > 0) return <span className={cn('font-semibold', ACCENT_TEXT)}>탐색 성공</span>;
switch (s.kind) {
case 'done':
case 'notfound':
// 검색은 정상 수행됐으나 기존보다 낮은 가격이 없었음(미발견 포함) — 값이 안 바뀐 상태.
return <span className="text-muted-foreground">변동 없음</span>;
case 'failed':
return <span className="text-foreground">탐색 실패</span>;
case 'timeout':
case 'stopped':
return <span className="text-foreground">탐색 중</span>;
default:
return <span className="text-muted-foreground">–</span>;
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/55 backdrop-blur-xs">
<div className="w-full max-w-lg bg-card border border-border rounded-lg shadow-2xl p-6 max-h-[90vh] overflow-y-auto animate-scale-up font-mono text-xs">
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40"
onMouseDown={(e) => {
if (e.target === e.currentTarget) handleClose(); // 오버레이 클릭으로도 닫기
}}
>
<div className="w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-[8px] border border-border bg-card shadow-sm">
{/* Modal Title */}
<div className="flex items-center justify-between pb-4 border-b border-border">
<div className="flex items-center gap-2 text-foreground">
<Globe className="text-rose-500 animate-pulse" size={18} />
<Typography variant="h3">인터넷 최저가 실시간 수집 및 동기화</Typography>
</div>
{!isCrawling && (
<button
onClick={onClose}
className="p-1 rounded text-muted-foreground hover:bg-muted cursor-pointer"
>
<X size={18} />
</button>
)}
</div>
{/* Modal Body */}
<div className="my-6 space-y-4">
<div className="p-3 bg-rose-500/5 border border-rose-500/20 rounded-md">
<Typography variant="small" className="text-[11.5px] leading-relaxed text-left flex items-start gap-2">
<AlertCircle size={15} className="text-rose-500 shrink-0 mt-0.5" />
<span>
총 <span className="font-bold text-rose-600 dark:text-rose-400 underline decoration-rose-500/50 decoration-2">{selectedIds.length}개</span> 품목에 대하여 네이버 쇼핑·쿠팡의 최저가를 수집하고 AI 가 동일 상품을 판정하여 인터넷 최저가 필드로 동기화합니다. 상품당 수십 초가 소요될 수 있습니다.
</span>
</Typography>
</div>
{/* Targeted Items List */}
<div className="border border-border rounded p-2.5 bg-muted/20 space-y-1.5">
<span className="text-[10px] text-muted-foreground font-bold block">수집 대상 상품 ({selectedIds.length})</span>
<div className="max-h-24 overflow-y-auto space-y-1 pr-1">
{selectedIds.map((id) => {
const prod = products.find((p) => p.item_id === id);
return prod ? (
<div key={id} className="flex justify-between items-center text-[11px] py-1 border-b border-border/30 last:border-b-0">
<span className="text-foreground truncate max-w-[200px] font-semibold">{prod.name}</span>
<div className="flex items-center gap-1.5 font-mono">
<span className="text-muted-foreground">{(prod.price ?? 0).toLocaleString()}원</span>
<span className="text-muted-foreground">→</span>
<span className="text-rose-500 font-bold">
{prod.internet_lowest_price != null ? `${Number(prod.internet_lowest_price).toLocaleString()}원 갱신` : '신규 수집'}
</span>
</div>
</div>
) : null;
})}
</div>
</div>
{/* Crawling Progress View */}
{isCrawling ? (
<div className="space-y-3 pt-2">
<div className="flex justify-between text-[11px] text-muted-foreground">
<span className="flex items-center gap-1.5 font-bold text-foreground">
<Loader2 size={13} className="animate-spin text-rose-500" />
최저가 검색·수집 진행 중... (닫아도 검색은 계속됩니다)
</span>
<span className="font-bold text-rose-500">{crawlingProgress}%</span>
</div>
{/* Progress Bar Container */}
<div className="w-full bg-muted rounded-full h-2.5 overflow-hidden border border-border">
<div
className="bg-gradient-to-r from-rose-500 to-rose-600 h-full transition-all duration-300 rounded-full"
style={{ width: `${crawlingProgress}%` }}
/>
</div>
{/* Terminal Log Output */}
<div className="bg-zinc-950 dark:bg-black text-rose-300 p-3 rounded font-mono text-[10px] border border-zinc-800 space-y-1.5 max-h-36 overflow-y-auto shadow-inner">
{crawlerLogs.map((log, idx) => (
<div key={idx} className="flex items-start gap-1">
<span className="text-zinc-600 select-none shrink-0">&gt;</span>
<span className={`text-left break-all ${log.includes('[System]') || log.includes('[Search]') ? 'text-zinc-400 font-bold' : log.includes('[완료]') ? 'text-emerald-400' : log.includes('[미발견]') || log.includes('[불가]') || log.includes('[오류]') || log.includes('[대기초과]') ? 'text-amber-400' : 'text-rose-300'}`}>
{log}
</span>
</div>
))}
</div>
</div>
) : (
<div className="text-center py-4 border border-dashed border-border rounded bg-muted/10 space-y-2">
<Cpu size={24} className="mx-auto text-muted-foreground/60" />
<Typography variant="muted" className="text-[11px]">
"인터넷 최저가 가동" 버튼을 누르시면 실시간 수집이 시작됩니다.
</Typography>
</div>
)}
</div>
{/* Modal Footer */}
<div className="flex justify-end gap-2 pt-4 border-t border-border">
{/* 다이얼로그 헤더 — X 는 푸터 버튼과 같은 높이(size-7)로 맞춘다 */}
<div className="flex items-center justify-between gap-3 px-5 py-4">
<Typography variant="h4" as="h2">인터넷 최저가 검색</Typography>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
cancelledRef.current = true; // 진행 중이면 폴링만 중단(서버 검색은 계속 → 주기 동기화로 반영)
onClose();
}}
size="icon-sm"
className="rounded-[4px]"
aria-label="닫기"
onClick={handleClose}
>
<X />
</Button>
</div>
<div className="px-5 pb-2">
{/* 결과 표 — 데이터가 주인공이라 맨 위에 둔다 */}
<div className="overflow-x-auto rounded-[6px] border border-border">
<div className="min-w-[36rem]">
<div className={cn(ROW_GRID, 'items-center border-b border-border bg-muted/60 py-1.5 text-xs text-muted-foreground')}>
<input
type="checkbox"
className={CHECKBOX}
checked={allChecked}
disabled={status === 'searching' || rows.length === 0}
onChange={(e) => toggleAll(e.target.checked)}
aria-label="전체 선택"
/>
<span>상품</span>
<span className={NUM}>기존 최저가</span>
{SOURCES.map(({ key, label }) => (
<span key={key} className={NUM}>{label}</span>
))}
<span className={NUM}>결과</span>
</div>
<div className="max-h-72 overflow-y-auto">
{rows.length === 0 && (
<div className="px-3 py-6 text-center text-[13px] text-muted-foreground">
선택된 상품이 없습니다. 목록에서 상품을 선택해 주십시오.
</div>
)}
{rows.map((id) => (
<label
key={id}
className={cn(
ROW_GRID,
'items-center py-1.5 text-[13px] border-b border-border/60 last:border-b-0',
status !== 'searching' && 'cursor-pointer hover:bg-muted/40',
)}
>
<input
type="checkbox"
className={CHECKBOX}
checked={checked.has(id)}
disabled={status === 'searching'}
onChange={(e) => toggleOne(id, e.target.checked)}
aria-label={`${nameOf(id)} 선택`}
/>
<span className="truncate text-foreground">{nameOf(id)}</span>
<span className={cn(NUM, 'text-muted-foreground')}>{fmtPrice(prevPriceOf(id))}</span>
{SOURCES.map(({ key }) => (
<span key={key} className={NUM}>{renderMallCell(id, key)}</span>
))}
<span className={NUM}>{renderResultCell(id)}</span>
</label>
))}
</div>
</div>
</div>
{/* 상태 요약 — 한 화면에 한 상태만. 표(데이터)가 먼저 오고 진행/결과는 그 아래.
confirm 단계는 별도 문구 없이 표와 하단 안내만으로 충분하다. */}
<div role="status" aria-live="polite" className={hasStatusBlock ? 'mt-4' : undefined}>
{status === 'searching' && (
<div className="rounded-[6px] border border-border px-4 py-3">
<div className="flex items-center justify-between gap-3">
<span className="flex items-center gap-2 text-[13px] font-medium text-foreground">
{/* 동작 중임을 알리는 표시등 — 의미는 옆 문구가 진다(색 단독 의존 방지) */}
<span
className="size-1.5 shrink-0 animate-pulse rounded-full bg-indigo-800 dark:bg-indigo-300"
aria-hidden
/>
더 낮은 가격 검색 중
</span>
<span className="flex shrink-0 items-center gap-1.5 text-xs tabular-nums text-muted-foreground">
<Clock size={13} aria-hidden />
<span className="sr-only">경과 시간 </span>
{fmtClock(elapsedSec)}
</span>
</div>
<div className="mt-3 flex items-center gap-3">
<span className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
<span
className="block h-full rounded-full bg-indigo-800 transition-all duration-500 dark:bg-indigo-300"
style={{ width: `${rows.length ? (settled / rows.length) * 100 : 0}%` }}
/>
</span>
<span className="shrink-0 text-xs font-semibold tabular-nums text-foreground">
{settled} / {rows.length}
</span>
</div>
</div>
)}
{/* 완료 — 행별 결과는 표의 '결과' 열이 말해주므로, 여기엔 절감액만 남긴다.
절감이 없으면 시각적으로는 비우고 완료 사실만 스크린리더에 알린다. */}
{status === 'done' && (
<div className="flex items-baseline justify-between gap-3">
{totalSaved > 0 ? (
<span className={cn('text-[22px] font-bold tabular-nums tracking-tight', ACCENT_TEXT)}>
총 {totalSaved.toLocaleString()}원 절감
</span>
) : (
<span className="sr-only">검색이 완료되었습니다. 갱신된 가격은 없습니다.</span>
)}
{failedIds.length > 0 && (
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
탐색 실패 {failedIds.length}개
</span>
)}
</div>
)}
</div>
{/* 보조 안내 — 할 말이 없으면 아예 그리지 않는다(빈 줄이 여백만 잡는 걸 막는다) */}
{note && <p className="mt-4 text-xs text-muted-foreground">{note}</p>}
</div>
{/* 액션 — 풀와이드 금지. 보조는 좌측 텍스트, 주 액션은 우측 */}
<div className="flex items-center justify-between gap-3 px-5 pb-5 pt-4">
<Button type="button" variant="outline" size="sm" className="rounded-[4px]" onClick={handleClose}>
닫기
</Button>
<Button type="button" variant="destructive" size="sm" disabled={isCrawling} onClick={handleStartCrawling}>
{isCrawling ? (
<>
<RefreshCw className="animate-spin" />
실시간 수집 분석중...
</>
) : (
<>
<Globe />
인터넷 최저가 가동
</>
)}
</Button>
{status === 'searching' ? (
<Button type="button" variant="secondary" size="sm" className="rounded-[4px]" onClick={handleStop}>
탐색 중단
</Button>
) : (
<Button
type="button"
size="sm"
className={cn('rounded-[4px]', ACCENT_BTN)}
disabled={checked.size === 0}
// 완료 후 재검색은 force — 미발견은 24h 네거티브 캐시에 걸려 그냥 누르면 실제로 안 돈다.
onClick={status === 'done' ? () => void runSearch([...checked], true) : handleStart}
>
{status === 'confirm' ? `선택 ${checked.size}개 검색` : `선택 ${checked.size}개 다시 검색`}
</Button>
)}
</div>
</div>
</div>