L
diff --git a/lps-admin/src/pages/Products.tsx b/lps-admin/src/pages/Products.tsx
index 6823183..ccc0c7a 100644
--- a/lps-admin/src/pages/Products.tsx
+++ b/lps-admin/src/pages/Products.tsx
@@ -1,12 +1,12 @@
-/** 상품·가격 — 이력 상품 목록 → 가격 추이 3선(네이버·쿠팡·최종) + 몰별 최저가 + 재검색. */
+/** 상품·가격 — 이력 상품 목록 → 가격 추이 3선(네이버·쿠팡·최종) + 몰별 가격 비교 + 재검색. */
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import {
- Bar, BarChart, CartesianGrid, Cell, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
+ CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
} from "recharts";
import { get, post } from "../api/client";
-import type { PriceHistoryRes, ProductItem, ProductListRes, SearchRes } from "../api/types";
+import type { PriceHistoryRes, PricePoint, ProductItem, ProductListRes, SearchRes } from "../api/types";
import { Card, Empty, ErrorNote, Loading } from "../components/ui";
import { dateShort, timeAgo, won } from "../lib/format";
@@ -37,8 +37,8 @@ export default function Products() {
-
-
+
+
{list.isPending && }
{list.isError && }
{list.data && list.data.items.length === 0 && 아직 검색된 상품이 없습니다}
@@ -87,11 +87,10 @@ function ProductDetail({ product }: { product: ProductItem | null }) {
const points = (history.data?.points ?? []).map((p) => ({ ...p, x: dateShort(p.triggered_at) }));
const latest = history.data?.points.at(-1);
- const malls = (latest?.by_mall ?? []).filter((m) => m.price != null)
- .sort((a, b) => (a.price ?? 0) - (b.price ?? 0)).slice(0, 8);
return (
-
+ // min-w-0: 그리드 자식이 콘텐츠(차트) 최소폭 때문에 안 줄어들어 가로 넘침 나는 것 방지
+
가격 추이 — {product.display_name || product.product_code}>}
hint={
@@ -137,51 +136,91 @@ function ProductDetail({ product }: { product: ProductItem | null }) {
)}
- {latest && (
-
-
- {malls.length === 0 ? (
- 몰별 데이터 없음
- ) : (
-
-
-
-
- [won(v), "최저가"]} />
- v.toLocaleString("ko-KR") }}>
- {malls.map((m, i) => (
- |
- ))}
-
-
-
- )}
-
-
-
-
-
-
- )}
+ {latest &&
}
);
}
+
+// ── 몰별 가격 비교(최근 검색) — 기존 '몰별 최저가' + '검증 링크' 를 하나로.
+// by_mall(몰별 최저가, 가격 오름차순)을 상위 N개 표로: 순위·쇼핑몰·상품명·가격(+배송)·최저가 대비·링크.
+const TOP_N_PRESETS = [3, 5, 10] as const;
+
+function MallCompare({ latest }: { latest: PricePoint }) {
+ const [topN, setTopN] = useState
(5);
+ const malls = (latest.by_mall ?? [])
+ .filter((m) => m.price != null)
+ .sort((a, b) => (a.price ?? 0) - (b.price ?? 0));
+ const shown = topN === "all" ? malls : malls.slice(0, topN);
+ const cheapest = malls[0]?.price ?? 0;
+ const maxPrice = Math.max(1, ...shown.map((m) => m.price ?? 0));
+
+ return (
+
+ {dateShort(latest.triggered_at)}
+
+ {TOP_N_PRESETS.map((n) => (
+
+ ))}
+
+
+
+ }>
+ {shown.length === 0 ? (
+ 몰별 데이터 없음
+ ) : (
+ <>
+
+ {shown.map((m, i) => {
+ const price = m.price ?? 0;
+ const delta = price - cheapest;
+ const src = m.source === "naver" ? "var(--color-naver)" : m.source === "coupang" ? "var(--color-coupang)" : "var(--color-ink-400)";
+ return (
+ -
+ {/* 순위 */}
+
+ {i + 1}
+
+ {/* 쇼핑몰 + 상품명 */}
+
+
+
+ {m.mall_name || m.source}
+ {i === 0 && 최저}
+
+
{m.name}
+
+ {/* 가격 + 최저가 대비 + 링크 */}
+
+
{won(price)}
+
+ {delta === 0 ? "최저가" : `+${delta.toLocaleString("ko-KR")}원`}
+ {m.detail_url && (
+
열기 ↗
+ )}
+
+
+ {/* 가격 비교 막대(전체 폭, 최고가 기준 상대 길이) */}
+
+
+ );
+ })}
+
+ {topN !== "all" && malls.length > shown.length && (
+ 몰 {malls.length}개 중 상위 {shown.length}개 표시 — '전체'로 모두 보기
+ )}
+ >
+ )}
+
+ );
+}