From 9ef9bfbca386782b5c774e2f3572a7d4473c1edc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Fri, 10 Jul 2026 17:18:09 +0900 Subject: [PATCH] =?UTF-8?q?feat(negodata/front):=20=EC=B5=9C=EC=A0=80?= =?UTF-8?q?=EA=B0=80=20=EC=83=81=EC=84=B8=EB=B3=B4=EA=B8=B0=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EB=85=B8=EC=B6=9C=20+=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=8B=9C=ED=8A=B8=20=EA=B0=80=EB=8F=85=EC=84=B1=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상품 테이블 인터넷 최저가 셀: 값 옆에 차트 아이콘 버튼 상시 노출 (기존 hover 밑줄만으론 클릭 가능성이 안 보임) — 값 없는 상품도 버튼으로 진입해 빈 상태 안내를 받는다 - 상세 시트 재구성: 견적 상세 드로어와 같은 SectionCard 문법으로 통일 · 헤드라인 카드: 상품명 → 대표가(26px rose) + 단가 대비 칩 (절감=emerald·역전=amber, Trending 아이콘) + 단가·마지막 수집 캡션 · 최저가 출처: 섹션 타이틀 우측 사이트 배지, 링크는 outline 버튼화 · 가격 추이: 수집 횟수 캡션, 2회 미만이면 안내 문구(빈 영역 방지) · 수집 이력: 배지 폭 고정(w-14)으로 컬럼 정렬, 링크 없는 행 자리 유지 검증: 헤드리스 렌더 확인(테이블 셀·시트 v2 스크린샷), tsc 통과 Co-Authored-By: Claude Fable 5 --- .../components/LowestPriceHistorySheet.tsx | 145 +++++++++++------- .../products/components/ProductTable.tsx | 30 ++-- 2 files changed, 110 insertions(+), 65 deletions(-) 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()}` : '-'} + +
), }, {