diff --git a/negodata/front/src/features/products/components/LowestPriceHistorySheet.tsx b/negodata/front/src/features/products/components/LowestPriceHistorySheet.tsx index 465a4ae..7d21e10 100644 --- a/negodata/front/src/features/products/components/LowestPriceHistorySheet.tsx +++ b/negodata/front/src/features/products/components/LowestPriceHistorySheet.tsx @@ -1,7 +1,9 @@ -import { ExternalLink, Loader2, TrendingDown } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { ChartSpline, ExternalLink, Loader2, TrendingDown, TrendingUp } from 'lucide-react'; import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts'; import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/ui/chart'; import { Badge } from '@/components/ui/badge'; +import { Card } from '@/components/ui/card'; import { Sheet } from '@/components/ui/sheet'; import { Typography } from '@/components/ui/typography'; import { useGetLowestPrice } from '@/api/generated/item/item'; @@ -33,6 +35,19 @@ const won = (n: number) => `₩${Math.round(n).toLocaleString()}`; const timeLabel = (iso: string) => new Date(iso).toLocaleString('ko-KR', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }); +/** 견적 상세 드로어와 같은 문법의 섹션 카드 — 11px 볼드 타이틀 + 하단 구분선. */ +function SectionCard({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) { + return ( + +
+ {title} + {action} +
+ {children} +
+ ); +} + // 인터넷 최저가 상세 시트 — 대표값·출처(사이트/상품명/링크)·가격 추이 그래프·수집 이력. // 협상 근거로 쓰는 값이므로 "어디서 찾았는지"를 클릭 한 번으로 검증할 수 있게 한다. export function LowestPriceHistorySheet({ product, onClose }: LowestPriceHistorySheetProps) { @@ -43,6 +58,7 @@ export function LowestPriceHistorySheet({ product, onClose }: LowestPriceHistory const successes = entries.filter((e): e is LowestPriceEntry & { lp_price: number } => !!e.success_yn && e.lp_price != null); const latest = successes[0]; // API 가 최신순으로 준다 const representative = data?.lowest_price ?? product.internet_lowest_price ?? latest?.lp_price ?? null; + const diff = product.price != null && representative != null ? Number(representative) - product.price : null; // 그래프는 시간 오름차순(성공 수집만). 점 1개는 추이가 아니므로 2건부터 그린다. const chartData = [...successes] @@ -51,60 +67,69 @@ export function LowestPriceHistorySheet({ product, onClose }: LowestPriceHistory return ( -
- {/* 상품 + 대표값 */} -
- {product.name} -
- +
+ {/* ── 헤드라인: 상품명 · 대표가 · 단가 대비 칩 ── */} +
+ {product.name} +
+ {representative != null ? won(Number(representative)) : '수집 전'} - {product.price != null && representative != null && ( - - 상품 단가 {won(product.price)} 대비{' '} - - {`${Number(representative) - product.price > 0 ? '+' : Number(representative) - product.price < 0 ? '-' : ''}${won(Math.abs(Number(representative) - product.price))}`} - - + {diff != null && ( + + {diff <= 0 ? : } + 단가 대비 {diff > 0 ? '+' : diff < 0 ? '-' : ''}{won(Math.abs(diff))} + )}
+ + 상품 단가 {product.price != null ? won(product.price) : '-'} + {latest?.crawl_end_time && <> · 마지막 수집 {timeLabel(latest.crawl_end_time)}} +
- {/* 출처 — 최신 성공 수집의 사이트/상품명/링크 */} + {/* ── 출처 — 최신 성공 수집의 사이트/상품명/링크 ── */} {latest && ( -
-
- {websiteLabel(latest.website)} - - {latest.crawl_end_time ? timeLabel(latest.crawl_end_time) : ''} 수집 - -
+ {websiteLabel(latest.website)}} + > {latest.lp_name ? ( - {latest.lp_name} + {latest.lp_name} ) : ( - 출처 상세 미수집(이전 버전 수집분) + 출처 상세 미수집(이전 버전 수집분) )} {latest.lp_url && ( 판매 페이지에서 확인 )} -
+ )} - {/* 가격 추이 — 성공 수집 2건부터 */} - {chartData.length >= 2 && ( -
- - 가격 추이 ({chartData.length}회 수집) - + {/* ── 가격 추이 ── */} + 0 ? ( + {successes.length}회 수집 + ) : undefined + } + > + {chartData.length >= 2 ? ( - + -
- )} + ) : ( +
+ + + {successes.length === 1 + ? '수집이 2회 이상 쌓이면 가격 추이 그래프가 표시됩니다.' + : '성공한 수집이 쌓이면 가격 추이 그래프가 표시됩니다.'} + +
+ )} + - {/* 수집 이력 */} -
- - 최근 수집 이력 - + {/* ── 수집 이력 ── */} + {isLoading ? ( -
+
- 이력을 불러오는 중... + 이력을 불러오는 중...
) : entries.length === 0 ? ( -
+
- + 수집 이력이 없습니다 — 상품 목록에서 "최저가 업데이트하기"로 수집을 시작하세요.
) : ( -
    +
      {entries.map((e, i) => ( -
    • -
      - {websiteLabel(e.website)} +
    • +
      + + {websiteLabel(e.website)} + {e.crawl_end_time ? timeLabel(e.crawl_end_time) : '-'}
      -
      +
      {e.success_yn && e.lp_price != null ? ( {won(e.lp_price)} ) : ( 미발견 )} - {e.lp_url && ( - + {e.lp_url ? ( + + ) : ( + /* 링크 없는 행도 가격 우측 정렬 유지 */ )}
    • ))}
    )} -
+
); diff --git a/negodata/front/src/features/products/components/ProductTable.tsx b/negodata/front/src/features/products/components/ProductTable.tsx index 12018bb..75491b0 100644 --- a/negodata/front/src/features/products/components/ProductTable.tsx +++ b/negodata/front/src/features/products/components/ProductTable.tsx @@ -1,4 +1,4 @@ -import { Image as ImageIcon } from 'lucide-react'; +import { ChartSpline, Image as ImageIcon } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { DataTable } from '@/components/ui/data-table'; import { TablePagination } from '@/components/ui/table-pagination'; @@ -93,19 +93,23 @@ export function ProductTable({ header: '인터넷 최저가', align: 'right', cellClassName: 'font-mono font-semibold text-rose-600 dark:text-rose-400', - // 클릭 → 출처(사이트·링크)·가격 추이 시트. 행 클릭(수정 시트)과 겹치지 않게 전파 차단. + // 값 + 상세보기 버튼(항상 노출) → 출처(사이트·링크)·가격 추이 시트. 행 클릭과 전파 차단. cell: (prod) => ( - +
+ {prod.internet_lowest_price != null ? `₩${Number(prod.internet_lowest_price).toLocaleString()}` : '-'} + +
), }, {