feat(negodata/front): 최저가 상세보기 버튼 노출 + 상세 시트 가독성 정리
- 상품 테이블 인터넷 최저가 셀: 값 옆에 차트 아이콘 버튼 상시 노출
(기존 hover 밑줄만으론 클릭 가능성이 안 보임) — 값 없는 상품도
버튼으로 진입해 빈 상태 안내를 받는다
- 상세 시트 재구성: 견적 상세 드로어와 같은 SectionCard 문법으로 통일
· 헤드라인 카드: 상품명 → 대표가(26px rose) + 단가 대비 칩
(절감=emerald·역전=amber, Trending 아이콘) + 단가·마지막 수집 캡션
· 최저가 출처: 섹션 타이틀 우측 사이트 배지, 링크는 outline 버튼화
· 가격 추이: 수집 횟수 캡션, 2회 미만이면 안내 문구(빈 영역 방지)
· 수집 이력: 배지 폭 고정(w-14)으로 컬럼 정렬, 링크 없는 행 자리 유지
검증: 헤드리스 렌더 확인(테이블 셀·시트 v2 스크린샷), tsc 통과
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
4a20194dc8
commit
9ef9bfbca3
@ -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 (
|
||||
<Card className="p-3 gap-2 rounded shadow-xs border-border/80">
|
||||
<div className="flex items-center justify-between border-b border-border pb-1">
|
||||
<span className="font-bold text-foreground text-[11px] font-sans">{title}</span>
|
||||
{action}
|
||||
</div>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 인터넷 최저가 상세 시트 — 대표값·출처(사이트/상품명/링크)·가격 추이 그래프·수집 이력.
|
||||
// 협상 근거로 쓰는 값이므로 "어디서 찾았는지"를 클릭 한 번으로 검증할 수 있게 한다.
|
||||
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 (
|
||||
<Sheet open title="인터넷 최저가 상세" onClose={onClose}>
|
||||
<div className="space-y-5 text-sm">
|
||||
{/* 상품 + 대표값 */}
|
||||
<div>
|
||||
<Typography variant="muted" className="text-[11px]">{product.name}</Typography>
|
||||
<div className="mt-1 flex items-baseline gap-3">
|
||||
<span className="font-mono text-2xl font-bold text-rose-600 dark:text-rose-400">
|
||||
<div className="space-y-4">
|
||||
{/* ── 헤드라인: 상품명 · 대표가 · 단가 대비 칩 ── */}
|
||||
<div className="rounded border border-border/80 bg-muted/25 p-3">
|
||||
<Typography as="p" variant="caption" className="font-medium">{product.name}</Typography>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||
<span className="font-mono text-[26px] leading-none font-bold text-rose-600 dark:text-rose-400">
|
||||
{representative != null ? won(Number(representative)) : '수집 전'}
|
||||
</span>
|
||||
{product.price != null && representative != null && (
|
||||
<Typography variant="muted" className="text-[11px]">
|
||||
상품 단가 {won(product.price)} 대비{' '}
|
||||
<span className={Number(representative) <= product.price ? 'text-emerald-600 dark:text-emerald-400' : 'text-amber-600 dark:text-amber-400'}>
|
||||
{`${Number(representative) - product.price > 0 ? '+' : Number(representative) - product.price < 0 ? '-' : ''}${won(Math.abs(Number(representative) - product.price))}`}
|
||||
</span>
|
||||
</Typography>
|
||||
{diff != null && (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ${
|
||||
diff <= 0
|
||||
? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400'
|
||||
: 'bg-amber-500/10 text-amber-700 dark:text-amber-400'
|
||||
}`}
|
||||
>
|
||||
{diff <= 0 ? <TrendingDown size={12} /> : <TrendingUp size={12} />}
|
||||
단가 대비 {diff > 0 ? '+' : diff < 0 ? '-' : ''}{won(Math.abs(diff))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Typography as="p" variant="caption" className="mt-1.5">
|
||||
상품 단가 {product.price != null ? won(product.price) : '-'}
|
||||
{latest?.crawl_end_time && <> · 마지막 수집 {timeLabel(latest.crawl_end_time)}</>}
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
{/* 출처 — 최신 성공 수집의 사이트/상품명/링크 */}
|
||||
{/* ── 출처 — 최신 성공 수집의 사이트/상품명/링크 ── */}
|
||||
{latest && (
|
||||
<div className="rounded-md border border-border bg-muted/20 p-3 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-[10px]">{websiteLabel(latest.website)}</Badge>
|
||||
<Typography variant="muted" className="text-[10px]">
|
||||
{latest.crawl_end_time ? timeLabel(latest.crawl_end_time) : ''} 수집
|
||||
</Typography>
|
||||
</div>
|
||||
<SectionCard
|
||||
title="최저가 출처"
|
||||
action={<Badge variant="secondary" className="text-[10px]">{websiteLabel(latest.website)}</Badge>}
|
||||
>
|
||||
{latest.lp_name ? (
|
||||
<Typography as="p" variant="small" className="text-[12px] leading-snug text-foreground">{latest.lp_name}</Typography>
|
||||
<Typography as="p" variant="small" className="text-[12.5px] leading-snug">{latest.lp_name}</Typography>
|
||||
) : (
|
||||
<Typography as="p" variant="muted" className="text-[11px]">출처 상세 미수집(이전 버전 수집분)</Typography>
|
||||
<Typography as="p" variant="caption">출처 상세 미수집(이전 버전 수집분)</Typography>
|
||||
)}
|
||||
{latest.lp_url && (
|
||||
<a
|
||||
href={latest.lp_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[11px] font-medium text-rose-600 dark:text-rose-400 hover:underline"
|
||||
className="inline-flex w-fit items-center gap-1.5 rounded border border-border px-2.5 py-1.5 text-[11px] font-medium text-foreground transition-colors hover:border-rose-400/60 hover:bg-rose-500/10 hover:text-rose-600 dark:hover:text-rose-400"
|
||||
>
|
||||
판매 페이지에서 확인 <ExternalLink size={11} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* 가격 추이 — 성공 수집 2건부터 */}
|
||||
{chartData.length >= 2 && (
|
||||
<div>
|
||||
<Typography variant="small" className="mb-2 block text-[11px] font-bold text-muted-foreground">
|
||||
가격 추이 ({chartData.length}회 수집)
|
||||
</Typography>
|
||||
{/* ── 가격 추이 ── */}
|
||||
<SectionCard
|
||||
title="가격 추이"
|
||||
action={
|
||||
successes.length > 0 ? (
|
||||
<Typography variant="caption">{successes.length}회 수집</Typography>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{chartData.length >= 2 ? (
|
||||
<ChartContainer config={chartConfig} className="aspect-auto h-40 w-full">
|
||||
<LineChart data={chartData} margin={{ top: 8, right: 12, left: 4, bottom: 0 }}>
|
||||
<LineChart data={chartData} margin={{ top: 10, right: 12, left: 4, bottom: 0 }}>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis dataKey="t" tickLine={false} axisLine={false} tickMargin={8} tickFormatter={timeLabel} fontSize={10} />
|
||||
<YAxis
|
||||
@ -126,53 +151,69 @@ export function LowestPriceHistorySheet({ product, onClose }: LowestPriceHistory
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<div className="flex items-center gap-2 py-3 text-muted-foreground">
|
||||
<ChartSpline size={14} className="shrink-0" />
|
||||
<Typography variant="caption">
|
||||
{successes.length === 1
|
||||
? '수집이 2회 이상 쌓이면 가격 추이 그래프가 표시됩니다.'
|
||||
: '성공한 수집이 쌓이면 가격 추이 그래프가 표시됩니다.'}
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{/* 수집 이력 */}
|
||||
<div>
|
||||
<Typography variant="small" className="mb-2 block text-[11px] font-bold text-muted-foreground">
|
||||
최근 수집 이력
|
||||
</Typography>
|
||||
{/* ── 수집 이력 ── */}
|
||||
<SectionCard title="최근 수집 이력">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 py-6 justify-center text-muted-foreground">
|
||||
<div className="flex items-center justify-center gap-2 py-6 text-muted-foreground">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
<span className="text-[11px]">이력을 불러오는 중...</span>
|
||||
<Typography variant="caption">이력을 불러오는 중...</Typography>
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="py-6 text-center border border-dashed border-border rounded bg-muted/10 space-y-1">
|
||||
<div className="space-y-1 rounded border border-dashed border-border bg-muted/10 py-6 text-center">
|
||||
<TrendingDown size={18} className="mx-auto text-muted-foreground/60" />
|
||||
<Typography as="p" variant="muted" className="text-[11px]">
|
||||
<Typography as="p" variant="caption">
|
||||
수집 이력이 없습니다 — 상품 목록에서 "최저가 업데이트하기"로 수집을 시작하세요.
|
||||
</Typography>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-border/60 rounded-md border border-border">
|
||||
<ul className="divide-y divide-border/60">
|
||||
{entries.map((e, i) => (
|
||||
<li key={i} className="flex items-center justify-between gap-2 px-3 py-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Badge variant="secondary" className="shrink-0 text-[10px]">{websiteLabel(e.website)}</Badge>
|
||||
<li key={i} className="flex items-center justify-between gap-2 py-2 first:pt-0.5 last:pb-0.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Badge variant="secondary" className="w-14 shrink-0 justify-center text-[10px]">
|
||||
{websiteLabel(e.website)}
|
||||
</Badge>
|
||||
<span className="truncate text-[11px] text-muted-foreground">
|
||||
{e.crawl_end_time ? timeLabel(e.crawl_end_time) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 font-mono text-[12px]">
|
||||
<div className="flex shrink-0 items-center gap-2.5 font-mono text-[12px]">
|
||||
{e.success_yn && e.lp_price != null ? (
|
||||
<span className="font-semibold text-rose-600 dark:text-rose-400">{won(e.lp_price)}</span>
|
||||
) : (
|
||||
<span className="text-amber-600 dark:text-amber-400">미발견</span>
|
||||
)}
|
||||
{e.lp_url && (
|
||||
<a href={e.lp_url} target="_blank" rel="noopener noreferrer" className="text-muted-foreground hover:text-foreground">
|
||||
{e.lp_url ? (
|
||||
<a
|
||||
href={e.lp_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground transition-colors hover:text-rose-600 dark:hover:text-rose-400"
|
||||
title="판매 페이지 열기"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
) : (
|
||||
<span className="w-3" /> /* 링크 없는 행도 가격 우측 정렬 유지 */
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
@ -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) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onLowestPriceClick(prod);
|
||||
}}
|
||||
className="cursor-pointer rounded px-1 -mx-1 hover:bg-rose-500/10 hover:underline decoration-rose-400/60 underline-offset-2"
|
||||
title="출처·가격 추이 보기"
|
||||
>
|
||||
{prod.internet_lowest_price != null ? `₩${Number(prod.internet_lowest_price).toLocaleString()}` : '-'}
|
||||
</button>
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<span>{prod.internet_lowest_price != null ? `₩${Number(prod.internet_lowest_price).toLocaleString()}` : '-'}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onLowestPriceClick(prod);
|
||||
}}
|
||||
className="cursor-pointer shrink-0 rounded border border-border/70 p-1 text-muted-foreground transition-colors hover:border-rose-400/60 hover:bg-rose-500/10 hover:text-rose-600 dark:hover:text-rose-400"
|
||||
title="출처·가격 추이 상세보기"
|
||||
aria-label="인터넷 최저가 상세보기"
|
||||
>
|
||||
<ChartSpline size={13} />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
Loading…
Reference in New Issue
Block a user