feat(lps-admin): 관리자 페이지 디자인 개선 및 컴포넌트 통일

- 공용 프리미티브(ui.tsx): PageHeader·Segmented·Button·SearchForm·Legend·
  ScrollBox·ScrollTable·Modal + Card(fill/compact) 옵션
- 차트 공용 설정(lib/chart.ts): 축·그리드·툴팁 스타일 단일화
- 뷰포트 채우기 레이아웃 + content 영역 스크롤(Layout), 버튼 커서 복구
- 표: 헤더 밴드·행 좌우 패딩·고정/채움/auto 높이·sticky 헤더, 배지 줄바꿈 방지
- 작업 큐: 풀폭 표 + 행별 상세/JSON 모달(복사)
- 상품·가격: 가로 배치, Y축 0 기준, 시점 클릭→몰별 비교, 커스텀 툴팁·
  그라데이션 area, 몰별 컴팩트 표(소스 뱃지)
- 크롤 상태: 도넛 중앙 총계·라운드 세그먼트, 막대 그라데이션, 툴팁 통일
- 테스트 검색: 결과 통일(모달·표·소스 뱃지), 진행상태 좌측 이동, 초기화 전
  재검색 차단, 퍼널 가독성(감소량), 배송(로켓배송) 표기, 한 화면 컴팩트
- 사이드바: API 상태 상단 뱃지, 테스트 메뉴 배경색으로 구분

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
민헌 2026-07-16 08:48:43 +09:00
parent e6005b4f94
commit d111610555
11 changed files with 860 additions and 525 deletions

View File

@ -4,15 +4,14 @@ import { useQuery } from "@tanstack/react-query";
import { NavLink, Outlet } from "react-router";
import { healthz } from "../api/client";
// action: 실제 비용이 드는 실행 페이지(모니터링과 성격이 다름) — 앰버(warn) 마커로 구분.
// group: 이 항목 앞에 구분선(모니터링 ↔ 실행/설정 경계).
// action: 실제 비용이 드는 실행 페이지(모니터링과 성격이 다름) — 다른 메뉴와 배경색(앰버)만 다르게.
const MENU = [
{ to: "/", label: "대시보드", end: true },
{ to: "/jobs", label: "작업 큐" },
{ to: "/products", label: "상품·가격" },
{ to: "/crawl", label: "크롤 상태" },
{ to: "/costs", label: "비용" },
{ to: "/test", label: "테스트 검색", action: true, group: true },
{ to: "/test", label: "테스트 검색", action: true },
{ to: "/settings", label: "설정" },
];
@ -20,55 +19,49 @@ export default function Layout() {
const health = useQuery({ queryKey: ["healthz"], queryFn: healthz, refetchInterval: 10_000 });
const up = health.data === true;
return (
<div className="mx-auto flex min-h-screen w-full max-w-[1280px] flex-col md:flex-row">
<div className="mx-auto flex h-dvh w-full max-w-[1280px] flex-col overflow-hidden md:flex-row">
{/* 모바일(<md): 상단 바 + 가로 스크롤 네비(터치 44px). md 이상: 좌측 고정 사이드바. */}
<aside className="w-full min-w-0 shrink-0 border-b border-line-200 px-3 py-3 md:w-[196px] md:border-b-0 md:border-r md:py-5">
<div className="flex items-center justify-between md:block">
<div className="flex items-center gap-2.5 px-2 md:mb-6">
<div className="flex items-center gap-2.5 px-2 md:mb-3">
<span className="grid h-8 w-8 place-items-center rounded-lg bg-gradient-to-br from-primary-600 to-primary-400 text-[14px] font-extrabold text-white">L</span>
<div>
<div className="text-[14px] font-bold leading-tight">LPS 관리자</div>
<div className="text-[11px] font-medium text-ink-400">인터넷 최저가 검색</div>
</div>
</div>
<div className="flex items-center gap-2 px-2 text-[12px] font-semibold text-ink-500 md:hidden">
<span className={`h-2 w-2 rounded-full ${up ? "bg-ok-600" : "bg-dead-600"}`} aria-hidden />
{up ? "연결됨" : "연결 안 됨"}
{/* API 헬스 — 상단 배지(모바일: 우측, 데스크톱: 제목 아래) */}
<div className="shrink-0 px-2 md:mb-5">
<span className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-semibold ${up ? "bg-ok-50 text-ok-600" : "bg-dead-50 text-dead-600"}`}>
<span className={`h-1.5 w-1.5 rounded-full ${up ? "bg-ok-600" : "bg-dead-600"}`} aria-hidden />
{up ? "API 연결됨" : "API 연결 안 됨"}
</span>
</div>
</div>
<nav aria-label="주 메뉴" className="mt-2 md:mt-0">
<ul className="flex items-center gap-1 overflow-x-auto md:flex-col md:items-stretch md:gap-0 md:space-y-0.5">
{MENU.map((m) => (
<li key={m.to} className="flex shrink-0 items-center">
{/* 모니터링 ↔ 실행/설정 경계: 모바일=세로 구분선, 데스크톱=가로 구분선 */}
{m.group && <span className="mx-1 h-5 w-px bg-line-200 md:my-1 md:h-px md:w-full" aria-hidden />}
<NavLink
to={m.to}
end={m.end}
className={({ isActive }) =>
`flex min-h-[44px] w-full items-center gap-1.5 whitespace-nowrap rounded-lg px-3 text-[13px] font-semibold transition-colors md:min-h-0 md:py-2 ${
`flex min-h-[44px] w-full items-center whitespace-nowrap rounded-lg px-3 text-[13px] font-semibold transition-colors md:min-h-0 md:py-2 ${
m.action
? isActive ? "bg-warn-50 text-warn-700" : "text-warn-700 hover:bg-warn-50"
? isActive ? "bg-warn-600/20 text-warn-700" : "bg-warn-600/10 text-ink-700 hover:bg-warn-600/20"
: isActive ? "bg-primary-50 text-primary-700" : "text-ink-700 hover:bg-surface-2"
}`
}
>
{m.action && <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-warn-600" aria-hidden />}
{m.label}
</NavLink>
</li>
))}
</ul>
</nav>
<div className="mt-8 hidden items-center gap-2 px-3 text-[12px] font-semibold text-ink-500 md:flex">
<span
className={`h-2 w-2 rounded-full ${up ? "bg-ok-600" : "bg-dead-600"}`}
aria-hidden
/>
{up ? "API 연결됨" : "API 연결 안 됨"}
</div>
</aside>
<main className="min-w-0 flex-1 px-4 py-4 md:px-6 md:py-5">
{/* content 영역만 스크롤 — 페이지가 h-full 로 채우면 스크롤 없음, 넘치면 여기서 스크롤. */}
<main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto px-4 py-4 md:px-6 md:py-5">
<Outlet />
</main>
</div>

View File

@ -1,20 +1,24 @@
/** 공용 UI 조각 — 카드·스탯 타일·상태 배지·빈/오류 표시. 웹 표준 시맨틱 마크업 유지. */
/** 공용 UI 조각 — 카드·스탯 타일·상태 배지·빈/오류 표시 + 페이지 공통 컨트롤
* (헤더·세그먼트 탭·버튼·검색폼·범례·고정높이 표/스크롤). 웹 표준 시맨틱 마크업 유지. */
import type { ReactNode } from "react";
import { useEffect } from "react";
import type { ButtonHTMLAttributes, ReactNode } from "react";
import type { JobStatusName } from "../api/types";
export function Card({ title, hint, children, className = "" }: {
title?: ReactNode; hint?: ReactNode; children: ReactNode; className?: string;
// fill: 카드가 부모 높이를 채우고 본문이 남는 높이를 flex 로 차지(그래프·표가 높이 채우기용).
// compact: 패딩을 줄여 밀도 높은 레이아웃(한 화면에 많이 담기).
export function Card({ title, hint, children, className = "", fill = false, compact = false }: {
title?: ReactNode; hint?: ReactNode; children: ReactNode; className?: string; fill?: boolean; compact?: boolean;
}) {
return (
<section className={`rounded-xl border border-line-200 bg-surface shadow-[0_1px_2px_rgba(16,24,40,.04)] ${className}`}>
<section className={`rounded-xl border border-line-200 bg-surface shadow-[0_1px_2px_rgba(16,24,40,.04)] ${fill ? "flex min-h-0 flex-col" : ""} ${className}`}>
{title !== undefined && (
<header className="flex items-center gap-2 border-b border-line-100 px-4 py-2.5">
<header className={`flex shrink-0 items-center gap-2 border-b border-line-100 ${compact ? "px-3 py-2" : "px-4 py-2.5"}`}>
<h2 className="text-[13px] font-bold tracking-tight">{title}</h2>
{hint && <span className="ml-auto text-[11px] text-ink-400">{hint}</span>}
{hint && <div className="ml-auto text-[11px] text-ink-400">{hint}</div>}
</header>
)}
<div className="p-4">{children}</div>
<div className={`${compact ? "p-3" : "p-4"} ${fill ? "flex min-h-0 flex-1 flex-col" : ""}`}>{children}</div>
</section>
);
}
@ -71,3 +75,145 @@ export function ErrorNote({ error }: { error: unknown }) {
export function Loading() {
return <p className="py-8 text-center text-[13px] text-ink-400" aria-busy="true">불러오는 중…</p>;
}
// ── 페이지 헤더 — h1 + 우측 컨트롤 슬롯(기간 탭 등). 모든 페이지 제목을 이걸로 통일. ──
export function PageHeader({ title, children }: { title: ReactNode; children?: ReactNode }) {
return (
<div className="flex items-center gap-3">
<h1 className="text-[17px] font-bold tracking-tight">{title}</h1>
{children && <div className="ml-auto flex items-center gap-2">{children}</div>}
</div>
);
}
// ── 세그먼트 탭 — 상태/기간/상위N 필터 공용(기존 4곳의 동일 마크업 통일). ──
export function Segmented<T extends string | number>({ options, value, onChange, ariaLabel, size = "md" }: {
options: readonly { v: T; label: ReactNode }[];
value: T; onChange: (v: T) => void; ariaLabel: string; size?: "sm" | "md";
}) {
const cell = size === "sm" ? "px-2 py-0.5 text-[11px]" : "px-3 py-1.5 text-[12px]";
return (
<div role="tablist" aria-label={ariaLabel} className="flex rounded-lg border border-line-200 bg-surface p-0.5">
{options.map((o) => (
<button key={String(o.v)} role="tab" aria-selected={value === o.v} onClick={() => onChange(o.v)}
className={`rounded-md font-semibold ${cell} ${value === o.v ? "bg-primary-50 text-primary-700" : "text-ink-500 hover:text-ink-700"}`}>
{o.label}
</button>
))}
</div>
);
}
// ── 버튼 — primary(실행 CTA)·outline(보조). 크기 sm/md/lg. 반복되던 파랑 버튼 통일. ──
export function Button({ variant = "primary", size = "md", className = "", ...props }: {
variant?: "primary" | "outline"; size?: "sm" | "md" | "lg";
} & ButtonHTMLAttributes<HTMLButtonElement>) {
const sizes = { sm: "px-2.5 py-1 text-[12px]", md: "px-3 py-1.5 text-[13px]", lg: "px-4 py-2 text-[13px]" };
const variants = {
primary: "bg-primary-600 text-white hover:bg-primary-700",
outline: "border border-line-200 text-ink-700 hover:bg-line-200 hover:text-ink-900",
};
return <button className={`cursor-pointer rounded-lg font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${sizes[size]} ${variants[variant]} ${className}`} {...props} />;
}
// ── 검색 폼 — input + 검색 버튼(Jobs·Products 동일 폼 통일). ──
export function SearchForm({ value, onChange, onSubmit, placeholder, className = "", inputClassName = "w-64" }: {
value: string; onChange: (v: string) => void; onSubmit: () => void;
placeholder: string; className?: string; inputClassName?: string;
}) {
return (
<form className={`flex gap-1.5 ${className}`} onSubmit={(e) => { e.preventDefault(); onSubmit(); }}>
<input value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} aria-label={placeholder}
className={`rounded-lg border border-line-200 bg-surface px-3 py-1.5 text-[13px] outline-none focus:border-primary-600 ${inputClassName}`} />
<Button type="submit">검색</Button>
</form>
);
}
// ── 범례 — 색 dot + 라벨(차트 아래, Dashboard·Costs·Products 통일). ──
export function Legend({ items, note }: {
items: readonly { label: ReactNode; color: string }[]; note?: ReactNode;
}) {
return (
<p className="mt-1 flex flex-wrap gap-4 text-[11px] font-semibold text-ink-500">
{items.map((it, i) => (
<span key={i}>
<span className="mr-1 inline-block h-2 w-2 rounded-full" style={{ background: it.color }} aria-hidden />
{it.label}
</span>
))}
{note && <span className="ml-auto font-medium text-ink-400">{note}</span>}
</p>
);
}
// ── 스크롤 컨테이너 — height 숫자면 고정 높이, "fill" 이면 부모의 남는 높이를 채움. ──
export function ScrollBox({ height = 420, className = "", children }: {
height?: number | "fill"; className?: string; children: ReactNode;
}) {
const fill = height === "fill";
return (
<div className={`overflow-y-auto ${fill ? "min-h-0 flex-1" : ""} ${className}`}
style={fill ? undefined : { height }}>{children}</div>
);
}
// ── 모달 — 배경 딤 + Esc/바깥클릭 닫기. actions 로 헤더 우측 버튼(복사 등) 배치. ──
export function Modal({ title, onClose, actions, children }: {
title: ReactNode; onClose: () => void; actions?: ReactNode; children: ReactNode;
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-ink-900/30" onClick={onClose} aria-hidden />
<div role="dialog" aria-modal="true"
className="relative flex max-h-[85vh] w-full max-w-2xl flex-col rounded-xl border border-line-200 bg-surface shadow-[0_10px_40px_rgba(16,24,40,.18)]">
<header className="flex shrink-0 items-center gap-2 border-b border-line-100 px-4 py-3">
<h2 className="text-[14px] font-bold tracking-tight">{title}</h2>
<div className="ml-auto flex items-center gap-2">
{actions}
<button onClick={onClose} aria-label="닫기"
className="grid h-7 w-7 place-items-center rounded-md text-ink-400 hover:bg-surface-2 hover:text-ink-700">✕</button>
</div>
</header>
<div className="min-h-0 flex-1 overflow-auto p-4">{children}</div>
</div>
</div>
);
}
// ── 표 — thead sticky. height 숫자=고정 높이, "fill"=남는 높이 채움, "auto"=내용 높이.
// fixed=table-fixed(좁은 폭에서 열 넘침·줄바꿈 방지), 열별 width 지정 가능. ──
export function ScrollTable({ height = 420, dense = false, fixed = false, columns, children }: {
height?: number | "fill" | "auto"; dense?: boolean; fixed?: boolean;
columns: readonly { key: string; label: ReactNode; align?: "right"; width?: string }[];
children: ReactNode;
}) {
const pad = dense ? "pb-1.5 pr-2 pt-1.5" : "pb-2 pr-3 pt-2";
const fill = height === "fill";
const auto = height === "auto";
// 행 좌우 여백: 첫/마지막 셀에 padding(헤더·본문 공통). 헤더는 bg-surface-2 밴드로 카드 제목바와 구분.
const edge = dense ? "[&_th:first-child]:pl-3 [&_td:first-child]:pl-3 [&_th:last-child]:pr-3 [&_td:last-child]:pr-3"
: "[&_th:first-child]:pl-4 [&_td:first-child]:pl-4 [&_th:last-child]:pr-4 [&_td:last-child]:pr-4";
return (
<div className={`overflow-auto ${fill ? "min-h-0 flex-1" : ""}`} style={fill || auto ? undefined : { height }}>
<table className={`w-full text-left ${fixed ? "table-fixed" : ""} ${dense ? "text-[12px]" : "text-[13px]"} ${edge}`}>
<thead>
<tr>
{columns.map((c) => (
<th key={c.key} style={c.width ? { width: c.width } : undefined}
className={`sticky top-0 z-10 whitespace-nowrap border-b border-line-200 bg-surface-2 text-[11px] font-semibold text-ink-500 ${pad} ${c.align === "right" ? "text-right" : ""}`}>
{c.label}
</th>
))}
</tr>
</thead>
<tbody>{children}</tbody>
</table>
</div>
);
}

View File

@ -49,10 +49,20 @@
"Apple SD Gothic Neo", "Noto Sans KR", sans-serif;
}
/* 앱을 뷰포트에 고정 — 페이지(body) 스크롤 없음. 스크롤은 content(main) 영역에서만. */
html, body, #root {
height: 100%;
}
body {
@apply bg-page text-ink-900 font-sans text-[14px] antialiased;
}
/* Tailwind v4 는 버튼 기본 커서를 default 로 바꿈 — 활성 버튼은 pointer 로 복구. */
button:not(:disabled) {
cursor: pointer;
}
/* 테이블 숫자 열 정렬 */
.tnum {
font-variant-numeric: tabular-nums;

View File

@ -0,0 +1,35 @@
/** recharts 공용 축·그리드 props — 반복되던 XAxis/YAxis/Grid 설정 단일화.
* 색은 index.css @theme 토큰 참조(단일 소스). 사용: <XAxis dataKey="x" {...xAxisProps} minTickGap={40} /> */
export const gridProps = { stroke: "var(--color-grid)", vertical: false } as const;
// X축: 하단 눈금 + 옅은 축선(기준선 역할).
export const xAxisProps = {
tick: { fill: "var(--color-ink-400)" },
tickLine: false,
axisLine: { stroke: "var(--color-line-200)" },
} as const;
// Y축: 눈금만(축선 없음 — 그리드가 대신함).
export const yAxisProps = {
tick: { fill: "var(--color-ink-400)" },
tickLine: false,
axisLine: false,
} as const;
// 툴팁 — 둥근 카드·부드러운 그림자·토큰 색(기본 recharts 툴팁을 앱 톤으로 통일).
// 사용: <Tooltip {...tooltipProps} formatter={...} /> (막대엔 cursor={barCursor} 추가)
export const tooltipProps = {
contentStyle: {
borderRadius: 12,
border: "1px solid var(--color-line-200)",
background: "var(--color-surface)",
boxShadow: "0 8px 24px rgba(16,24,40,.14)",
padding: "8px 12px",
},
labelStyle: { color: "var(--color-ink-500)", fontWeight: 700, marginBottom: 4, fontSize: 12 },
itemStyle: { padding: "1px 0", fontSize: 12 },
} as const;
// 막대 hover 커서 — 옅은 배경 하이라이트(기본 회색 박스 대신).
export const barCursor = { fill: "color-mix(in srgb, var(--color-ink-900) 4%, transparent)" } as const;

View File

@ -7,13 +7,14 @@ import {
} from "recharts";
import { get } from "../api/client";
import type { CostStatsRes } from "../api/types";
import { Card, Empty, ErrorNote, Loading, StatTile } from "../components/ui";
import { Card, Empty, ErrorNote, Legend, Loading, PageHeader, Segmented, StatTile } from "../components/ui";
import { barCursor, gridProps, tooltipProps, xAxisProps, yAxisProps } from "../lib/chart";
import { dateShort, usd } from "../lib/format";
const RANGES = [
{ h: 24, label: "24시간" },
{ h: 48, label: "48시간" },
{ h: 168, label: "7일" },
{ v: 24, label: "24시간" },
{ v: 48, label: "48시간" },
{ v: 168, label: "7일" },
];
export default function Costs() {
@ -32,68 +33,66 @@ export default function Costs() {
const perSearch = totals.jobs > 0 ? totals.total / totals.jobs : null;
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<h1 className="text-[17px] font-bold tracking-tight">비용</h1>
<div role="tablist" aria-label="기간" className="ml-auto flex rounded-lg border border-line-200 bg-surface p-0.5">
{RANGES.map((r) => (
<button key={r.h} role="tab" aria-selected={hours === r.h} onClick={() => setHours(r.h)}
className={`rounded-md px-3 py-1.5 text-[12px] font-semibold ${hours === r.h ? "bg-primary-50 text-primary-700" : "text-ink-500 hover:text-ink-700"}`}>
{r.label}
</button>
))}
</div>
</div>
<div className="flex h-full min-h-0 flex-col gap-4">
<PageHeader title="비용">
<Segmented ariaLabel="기간" options={RANGES} value={hours} onChange={setHours} />
</PageHeader>
{cost.isError && <ErrorNote error={cost.error} />}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<div className="grid shrink-0 grid-cols-2 gap-3 md:grid-cols-4">
<StatTile label="완료 검색" value={totals.jobs} sub="기간 내" />
<StatTile label="총 비용" value={usd(totals.total, 2)} sub={`AI ${usd(totals.ai, 2)} + 프록시 ${usd(totals.proxy, 2)}`} />
<StatTile label="검색 1건당" value={perSearch === null ? "—" : usd(perSearch)} sub="평균 원가" />
<StatTile label="프록시 비중" value={totals.total > 0 ? `${Math.round((totals.proxy / totals.total) * 100)}%` : "—"} sub="대역폭이 원가의 대부분" />
</div>
<Card title="시간별 검색원가" hint="AI vs 프록시 대역폭">
<Card fill className="flex-[3]" title="시간별 검색원가" hint="AI vs 프록시 대역폭">
{cost.isPending && <Loading />}
{cost.data && buckets.length === 0 && <Empty>기간 내 완료된 검색이 없습니다 — 워커를 켜고 검색을 돌리면 채워집니다</Empty>}
{cost.data && buckets.length === 0 && (
<div className="grid h-full place-items-center"><Empty>기간 내 완료된 검색이 없습니다 — 워커를 켜고 검색을 돌리면 채워집니다</Empty></div>
)}
{buckets.length > 0 && (
<>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={buckets} margin={{ top: 6, right: 12, bottom: 0, left: -10 }}>
<CartesianGrid stroke="var(--color-grid)" vertical={false} />
<XAxis dataKey="x" tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={{ stroke: "var(--color-line-200)" }} minTickGap={40} />
<YAxis tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={false} tickFormatter={(v: number) => `$${v}`} />
<Tooltip formatter={(v: number, name: string) => [usd(v), name]}
labelFormatter={(l, payload) => {
const jobs = (payload?.[0]?.payload as { jobs?: number } | undefined)?.jobs;
return `${l}${jobs != null ? ` · 검색 ${jobs}건` : ""}`;
}} />
<Bar dataKey="ai_usd" name="AI" stackId="c" fill="var(--color-cost-ai)" maxBarSize={28} isAnimationActive={false} />
<Bar dataKey="proxy_usd" name="프록시" stackId="c" fill="var(--color-cost-proxy)" radius={[4, 4, 0, 0]} maxBarSize={28} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
<p className="mt-1 flex gap-4 text-[11px] font-semibold text-ink-500">
<span><span className="mr-1 inline-block h-2 w-2 rounded-full bg-cost-ai" aria-hidden />AI (판정·검색어)</span>
<span><span className="mr-1 inline-block h-2 w-2 rounded-full bg-cost-proxy" aria-hidden />프록시 대역폭 (DECODO)</span>
</p>
<div className="min-h-0 flex-1">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={buckets} margin={{ top: 6, right: 12, bottom: 0, left: -10 }}>
<CartesianGrid {...gridProps} />
<XAxis dataKey="x" {...xAxisProps} minTickGap={40} />
<YAxis {...yAxisProps} tickFormatter={(v: number) => `$${v}`} />
<Tooltip {...tooltipProps} cursor={barCursor} formatter={(v: number, name: string) => [usd(v), name]}
labelFormatter={(l, payload) => {
const jobs = (payload?.[0]?.payload as { jobs?: number } | undefined)?.jobs;
return `${l}${jobs != null ? ` · 검색 ${jobs}건` : ""}`;
}} />
<Bar dataKey="ai_usd" name="AI" stackId="c" fill="var(--color-cost-ai)" maxBarSize={28} isAnimationActive={false} />
<Bar dataKey="proxy_usd" name="프록시" stackId="c" fill="var(--color-cost-proxy)" radius={[4, 4, 0, 0]} maxBarSize={28} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
</div>
<Legend items={[
{ label: "AI (판정·검색어)", color: "var(--color-cost-ai)" },
{ label: "프록시 대역폭 (DECODO)", color: "var(--color-cost-proxy)" },
]} />
</>
)}
</Card>
<Card title="검색 1건 평균 소요 시간" hint="시간별 · 상승하면 경합/차단 신호">
<Card fill className="flex-[2]" title="검색 1건 평균 소요 시간" hint="시간별 · 상승하면 경합/차단 신호">
{buckets.length === 0 ? (
<Empty>데이터 없음</Empty>
<div className="grid h-full place-items-center"><Empty>데이터 없음</Empty></div>
) : (
<ResponsiveContainer width="100%" height={160}>
<LineChart data={buckets} margin={{ top: 6, right: 12, bottom: 0, left: -10 }}>
<CartesianGrid stroke="var(--color-grid)" vertical={false} />
<XAxis dataKey="x" tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={{ stroke: "var(--color-line-200)" }} minTickGap={40} />
<YAxis tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={false} tickFormatter={(v: number) => `${(v / 1000).toFixed(0)}s`} />
<Tooltip formatter={(v: number) => [`${(v / 1000).toFixed(1)}초`, "평균 소요"]} />
<Line type="monotone" dataKey="avg_ms" name="평균 소요" stroke="var(--color-primary-600)" strokeWidth={2} dot={false} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
<div className="min-h-0 flex-1">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={buckets} margin={{ top: 6, right: 12, bottom: 0, left: -10 }}>
<CartesianGrid {...gridProps} />
<XAxis dataKey="x" {...xAxisProps} minTickGap={40} />
<YAxis {...yAxisProps} tickFormatter={(v: number) => `${(v / 1000).toFixed(0)}s`} />
<Tooltip {...tooltipProps} formatter={(v: number) => [`${(v / 1000).toFixed(1)}초`, "평균 소요"]} />
<Line type="monotone" dataKey="avg_ms" name="평균 소요" stroke="var(--color-primary-600)" strokeWidth={2} dot={false} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
</div>
)}
</Card>
</div>

View File

@ -7,9 +7,28 @@ import {
} from "recharts";
import { get } from "../api/client";
import type { BotStatsRes, IpSessionStatsRes } from "../api/types";
import { Card, Empty, ErrorNote, Loading } from "../components/ui";
import { Card, Empty, ErrorNote, Loading, PageHeader, ScrollTable, Segmented } from "../components/ui";
import { barCursor, gridProps, tooltipProps, xAxisProps, yAxisProps } from "../lib/chart";
import { dateShort, durationSec, hhmm } from "../lib/format";
const BOT_COLS = [
{ key: "time", label: "시각" },
{ key: "query", label: "검색어" },
{ key: "reqno", label: "요청#", align: "right" },
{ key: "port", label: "포트", align: "right" },
{ key: "marker", label: "감지 근거" },
] as const;
const SESSION_COLS = [
{ key: "end", label: "종료 시각" },
{ key: "source", label: "소스" },
{ key: "port", label: "포트", align: "right" },
{ key: "req", label: "요청", align: "right" },
{ key: "okblock", label: "성공/차단", align: "right" },
{ key: "dur", label: "지속", align: "right" },
{ key: "reason", label: "종료 사유" },
] as const;
// 종료 사유 — 의미 기반 상태색. 색은 전부 index.css @theme 토큰 참조(단일 소스) —
// raw hex 재정의 금지(토큰을 바꾸면 여기도 함께 바뀌어야 한다).
const REASONS: Record<string, { label: string; color: string; desc: string }> = {
@ -23,9 +42,9 @@ const REASONS: Record<string, { label: string; color: string; desc: string }> =
};
const RANGES = [
{ h: 24, label: "24시간" },
{ h: 168, label: "7일" },
{ h: 720, label: "30일" },
{ v: 24, label: "24시간" },
{ v: 168, label: "7일" },
{ v: 720, label: "30일" },
];
export default function Crawl() {
@ -49,17 +68,9 @@ export default function Crawl() {
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<h1 className="text-[17px] font-bold tracking-tight">크롤 상태</h1>
<div role="tablist" aria-label="기간" className="ml-auto flex rounded-lg border border-line-200 bg-surface p-0.5">
{RANGES.map((r) => (
<button key={r.h} role="tab" aria-selected={hours === r.h} onClick={() => setHours(r.h)}
className={`rounded-md px-3 py-1.5 text-[12px] font-semibold ${hours === r.h ? "bg-primary-50 text-primary-700" : "text-ink-500 hover:text-ink-700"}`}>
{r.label}
</button>
))}
</div>
</div>
<PageHeader title="크롤 상태">
<Segmented ariaLabel="기간" options={RANGES} value={hours} onChange={setHours} />
</PageHeader>
{ip.isError && <ErrorNote error={ip.error} />}
@ -82,14 +93,22 @@ export default function Crawl() {
{ip.data && reasons.length === 0 && <Empty>기간 내 세션 없음 — 워커가 검색을 시작하면 쌓입니다</Empty>}
{reasons.length > 0 && (
<div className="flex items-center gap-4">
{/* 고정 크기 차트 — ResponsiveContainer 불필요(dev 경고 발생원) */}
<PieChart width={150} height={150}>
<Pie data={reasons} dataKey="value" nameKey="label" innerRadius={42} outerRadius={70}
paddingAngle={2} stroke="var(--color-surface)" strokeWidth={2} isAnimationActive={false}>
{reasons.map((r) => <Cell key={r.key} fill={r.color} />)}
</Pie>
<Tooltip formatter={(v: number, name: string) => [`${v}건`, name]} />
</PieChart>
{/* 고정 크기 차트 — ResponsiveContainer 불필요(dev 경고 발생원). 중앙에 총계 오버레이. */}
<div className="relative shrink-0">
<PieChart width={150} height={150}>
<Pie data={reasons} dataKey="value" nameKey="label" innerRadius={46} outerRadius={70}
paddingAngle={2} cornerRadius={4} stroke="var(--color-surface)" strokeWidth={2} isAnimationActive={false}>
{reasons.map((r) => <Cell key={r.key} fill={r.color} />)}
</Pie>
{/* 툴팁 제거 — 오른쪽 범례가 사유별 건수를 모두 보여줘 중복이고, 중앙 총계와 겹침 방지 */}
</PieChart>
<div className="pointer-events-none absolute inset-0 grid place-items-center">
<div className="text-center">
<div className="tnum text-[20px] font-bold leading-none">{totalSessions}</div>
<div className="mt-0.5 text-[10px] font-medium text-ink-400">세션</div>
</div>
</div>
</div>
<ul className="min-w-0 flex-1 space-y-1.5 text-[12px]">
{reasons.map((r) => (
<li key={r.key} className="flex items-baseline gap-2">
@ -108,25 +127,32 @@ export default function Crawl() {
)}
</Card>
<Card title="세션당 요청 수 분포" hint="예산 튜닝 근거">
<Card fill title="세션당 요청 수 분포" hint="예산 튜닝 근거">
{ip.isPending && <Loading />}
{ip.data && ip.data.histogram.length === 0 && <Empty>데이터 없음</Empty>}
{ip.data && ip.data.histogram.length === 0 && <div className="grid h-full place-items-center"><Empty>데이터 없음</Empty></div>}
{ip.data && ip.data.histogram.length > 0 && (
<>
<ResponsiveContainer width="100%" height={170}>
<BarChart data={ip.data.histogram} margin={{ top: 14, right: 12, bottom: 0, left: -22 }}>
<CartesianGrid stroke="var(--color-grid)" vertical={false} />
<XAxis dataKey="requests" tick={{ fill: "var(--color-ink-400)" }} tickLine={false}
axisLine={{ stroke: "var(--color-line-200)" }} label={undefined} />
<YAxis allowDecimals={false} tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={false} />
<Tooltip formatter={(v: number) => [`${v}건`, "세션 수"]} labelFormatter={(l) => `IP당 ${l}회 요청`} />
<Bar dataKey="count" fill="var(--color-cost-ai)" radius={[4, 4, 0, 0]} maxBarSize={36} isAnimationActive={false} />
{ip.data.block_min_requests != null && (
<ReferenceLine x={ip.data.block_min_requests} stroke="var(--color-dead-600)" strokeDasharray="4 3"
label={{ value: `차단 시작 ${ip.data.block_min_requests}회`, position: "top", fill: "var(--color-dead-600)", fontSize: 11, fontWeight: 600 }} />
)}
</BarChart>
</ResponsiveContainer>
<div className="min-h-0 flex-1">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={ip.data.histogram} margin={{ top: 14, right: 12, bottom: 0, left: -22 }}>
<defs>
<linearGradient id="histBar" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--color-cost-ai)" stopOpacity={0.95} />
<stop offset="100%" stopColor="var(--color-cost-ai)" stopOpacity={0.55} />
</linearGradient>
</defs>
<CartesianGrid {...gridProps} />
<XAxis dataKey="requests" {...xAxisProps} />
<YAxis allowDecimals={false} {...yAxisProps} />
<Tooltip {...tooltipProps} cursor={barCursor} formatter={(v: number) => [`${v}건`, "세션 수"]} labelFormatter={(l) => `IP당 ${l}회 요청`} />
<Bar dataKey="count" fill="url(#histBar)" radius={[5, 5, 0, 0]} maxBarSize={36} isAnimationActive={false} />
{ip.data.block_min_requests != null && (
<ReferenceLine x={ip.data.block_min_requests} stroke="var(--color-dead-600)" strokeDasharray="4 3"
label={{ value: `차단 시작 ${ip.data.block_min_requests}회`, position: "top", fill: "var(--color-dead-600)", fontSize: 11, fontWeight: 600 }} />
)}
</BarChart>
</ResponsiveContainer>
</div>
<p className="mt-1 text-[11px] leading-relaxed text-ink-400">
X축 = 한 IP로 보낸 요청 수. 분포가 예산값에 몰려 있으면 정상, 붉은 기준선(차단 시작점)보다 예산이 낮아야 안전.
</p>
@ -136,50 +162,45 @@ export default function Crawl() {
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card title="차단 발생 추이" hint="시간대별">
<Card fill title="차단 발생 추이" hint="시간대별">
{bot.isPending && <Loading />}
{bot.isError && <ErrorNote error={bot.error} />}
{bot.data && bot.data.hourly.length === 0 && <Empty>기간 내 차단 없음 <span aria-hidden>🎉</span></Empty>}
{bot.data && bot.data.hourly.length === 0 && <div className="grid h-full place-items-center"><Empty>기간 내 차단 없음 <span aria-hidden>🎉</span></Empty></div>}
{bot.data && bot.data.hourly.length > 0 && (
<ResponsiveContainer width="100%" height={150}>
<BarChart data={bot.data.hourly.map((h) => ({ ...h, x: dateShort(h.bucket) }))} margin={{ top: 6, right: 12, bottom: 0, left: -22 }}>
<CartesianGrid stroke="var(--color-grid)" vertical={false} />
<XAxis dataKey="x" tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={{ stroke: "var(--color-line-200)" }} minTickGap={40} />
<YAxis allowDecimals={false} tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={false} />
<Tooltip formatter={(v: number) => [`${v}건`, "차단"]} />
<Bar dataKey="count" fill="var(--color-dead-600)" radius={[4, 4, 0, 0]} maxBarSize={24} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
<div className="min-h-0 flex-1">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={bot.data.hourly.map((h) => ({ ...h, x: dateShort(h.bucket) }))} margin={{ top: 6, right: 12, bottom: 0, left: -22 }}>
<defs>
<linearGradient id="blockBar" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--color-dead-600)" stopOpacity={0.95} />
<stop offset="100%" stopColor="var(--color-dead-600)" stopOpacity={0.55} />
</linearGradient>
</defs>
<CartesianGrid {...gridProps} />
<XAxis dataKey="x" {...xAxisProps} minTickGap={40} />
<YAxis allowDecimals={false} {...yAxisProps} />
<Tooltip {...tooltipProps} cursor={barCursor} formatter={(v: number) => [`${v}건`, "차단"]} />
<Bar dataKey="count" fill="url(#blockBar)" radius={[5, 5, 0, 0]} maxBarSize={28} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
</div>
)}
</Card>
<Card title="최근 차단 이력" hint="최근 50건">
{bot.data && bot.data.items.length === 0 && <Empty>기간 내 차단 없음</Empty>}
{bot.data && bot.data.items.length > 0 && (
<div className="max-h-64 overflow-y-auto">
<table className="w-full text-left text-[12px]">
<thead>
<tr className="border-b border-line-200 text-[11px] text-ink-400">
<th className="pb-1 pr-2 font-semibold">시각</th>
<th className="pb-1 pr-2 font-semibold">검색어</th>
<th className="pb-1 pr-2 text-right font-semibold">요청#</th>
<th className="pb-1 pr-2 text-right font-semibold">포트</th>
<th className="pb-1 font-semibold">감지 근거</th>
</tr>
</thead>
<tbody>
{bot.data.items.map((b, i) => (
<tr key={i} className="border-b border-line-100">
<td className="tnum py-1.5 pr-2 text-ink-500">{hhmm(b.created_at)}</td>
<td className="max-w-32 truncate py-1.5 pr-2">{b.query || "—"}</td>
<td className="tnum py-1.5 pr-2 text-right">{b.ip_request_no ?? "—"}</td>
<td className="tnum py-1.5 pr-2 text-right text-ink-500">{b.proxy_port ?? "—"}</td>
<td className="max-w-40 truncate py-1.5 text-[11px] text-ink-500">{b.marker}</td>
</tr>
))}
</tbody>
</table>
</div>
<ScrollTable height={240} dense columns={BOT_COLS}>
{bot.data.items.map((b, i) => (
<tr key={i} className="border-b border-line-100">
<td className="tnum py-1.5 pr-2 text-ink-500">{hhmm(b.created_at)}</td>
<td className="max-w-32 truncate py-1.5 pr-2">{b.query || "—"}</td>
<td className="tnum py-1.5 pr-2 text-right">{b.ip_request_no ?? "—"}</td>
<td className="tnum py-1.5 pr-2 text-right text-ink-500">{b.proxy_port ?? "—"}</td>
<td className="max-w-40 truncate py-1.5 text-[11px] text-ink-500">{b.marker}</td>
</tr>
))}
</ScrollTable>
)}
</Card>
</div>
@ -187,42 +208,27 @@ export default function Crawl() {
<Card title="최근 IP 세션" hint="최근 50건">
{ip.data && ip.data.sessions.length === 0 && <Empty>세션 없음</Empty>}
{ip.data && ip.data.sessions.length > 0 && (
<div className="max-h-72 overflow-y-auto">
<table className="w-full text-left text-[12px]">
<thead>
<tr className="border-b border-line-200 text-[11px] text-ink-400">
<th className="pb-1 pr-2 font-semibold">종료 시각</th>
<th className="pb-1 pr-2 font-semibold">소스</th>
<th className="pb-1 pr-2 text-right font-semibold">포트</th>
<th className="pb-1 pr-2 text-right font-semibold">요청</th>
<th className="pb-1 pr-2 text-right font-semibold">성공/차단</th>
<th className="pb-1 pr-2 text-right font-semibold">지속</th>
<th className="pb-1 font-semibold">종료 사유</th>
<ScrollTable height={320} dense columns={SESSION_COLS}>
{ip.data.sessions.map((s, i) => {
const r = REASONS[s.end_reason] ?? { label: s.end_reason, color: "var(--color-ink-400)" };
return (
<tr key={i} className="border-b border-line-100">
<td className="tnum py-1.5 pr-2 text-ink-500">{dateShort(s.created_at)}</td>
<td className="py-1.5 pr-2">{s.source}</td>
<td className="tnum py-1.5 pr-2 text-right text-ink-500">{s.proxy_port ?? "—"}</td>
<td className="tnum py-1.5 pr-2 text-right font-semibold">{s.requests}</td>
<td className="tnum py-1.5 pr-2 text-right text-ink-500">{s.ok_count}/{s.blocked_count}</td>
<td className="tnum py-1.5 pr-2 text-right text-ink-500">{durationSec(s.elapsed_sec)}</td>
<td className="py-1.5">
<span className="inline-flex items-center gap-1.5 text-[12px] font-semibold" style={{ color: r.color }}>
<span className="h-1.5 w-1.5 rounded-full" style={{ background: r.color }} aria-hidden />
{r.label}
</span>
</td>
</tr>
</thead>
<tbody>
{ip.data.sessions.map((s, i) => {
const r = REASONS[s.end_reason] ?? { label: s.end_reason, color: "var(--color-ink-400)" };
return (
<tr key={i} className="border-b border-line-100">
<td className="tnum py-1.5 pr-2 text-ink-500">{dateShort(s.created_at)}</td>
<td className="py-1.5 pr-2">{s.source}</td>
<td className="tnum py-1.5 pr-2 text-right text-ink-500">{s.proxy_port ?? "—"}</td>
<td className="tnum py-1.5 pr-2 text-right font-semibold">{s.requests}</td>
<td className="tnum py-1.5 pr-2 text-right text-ink-500">{s.ok_count}/{s.blocked_count}</td>
<td className="tnum py-1.5 pr-2 text-right text-ink-500">{durationSec(s.elapsed_sec)}</td>
<td className="py-1.5">
<span className="inline-flex items-center gap-1.5 text-[12px] font-semibold" style={{ color: r.color }}>
<span className="h-1.5 w-1.5 rounded-full" style={{ background: r.color }} aria-hidden />
{r.label}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
})}
</ScrollTable>
)}
</Card>
</div>

View File

@ -7,7 +7,8 @@ import {
} from "recharts";
import { get } from "../api/client";
import type { OpsSnapshot } from "../api/types";
import { Card, ErrorNote, StatTile } from "../components/ui";
import { Card, ErrorNote, Legend, PageHeader, StatTile } from "../components/ui";
import { gridProps, tooltipProps, xAxisProps, yAxisProps } from "../lib/chart";
import { durationSec, usd } from "../lib/format";
// 알림 임계 기본값 미러 — 서버 [AlertConfig] 와 동일 기본(서버가 조정했으면 다를 수 있음, 참고용 배너).
@ -44,8 +45,8 @@ export default function Dashboard() {
}
return (
<div className="space-y-4">
<h1 className="text-[17px] font-bold tracking-tight">대시보드</h1>
<div className="flex h-full min-h-0 flex-col gap-4">
<PageHeader title="대시보드" />
{ops.isError && <ErrorNote error={ops.error} />}
{alerts.map((a) => (
@ -71,28 +72,32 @@ export default function Dashboard() {
<StatTile label="DB 풀" value={d ? `${d.pool_pct}%` : "—"} sub={d ? `${d.pool_checked_out}/${d.pool_capacity} 사용 중` : undefined} />
</div>
<Card title="큐 추이" hint="5초 간격 · 이 화면을 열어둔 동안 누적">
{trend.current.length < 2 ? (
<p className="py-6 text-center text-[12px] text-ink-400">수집 중… 잠시 후 그래프가 나타납니다</p>
) : (
<ResponsiveContainer width="100%" height={180}>
<LineChart data={trend.current} margin={{ top: 6, right: 12, bottom: 0, left: -18 }}>
<CartesianGrid stroke="var(--color-grid)" vertical={false} />
<XAxis dataKey="t" tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={{ stroke: "var(--color-line-200)" }} minTickGap={48} />
<YAxis allowDecimals={false} tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={false} />
<Tooltip formatter={(v: number, name: string) => [v, name === "pending" ? "대기" : "처리중"]} />
<Line type="monotone" dataKey="pending" name="대기" stroke="var(--color-warn-600)" strokeWidth={2} dot={false} isAnimationActive={false} />
<Line type="monotone" dataKey="running" name="처리중" stroke="var(--color-run-600)" strokeWidth={2} dot={false} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
)}
<p className="mt-1 flex gap-4 text-[11px] font-semibold text-ink-500">
<span><span className="mr-1 inline-block h-2 w-2 rounded-full bg-warn-600" aria-hidden />대기</span>
<span><span className="mr-1 inline-block h-2 w-2 rounded-full bg-run-600" aria-hidden />처리중</span>
</p>
<Card fill className="flex-1" title="큐 추이" hint="5초 간격 · 이 화면을 열어둔 동안 누적">
<div className="min-h-0 flex-1">
{trend.current.length < 2 ? (
<div className="grid h-full place-items-center">
<p className="text-[12px] text-ink-400">수집 중… 잠시 후 그래프가 나타납니다</p>
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={trend.current} margin={{ top: 6, right: 12, bottom: 0, left: -18 }}>
<CartesianGrid {...gridProps} />
<XAxis dataKey="t" {...xAxisProps} minTickGap={48} />
<YAxis allowDecimals={false} {...yAxisProps} />
<Tooltip {...tooltipProps} formatter={(v: number, name: string) => [v, name === "pending" ? "대기" : "처리중"]} />
<Line type="monotone" dataKey="pending" name="대기" stroke="var(--color-warn-600)" strokeWidth={2} dot={false} isAnimationActive={false} />
<Line type="monotone" dataKey="running" name="처리중" stroke="var(--color-run-600)" strokeWidth={2} dot={false} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
)}
</div>
<Legend items={[
{ label: "대기", color: "var(--color-warn-600)" },
{ label: "처리중", color: "var(--color-run-600)" },
]} />
</Card>
<p className="text-[11px] text-ink-400">
<p className="shrink-0 text-[11px] text-ink-400">
임계 배너는 서버 알림 기본값 기준입니다(서버 toml 에서 조정했으면 실제 알림과 다를 수 있음).
전체 룰은 lps/docs/operations.md 참고.
</p>

View File

@ -1,12 +1,24 @@
/** 작업 큐 — 잡 목록(필터·검색·페이지네이션) + 상세 패널 + DEAD 재큐. */
/** 작업 큐 — 잡 목록(필터·검색·페이지네이션) 표 + 행별 상세/JSON 모달 + DEAD 재큐. */
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { get, post } from "../api/client";
import type { JobDetailRes, JobListItem, JobListRes, JobStatusName, RequeueRes } from "../api/types";
import { Card, Empty, ErrorNote, Loading, StatusBadge } from "../components/ui";
import { Button, Card, Empty, ErrorNote, Loading, Modal, PageHeader, ScrollTable, SearchForm, Segmented, StatusBadge } from "../components/ui";
import { dateShort, usd, won } from "../lib/format";
type DetailView = "detail" | "json";
const COLS = [
{ key: "product", label: "상품" },
{ key: "status", label: "상태" },
{ key: "outcome", label: "결과" },
{ key: "price", label: "최저가", align: "right" },
{ key: "cost", label: "원가", align: "right" },
{ key: "time", label: "요청 시각" },
{ key: "actions", label: "", align: "right" },
] as const;
const PAGE = 20;
const TABS: { v: JobStatusName | ""; label: string }[] = [
{ v: "", label: "전체" },
@ -21,7 +33,10 @@ export default function Jobs() {
const [q, setQ] = useState("");
const [qInput, setQInput] = useState("");
const [page, setPage] = useState(0);
const [selected, setSelected] = useState<JobListItem | null>(null);
const [modalJob, setModalJob] = useState<JobListItem | null>(null);
const [modalView, setModalView] = useState<DetailView>("detail");
const open = (j: JobListItem, view: DetailView) => { setModalView(view); setModalJob(j); };
const params = new URLSearchParams({ limit: String(PAGE), offset: String(page * PAGE) });
if (status) params.set("status", status);
@ -35,21 +50,12 @@ export default function Jobs() {
const pages = Math.max(1, Math.ceil((list.data?.total ?? 0) / PAGE));
return (
<div className="space-y-4">
<h1 className="text-[17px] font-bold tracking-tight">작업 큐</h1>
<div className="flex h-full min-h-0 flex-col gap-4">
<PageHeader title="작업 큐" />
<div className="flex flex-wrap items-center gap-2">
<div role="tablist" aria-label="상태 필터" className="flex rounded-lg border border-line-200 bg-surface p-0.5">
{TABS.map((t) => (
<button key={t.v} role="tab" aria-selected={status === t.v}
onClick={() => { setStatus(t.v); setPage(0); }}
className={`rounded-md px-3 py-1.5 text-[12px] font-semibold ${
status === t.v ? "bg-primary-50 text-primary-700" : "text-ink-500 hover:text-ink-700"
}`}>
{t.label}
</button>
))}
</div>
<Segmented ariaLabel="상태 필터" options={TABS} value={status}
onChange={(v) => { setStatus(v); setPage(0); }} />
<button onClick={() => { const v = q === "TEST-" ? "" : "TEST-"; setQ(v); setQInput(v); setPage(0); }}
aria-pressed={q === "TEST-"}
className={`rounded-lg border px-3 py-1.5 text-[12px] font-semibold ${
@ -57,119 +63,130 @@ export default function Jobs() {
}`}>
테스트만
</button>
<form className="ml-auto flex gap-1.5" onSubmit={(e) => { e.preventDefault(); setQ(qInput.trim()); setPage(0); }}>
<input value={qInput} onChange={(e) => setQInput(e.target.value)} placeholder="상품코드·상품명 검색" aria-label="상품코드·상품명 검색"
className="w-56 rounded-lg border border-line-200 bg-surface px-3 py-1.5 text-[13px] outline-none focus:border-primary-600" />
<button type="submit" className="rounded-lg bg-primary-600 px-3 py-1.5 text-[12px] font-semibold text-white hover:bg-primary-700">검색</button>
</form>
<SearchForm className="ml-auto" inputClassName="w-56" placeholder="상품코드·상품명 검색"
value={qInput} onChange={setQInput} onSubmit={() => { setQ(qInput.trim()); setPage(0); }} />
</div>
<div className="grid gap-4 lg:grid-cols-[1fr_360px]">
<Card title={`작업 ${list.data?.total ?? "—"}건`} hint="15초마다 갱신">
{list.isPending && <Loading />}
{list.isError && <ErrorNote error={list.error} />}
{list.data && list.data.items.length === 0 && <Empty>조건에 맞는 작업이 없습니다</Empty>}
{list.data && list.data.items.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-left text-[13px]">
<thead>
<tr className="border-b border-line-200 text-[11px] text-ink-400">
<th className="pb-1.5 pr-3 font-semibold">상품</th>
<th className="pb-1.5 pr-3 font-semibold">상태</th>
<th className="pb-1.5 pr-3 font-semibold">결과</th>
<th className="pb-1.5 pr-3 text-right font-semibold">최저가</th>
<th className="pb-1.5 pr-3 text-right font-semibold">원가</th>
<th className="pb-1.5 font-semibold">요청 시각</th>
</tr>
</thead>
<tbody>
{list.data.items.map((j) => (
<tr key={j.job_id} onClick={() => setSelected(j)} tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setSelected(j); } }}
aria-selected={selected?.job_id === j.job_id}
className={`cursor-pointer border-b border-line-100 hover:bg-surface-2 focus-visible:bg-primary-50/60 ${selected?.job_id === j.job_id ? "bg-primary-50/60" : ""}`}>
<td className="py-2 pr-3">
<div className="font-semibold">{j.product_name || "—"}</div>
<div className="text-[11px] text-ink-400">{j.product_code}</div>
</td>
<td className="py-2 pr-3"><StatusBadge status={j.status} /></td>
<td className="py-2 pr-3">{j.outcome === "found" ? "찾음" : j.outcome === "not_found" ? "없음" : "—"}</td>
<td className="tnum py-2 pr-3 text-right">{won(j.final_lowest)}</td>
<td className="tnum py-2 pr-3 text-right text-ink-500">{usd(j.cost_usd)}</td>
<td className="tnum py-2 text-[12px] text-ink-500">{dateShort(j.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{pages > 1 && (
<nav aria-label="페이지" className="mt-3 flex items-center justify-end gap-2 text-[12px]">
<button disabled={page === 0} onClick={() => setPage((p) => p - 1)}
className="rounded-md border border-line-200 px-2.5 py-1 font-semibold disabled:opacity-40">이전</button>
<span className="tnum text-ink-500">{page + 1} / {pages}</span>
<button disabled={page + 1 >= pages} onClick={() => setPage((p) => p + 1)}
className="rounded-md border border-line-200 px-2.5 py-1 font-semibold disabled:opacity-40">다음</button>
</nav>
)}
</Card>
<Card fill className="min-h-0 flex-1" title={`작업 ${list.data?.total ?? "—"}건`} hint="15초마다 갱신">
{list.isPending && <Loading />}
{list.isError && <ErrorNote error={list.error} />}
{list.data && list.data.items.length === 0 && <Empty>조건에 맞는 작업이 없습니다</Empty>}
{list.data && list.data.items.length > 0 && (
<ScrollTable height="fill" columns={COLS}>
{list.data.items.map((j) => (
<tr key={j.job_id} className="border-b border-line-100 hover:bg-surface-2">
<td className="py-2 pr-3">
<div className="font-semibold">{j.product_name || "—"}</div>
<div className="text-[11px] text-ink-400">{j.product_code}</div>
</td>
<td className="py-2 pr-3"><StatusBadge status={j.status} /></td>
<td className="py-2 pr-3">{j.outcome === "found" ? "찾음" : j.outcome === "not_found" ? "없음" : "—"}</td>
<td className="tnum py-2 pr-3 text-right">{won(j.final_lowest)}</td>
<td className="tnum py-2 pr-3 text-right text-ink-500">{usd(j.cost_usd)}</td>
<td className="tnum py-2 pr-3 text-[12px] text-ink-500">{dateShort(j.created_at)}</td>
<td className="py-2 text-right">
<div className="flex justify-end gap-1.5">
<Button variant="outline" size="sm" onClick={() => open(j, "detail")}>상세</Button>
<Button variant="outline" size="sm" onClick={() => open(j, "json")}>JSON</Button>
</div>
</td>
</tr>
))}
</ScrollTable>
)}
{pages > 1 && (
<nav aria-label="페이지" className="mt-3 flex shrink-0 items-center justify-end gap-2 text-[12px]">
<Button variant="outline" size="sm" disabled={page === 0} onClick={() => setPage((p) => p - 1)}>이전</Button>
<span className="tnum text-ink-500">{page + 1} / {pages}</span>
<Button variant="outline" size="sm" disabled={page + 1 >= pages} onClick={() => setPage((p) => p + 1)}>다음</Button>
</nav>
)}
</Card>
<JobDetail job={selected} onClose={() => setSelected(null)} />
</div>
{modalJob && (
<JobModal job={modalJob} view={modalView} onView={setModalView} onClose={() => setModalJob(null)} />
)}
</div>
);
}
function JobDetail({ job, onClose }: { job: JobListItem | null; onClose: () => void }) {
// 행별 "상세"/"JSON" 버튼이 여는 모달. 상단 토글로 두 뷰 전환, JSON 뷰엔 복사 버튼.
function JobModal({ job, view, onView, onClose }: {
job: JobListItem; view: DetailView; onView: (v: DetailView) => void; onClose: () => void;
}) {
const qc = useQueryClient();
const detail = useQuery({
queryKey: ["job", job?.job_id],
queryFn: () => get<JobDetailRes>(`/v1/lps/jobs/${job!.job_id}`),
enabled: !!job,
queryKey: ["job", job.job_id],
queryFn: () => get<JobDetailRes>(`/v1/lps/jobs/${job.job_id}`),
});
const requeue = useMutation({
mutationFn: () => post<RequeueRes>(`/v1/lps/jobs/${job!.job_id}/requeue`),
mutationFn: () => post<RequeueRes>(`/v1/lps/jobs/${job.job_id}/requeue`),
onSuccess: () => qc.invalidateQueries({ queryKey: ["jobs"] }),
});
if (!job) return <Card title="상세"><Empty>왼쪽 목록에서 작업을 선택하세요</Empty></Card>;
const [copied, setCopied] = useState(false);
const out = detail.data?.output as Record<string, any> | undefined;
const jsonText = out ? JSON.stringify(out, null, 2) : "";
const copyJson = async () => {
try {
await navigator.clipboard.writeText(jsonText);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch { /* 클립보드 권한 없음 — 무시 */ }
};
return (
<Card title="상세" hint={<button onClick={onClose} className="font-semibold text-ink-400 hover:text-ink-700">닫기</button>}>
<dl className="space-y-2 text-[13px]">
<Row k="작업 ID"><span className="tnum break-all text-[12px]">{job.job_id}</span></Row>
<Row k="상품">{job.product_name || "—"}{job.product_code && <span className="text-ink-400"> ({job.product_code})</span>}</Row>
<Row k="상태"><StatusBadge status={job.status} /> <span className="text-ink-400">시도 {job.attempts}/{job.max_attempts}</span></Row>
{job.last_error && <Row k="오류"><span className="break-all text-dead-600">{job.last_error}</span></Row>}
{out?.outcome && <Row k="결과">{out.outcome === "found" ? "같은 상품을 찾음" : "같은 상품 없음"}</Row>}
{out?.lowest && <Row k="최저가"><b>{won(out.lowest.price)}</b> <span className="text-ink-400">({out.lowest.source} · {out.lowest.mall_name || "—"})</span></Row>}
{out?.metrics?.cost && (
<Row k="검색 원가">
{usd(out.metrics.cost.total_usd)} <span className="text-ink-400">(AI {usd(out.metrics.cost.ai_usd)} + 프록시 {usd(out.metrics.cost.proxy_usd)})</span>
</Row>
)}
{out?.metrics?.duration_ms != null && <Row k="소요">{(out.metrics.duration_ms / 1000).toFixed(1)}초</Row>}
</dl>
<Modal
title={<>상세 <span className="font-medium text-ink-400">— {job.product_name || job.product_code}</span></>}
onClose={onClose}
actions={
<>
<Segmented ariaLabel="보기 방식" size="sm" value={view}
options={[{ v: "detail", label: "상세" }, { v: "json", label: "JSON" }]} onChange={onView} />
{view === "json" && (
<Button variant="outline" size="sm" disabled={!out} onClick={copyJson}>{copied ? "복사됨 ✓" : "복사"}</Button>
)}
</>
}
>
{view === "detail" ? (
<>
<dl className="space-y-2 text-[13px]">
<Row k="작업 ID"><span className="tnum break-all text-[12px]">{job.job_id}</span></Row>
<Row k="상품">{job.product_name || "—"}{job.product_code && <span className="text-ink-400"> ({job.product_code})</span>}</Row>
<Row k="상태"><StatusBadge status={job.status} /> <span className="text-ink-400">시도 {job.attempts}/{job.max_attempts}</span></Row>
{job.last_error && <Row k="오류"><span className="break-all text-dead-600">{job.last_error}</span></Row>}
{detail.isPending && <Row k="결과"><span className="text-ink-400">불러오는 중…</span></Row>}
{out?.outcome && <Row k="결과">{out.outcome === "found" ? "같은 상품을 찾음" : "같은 상품 없음"}</Row>}
{out?.lowest && <Row k="최저가"><b>{won(out.lowest.price)}</b> <span className="text-ink-400">({out.lowest.source} · {out.lowest.mall_name || "—"})</span></Row>}
{out?.metrics?.cost && (
<Row k="검색 원가">
{usd(out.metrics.cost.total_usd)} <span className="text-ink-400">(AI {usd(out.metrics.cost.ai_usd)} + 프록시 {usd(out.metrics.cost.proxy_usd)})</span>
</Row>
)}
{out?.metrics?.duration_ms != null && <Row k="소요">{(out.metrics.duration_ms / 1000).toFixed(1)}초</Row>}
</dl>
{job.status === "DEAD" && (
<div className="mt-4 border-t border-line-100 pt-3">
<button onClick={() => requeue.mutate()} disabled={requeue.isPending}
className="w-full rounded-lg bg-primary-600 px-3 py-2 text-[13px] font-semibold text-white hover:bg-primary-700 disabled:opacity-50">
{requeue.isPending ? "재시도 요청 중…" : "다시 검색하기 (재큐)"}
</button>
{requeue.isError && <p className="mt-2 text-[12px] font-medium text-dead-600">{(requeue.error as Error).message}</p>}
{requeue.isSuccess && <p className="mt-2 text-[12px] font-medium text-ok-600">대기열에 다시 넣었습니다 — 워커가 곧 처리합니다</p>}
</div>
{job.status === "DEAD" && (
<div className="mt-4 border-t border-line-100 pt-3">
<Button size="lg" className="w-full" onClick={() => requeue.mutate()} disabled={requeue.isPending}>
{requeue.isPending ? "재시도 요청 중…" : "다시 검색하기 (재큐)"}
</Button>
{requeue.isError && <p className="mt-2 text-[12px] font-medium text-dead-600">{(requeue.error as Error).message}</p>}
{requeue.isSuccess && <p className="mt-2 text-[12px] font-medium text-ok-600">대기열에 다시 넣었습니다 — 워커가 곧 처리합니다</p>}
</div>
)}
</>
) : detail.isPending ? (
<Loading />
) : detail.isError ? (
<ErrorNote error={detail.error} />
) : out ? (
<pre className="w-max min-w-full rounded-lg bg-surface-2 p-3 text-[11px] leading-relaxed">{jsonText}</pre>
) : (
<Empty>이 작업에는 원본 결과가 없습니다 (완료 전이거나 실패)</Empty>
)}
{out && (
<details className="mt-3">
<summary className="cursor-pointer text-[12px] font-semibold text-ink-400">원본 결과(JSON)</summary>
<pre className="mt-1 max-h-64 overflow-auto rounded-lg bg-surface-2 p-2 text-[11px] leading-relaxed">{JSON.stringify(out, null, 2)}</pre>
</details>
)}
</Card>
</Modal>
);
}

View File

@ -3,11 +3,12 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import {
CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
Area, CartesianGrid, ComposedChart, Line, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis,
} from "recharts";
import { get, post } from "../api/client";
import type { PriceHistoryRes, PricePoint, ProductItem, ProductListRes, SearchRes } from "../api/types";
import { Card, Empty, ErrorNote, Loading } from "../components/ui";
import { Button, Card, Empty, ErrorNote, Legend, Loading, PageHeader, ScrollBox, ScrollTable, SearchForm, Segmented } from "../components/ui";
import { gridProps, xAxisProps, yAxisProps } from "../lib/chart";
import { dateShort, timeAgo, won } from "../lib/format";
// 시리즈 색은 엔터티 고정(네이버 초록은 CVD 분리 검증 통과 톤) — 순서·색 재배정 금지.
@ -17,6 +18,34 @@ const SERIES = [
{ key: "final", label: "최종 최저가", color: "var(--color-final)" },
] as const;
// 커스텀 툴팁 — 둥근 카드·부드러운 그림자·토큰 색. 값 없는(못 찾은) 소스는 생략, 시리즈 순서 고정.
function ChartTooltip({ active, payload, label }: {
active?: boolean;
payload?: { dataKey?: string | number; value?: number; name?: string }[];
label?: string;
}) {
if (!active || !payload?.length) return null;
const byKey = new Map(payload.map((p) => [p.dataKey, p]));
return (
<div className="rounded-xl border border-line-200 bg-surface px-3 py-2 shadow-[0_8px_24px_rgba(16,24,40,.14)]">
<div className="mb-1.5 text-[11px] font-semibold text-ink-400">{label}</div>
<ul className="space-y-1">
{SERIES.map((s) => {
const p = byKey.get(s.key);
if (p?.value == null) return null;
return (
<li key={s.key} className="flex items-center gap-2 text-[12px]">
<span className="h-2 w-2 shrink-0 rounded-full" style={{ background: s.color }} aria-hidden />
<span className="text-ink-500">{s.label}</span>
<span className="tnum ml-auto pl-6 font-bold text-ink-900">{won(p.value)}</span>
</li>
);
})}
</ul>
</div>
);
}
export default function Products() {
const [q, setQ] = useState("");
const [qInput, setQInput] = useState("");
@ -28,26 +57,26 @@ export default function Products() {
});
return (
<div className="space-y-4">
<h1 className="text-[17px] font-bold tracking-tight">상품·가격</h1>
<div className="flex h-full min-h-0 flex-col gap-4">
<PageHeader title="상품·가격" />
<form className="flex gap-1.5" onSubmit={(e) => { e.preventDefault(); setQ(qInput.trim()); }}>
<input value={qInput} onChange={(e) => setQInput(e.target.value)} placeholder="상품코드 검색" aria-label="상품코드 검색"
className="w-64 rounded-lg border border-line-200 bg-surface px-3 py-1.5 text-[13px] outline-none focus:border-primary-600" />
<button type="submit" className="rounded-lg bg-primary-600 px-3 py-1.5 text-[12px] font-semibold text-white hover:bg-primary-700">검색</button>
</form>
<SearchForm placeholder="상품코드 검색" value={qInput} onChange={setQInput} onSubmit={() => setQ(qInput.trim())} />
<div className="grid min-w-0 gap-4 lg:grid-cols-[400px_1fr]">
<Card className="min-w-0" title={`검색 이력 상품 ${list.data?.items.length ?? "—"}건`} hint="최근 검색순">
<Card fill className="min-h-0 min-w-0 flex-1" title={`검색 이력 상품 ${list.data?.items.length ?? "—"}건`} hint="최근 검색순">
{list.isPending && <Loading />}
{list.isError && <ErrorNote error={list.error} />}
{list.data && list.data.items.length === 0 && <Empty>아직 검색된 상품이 없습니다</Empty>}
{list.data && list.data.items.length > 0 && (
<ul className="max-h-[560px] divide-y divide-line-100 overflow-y-auto">
<ScrollBox height="fill">
<ul className="divide-y divide-line-100">
{list.data.items.map((p) => (
<li key={p.product_code}>
<button onClick={() => setSelected(p)}
className={`w-full px-1 py-2 text-left hover:bg-surface-2 ${selected?.product_code === p.product_code ? "bg-primary-50/60" : ""}`}>
className={`block w-full border-l-2 px-3 py-2.5 text-left transition-colors ${
selected?.product_code === p.product_code
? "border-primary-600 bg-primary-50/70"
: "border-transparent hover:bg-surface-2"
}`}>
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-[13px] font-semibold">{p.display_name || p.product_code}</span>
<span className="tnum shrink-0 text-[13px] font-bold">{won(p.final_lowest)}</span>
@ -59,18 +88,20 @@ export default function Products() {
</button>
</li>
))}
</ul>
</ul>
</ScrollBox>
)}
</Card>
</Card>
<ProductDetail product={selected} />
</div>
<ProductDetail product={selected} />
</div>
);
}
function ProductDetail({ product }: { product: ProductItem | null }) {
const qc = useQueryClient();
// 그래프에서 클릭한 시점(triggered_at). 없으면 최신 시점. 상품이 바뀌어 목록에 없으면 자동으로 최신으로 폴백.
const [selectedTs, setSelectedTs] = useState<string | null>(null);
const history = useQuery({
queryKey: ["history", product?.product_code],
queryFn: () => get<PriceHistoryRes>(`/v1/lps/products/${encodeURIComponent(product!.product_code)}/history?limit=200`),
@ -83,141 +114,161 @@ function ProductDetail({ product }: { product: ProductItem | null }) {
onSuccess: () => qc.invalidateQueries({ queryKey: ["jobs"] }),
});
if (!product) return <Card title="가격 추이"><Empty>왼쪽에서 상품을 선택하세요</Empty></Card>;
if (!product) return (
<Card fill className="min-h-0 flex-[2]" title="가격 추이">
<div className="grid h-full place-items-center"><Empty>목록에서 상품을 선택하세요</Empty></div>
</Card>
);
const points = (history.data?.points ?? []).map((p) => ({ ...p, x: dateShort(p.triggered_at) }));
const latest = history.data?.points.at(-1);
// 클릭한 시점의 데이터 포인트(없으면 최신). 몰별 비교 표에 이 시점의 by_mall 을 보여준다.
const activePoint = points.find((p) => p.triggered_at === selectedTs) ?? latest;
return (
// min-w-0: 그리드 자식이 콘텐츠(차트) 최소폭 때문에 안 줄어들어 가로 넘침 나는 것 방지
<div className="min-w-0 space-y-4">
<Card
// xl 이상: 왼쪽 그래프 + 오른쪽 몰별 표 가로 배치. 그 이하: 세로 스택(스크롤).
// min-w-0: 자식이 콘텐츠(차트) 최소폭 때문에 안 줄어들어 가로 넘침 나는 것 방지.
<div className="flex min-h-0 min-w-0 flex-[2] flex-col gap-4 max-xl:overflow-y-auto xl:flex-row">
<Card fill className="min-w-0 flex-1"
title={<>가격 추이 — {product.display_name || product.product_code}</>}
hint={
<button onClick={() => research.mutate()} disabled={research.isPending}
className="rounded-md bg-primary-600 px-2.5 py-1 text-[12px] font-semibold text-white hover:bg-primary-700 disabled:opacity-50">
<Button size="sm" onClick={() => research.mutate()} disabled={research.isPending}>
{research.isPending ? "요청 중…" : "지금 다시 검색"}
</button>
</Button>
}>
{research.isSuccess && (
<p className="mb-2 rounded-lg bg-ok-50 px-3 py-1.5 text-[12px] font-medium text-ok-600">
<p className="mb-2 shrink-0 rounded-lg bg-ok-50 px-3 py-1.5 text-[12px] font-medium text-ok-600">
{research.data.accepted > 0 ? "검색을 접수했습니다 — 잠시 후 새 점이 추가됩니다" : "이미 같은 상품 검색이 진행 중입니다"}
</p>
)}
{research.isError && <ErrorNote error={research.error} />}
{history.isPending && <Loading />}
{history.isError && <ErrorNote error={history.error} />}
{history.data && points.length === 0 && <Empty>이력이 없습니다</Empty>}
{points.length > 0 && (
<>
<ResponsiveContainer width="100%" height={240}>
<LineChart data={points} margin={{ top: 6, right: 12, bottom: 0, left: 0 }}>
<CartesianGrid stroke="var(--color-grid)" vertical={false} />
<XAxis dataKey="x" tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={{ stroke: "var(--color-line-200)" }} minTickGap={56} />
<YAxis tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={false}
width={64} tickFormatter={(v: number) => v.toLocaleString("ko-KR")} domain={["auto", "auto"]} />
<Tooltip formatter={(v: number, name: string) => [won(v), name]} />
{SERIES.map((s) => (
<Line key={s.key} type="monotone" dataKey={s.key} name={s.label} stroke={s.color}
strokeWidth={2} dot={{ r: 2.5, strokeWidth: 0, fill: s.color }} connectNulls isAnimationActive={false} />
{research.isError && <div className="mb-2 shrink-0"><ErrorNote error={research.error} /></div>}
<div className="min-h-[240px] flex-1">
{history.isPending ? (
<div className="grid h-full place-items-center"><Loading /></div>
) : history.isError ? (
<ErrorNote error={history.error} />
) : points.length === 0 ? (
<div className="grid h-full place-items-center"><Empty>이력이 없습니다</Empty></div>
) : (
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={points} margin={{ top: 10, right: 16, bottom: 0, left: 0 }} style={{ cursor: "pointer" }}
onClick={(state) => {
const ts = (state as { activePayload?: { payload?: PricePoint }[] })?.activePayload?.[0]?.payload?.triggered_at;
if (ts) setSelectedTs(ts);
}}>
<defs>
<linearGradient id="finalArea" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--color-final)" stopOpacity={0.14} />
<stop offset="100%" stopColor="var(--color-final)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid {...gridProps} />
<XAxis dataKey="x" {...xAxisProps} minTickGap={56} tickMargin={8} />
<YAxis {...yAxisProps} width={64} tickMargin={4} tickFormatter={(v: number) => v.toLocaleString("ko-KR")} domain={[0, "auto"]} />
<Tooltip content={<ChartTooltip />} cursor={{ stroke: "var(--color-line-200)", strokeWidth: 1 }} />
{activePoint && (
<ReferenceLine x={dateShort(activePoint.triggered_at)} stroke="var(--color-ink-400)" strokeDasharray="3 3" />
)}
{SERIES.filter((s) => s.key !== "final").map((s) => (
<Line key={s.key} type="monotone" dataKey={s.key} name={s.label} stroke={s.color} strokeWidth={2.5}
strokeLinecap="round" connectNulls isAnimationActive={false}
dot={{ r: 3, fill: s.color, stroke: "var(--color-surface)", strokeWidth: 1.5 }}
activeDot={{ r: 5, fill: s.color, stroke: "var(--color-surface)", strokeWidth: 2 }} />
))}
</LineChart>
<Area type="monotone" dataKey="final" name="최종 최저가" stroke="var(--color-final)" strokeWidth={2.5}
strokeLinecap="round" fill="url(#finalArea)" connectNulls isAnimationActive={false}
dot={{ r: 3, fill: "var(--color-final)", stroke: "var(--color-surface)", strokeWidth: 1.5 }}
activeDot={{ r: 5, fill: "var(--color-final)", stroke: "var(--color-surface)", strokeWidth: 2 }} />
</ComposedChart>
</ResponsiveContainer>
<p className="mt-1 flex flex-wrap gap-4 text-[11px] font-semibold text-ink-500">
{SERIES.map((s) => (
<span key={s.key}>
<span className="mr-1 inline-block h-2 w-2 rounded-full" style={{ background: s.color }} aria-hidden />
{s.label}
</span>
))}
<span className="ml-auto font-medium text-ink-400">빈 구간 = 그 시점에 해당 소스에서 못 찾음</span>
</p>
</>
)}
</div>
{points.length > 0 && (
<Legend items={SERIES.map((s) => ({ label: s.label, color: s.color }))}
note="빈 구간 = 그 시점에 해당 소스에서 못 찾음" />
)}
</Card>
{latest && <MallCompare latest={latest} />}
{activePoint ? (
<MallCompare point={activePoint} />
) : (
<Card fill className="min-w-0 xl:w-[320px] xl:shrink-0" title="몰별 가격 비교">
<div className="grid h-full place-items-center"><Empty>최근 검색 데이터가 없습니다</Empty></div>
</Card>
)}
</div>
);
}
// ── 몰별 가격 비교(최근 검색) — 기존 '몰별 최저가' + '검증 링크' 를 하나로.
// by_mall(몰별 최저가, 가격 오름차순)을 상위 N개 표로: 순위·쇼핑몰·상품명·가격(+배송)·최저가 대비·링크.
const TOP_N_PRESETS = [3, 5, 10] as const;
// ── 몰별 가격 비교(최근 검색) — 컴팩트 표. 막대 제거, 소스(발견 채널) 뱃지 추가, 상품명은 보조줄로 유지.
const TOP_OPTIONS: { v: number | "all"; label: string }[] = [
{ v: 3, label: "3" }, { v: 5, label: "5" }, { v: 10, label: "10" }, { v: "all", label: "전체" },
];
function MallCompare({ latest }: { latest: PricePoint }) {
const MALL_COLS = [
{ key: "rank", label: "#", width: "2.25rem" },
{ key: "mall", label: "쇼핑몰" },
{ key: "price", label: "가격", align: "right", width: "5rem" },
] as const;
// 소스(발견 채널) 색·라벨 — 시리즈 색 재사용(엔터티 고정).
const srcColor = (s?: string) => s === "naver" ? "var(--color-naver)" : s === "coupang" ? "var(--color-coupang)" : "var(--color-ink-400)";
const srcLabel = (s?: string) => s === "naver" ? "네이버" : s === "coupang" ? "쿠팡" : (s || "기타");
function MallCompare({ point }: { point: PricePoint }) {
const [topN, setTopN] = useState<number | "all">(5);
const malls = (latest.by_mall ?? [])
const malls = (point.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 (
<Card
title="몰별 가격 비교 (최근 검색)"
hint={
<span className="flex items-center gap-2">
<span className="hidden text-ink-400 sm:inline">{dateShort(latest.triggered_at)}</span>
<span role="tablist" aria-label="상위 N개" className="flex rounded-lg border border-line-200 p-0.5">
{TOP_N_PRESETS.map((n) => (
<button key={n} role="tab" aria-selected={topN === n} onClick={() => setTopN(n)}
className={`rounded-md px-2 py-0.5 text-[11px] font-semibold ${topN === n ? "bg-primary-50 text-primary-700" : "text-ink-500 hover:text-ink-700"}`}>
{n}
</button>
))}
<button role="tab" aria-selected={topN === "all"} onClick={() => setTopN("all")}
className={`rounded-md px-2 py-0.5 text-[11px] font-semibold ${topN === "all" ? "bg-primary-50 text-primary-700" : "text-ink-500 hover:text-ink-700"}`}>
전체
</button>
</span>
</span>
}>
<Card fill className="min-w-0 xl:w-[320px] xl:shrink-0"
title="몰별 가격 비교"
hint={<Segmented ariaLabel="상위 N개" size="sm" value={topN} options={TOP_OPTIONS} onChange={setTopN} />}>
<p className="mb-1.5 shrink-0 text-[11px] text-ink-400">
<span className="tnum font-semibold text-ink-500">{dateShort(point.triggered_at)}</span> 기준 · 그래프의 시점을 클릭해 이동
</p>
{shown.length === 0 ? (
<Empty>몰별 데이터 없음</Empty>
<div className="grid h-full place-items-center"><Empty>몰별 데이터 없음</Empty></div>
) : (
<>
<ul className="space-y-2.5">
<ScrollTable height="fill" dense fixed columns={MALL_COLS}>
{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)";
const pct = cheapest > 0 ? (delta / cheapest) * 100 : 0;
const src = srcColor(m.source);
return (
<li key={i} className="grid grid-cols-[auto_1fr_auto] items-center gap-x-3 gap-y-1">
{/* 순위 */}
<span className={`tnum grid h-6 w-6 place-items-center rounded-full text-[12px] font-bold ${i === 0 ? "bg-ok-50 text-ok-600" : "bg-surface-2 text-ink-500"}`}>
{i + 1}
</span>
{/* 쇼핑몰 + 상품명 */}
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 shrink-0 rounded-full" style={{ background: src }} aria-hidden />
<span className="truncate text-[13px] font-semibold">{m.mall_name || m.source}</span>
{i === 0 && <span className="shrink-0 rounded bg-ok-50 px-1.5 text-[10px] font-bold text-ok-600">최저</span>}
<tr key={i} className="border-b border-line-100 hover:bg-surface-2">
<td className="py-1.5 pr-1 align-top">
<span className={`tnum grid h-4 w-4 place-items-center rounded-full text-[10px] font-bold ${i === 0 ? "bg-ok-50 text-ok-600" : "bg-surface-2 text-ink-500"}`}>{i + 1}</span>
</td>
<td className="min-w-0 py-1.5 pr-2 align-top">
<div className="flex items-center gap-1">
<span className="shrink-0 rounded px-1 text-[9px] font-bold leading-normal" style={{ color: src, background: `color-mix(in srgb, ${src} 14%, transparent)` }}>{srcLabel(m.source)}</span>
<span className="truncate text-[11px] font-semibold">{m.mall_name || m.source}</span>
{i === 0 && <span className="shrink-0 rounded bg-ok-50 px-1 text-[9px] font-bold text-ok-600">최저</span>}
</div>
<div className="truncate text-[11px] text-ink-400">{m.name}</div>
</div>
{/* 가격 + 최저가 대비 + 링크 */}
<div className="text-right">
<div className="tnum text-[14px] font-bold">{won(price)}</div>
<div className="tnum text-[11px] text-ink-400">
{delta === 0 ? "최저가" : `+${delta.toLocaleString("ko-KR")}원`}
<div className="truncate text-[10px] leading-tight text-ink-400">{m.name}</div>
</td>
<td className="py-1.5 text-right align-top">
<div className="tnum whitespace-nowrap text-[12px] font-bold leading-tight">{won(price)}</div>
<div className="tnum mt-0.5 flex items-center justify-end gap-0.5 whitespace-nowrap text-[10px] text-ink-400">
<span>{delta === 0 ? "최저" : `+${pct.toFixed(1)}%`}</span>
{m.detail_url && (
<a href={m.detail_url} target="_blank" rel="noreferrer" className="ml-1.5 font-semibold text-primary-600 hover:underline">열기 ↗</a>
<a href={m.detail_url} target="_blank" rel="noreferrer"
className="font-bold text-primary-600 hover:underline" aria-label="상품 페이지 열기 (새 탭)">↗</a>
)}
</div>
</div>
{/* 가격 비교 막대(전체 폭, 최고가 기준 상대 길이) */}
<div className="col-span-3 h-1.5 overflow-hidden rounded bg-surface-2" aria-hidden>
<div className="h-full rounded" style={{ width: `${(price / maxPrice) * 100}%`, background: src }} />
</div>
</li>
</td>
</tr>
);
})}
</ul>
</ScrollTable>
{topN !== "all" && malls.length > shown.length && (
<p className="mt-2 text-[11px] text-ink-400">몰 {malls.length}개 중 상위 {shown.length}개 표시 — '전체'로 모두 보기</p>
<p className="mt-2 shrink-0 text-[11px] text-ink-400">몰 {malls.length}개 중 상위 {shown.length}개 — '전체'로 모두 보기</p>
)}
</>
)}

View File

@ -2,7 +2,7 @@
import { useState } from "react";
import { settings } from "../api/client";
import { Card } from "../components/ui";
import { Button, Card, PageHeader } from "../components/ui";
export default function Settings() {
const [baseUrl, setBaseUrl] = useState(settings.baseUrl());
@ -11,7 +11,7 @@ export default function Settings() {
return (
<div className="max-w-xl space-y-4">
<h1 className="text-[17px] font-bold tracking-tight">설정</h1>
<PageHeader title="설정" />
<Card title="API 연결">
<form
className="space-y-4"
@ -37,9 +37,7 @@ export default function Settings() {
className="w-full rounded-lg border border-line-200 bg-surface px-3 py-2 text-[13px] outline-none focus:border-primary-600" />
<p className="mt-1 text-[11px] text-ink-400">서버 toml 의 [WebServerConfig].api_keys 중 하나. 이 브라우저에만 저장됩니다.</p>
</div>
<button type="submit" className="rounded-lg bg-primary-600 px-4 py-2 text-[13px] font-semibold text-white hover:bg-primary-700">
저장
</button>
<Button type="submit" size="lg">저장</Button>
{saved && <span className="ml-2 text-[12px] font-medium text-ok-600">저장했습니다</span>}
</form>
</Card>

View File

@ -9,11 +9,38 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { get, post } from "../api/client";
import type { JobDetailRes, JobOutput, SearchRes } from "../api/types";
import { Card, Empty, ErrorNote } from "../components/ui";
import { Button, Card, Empty, ErrorNote, Modal, PageHeader, ScrollTable } from "../components/ui";
import { usd, won } from "../lib/format";
const TOP_COLS = [
{ key: "source", label: "소스" },
{ key: "name", label: "상품명" },
{ key: "price", label: "가격", align: "right" },
{ key: "ship", label: "배송", align: "right" },
{ key: "link", label: "" },
] as const;
const SOURCE_LABEL: Record<string, string> = { naver: "네이버", coupang: "쿠팡", gmarket: "G마켓", auction: "옥션", st11: "11번가" };
const sourceLabel = (s: string) => SOURCE_LABEL[s] ?? s;
// 소스(발견 채널) 색 — 시리즈 색 재사용(엔터티 고정). 상품·가격 몰별 비교와 동일 언어.
const srcColor = (s: string) => s === "naver" ? "var(--color-naver)" : s === "coupang" ? "var(--color-coupang)" : "var(--color-ink-400)";
// 배송 표기 — 쿠팡 로켓 계열(로켓배송·판매자로켓·로켓프레시…) / 무료 / 유료 / 미상.
function shipInfo(fee?: number | null, type?: string | null): { text: string; tone: "rocket" | "free" | "paid" | "none" } {
const t = (type ?? "").toLowerCase();
if (t.startsWith("rocket")) {
const label = t.includes("fresh") ? "로켓프레시" : t.includes("wow") ? "로켓와우"
: t.includes("global") ? "로켓직구" : (t.includes("merchant") || t.includes("seller")) ? "판매자로켓" : "로켓배송";
return { text: label, tone: "rocket" };
}
if (t === "free" || fee === 0) return { text: "무료", tone: "free" };
if (fee != null && fee > 0) return { text: `+${fee.toLocaleString("ko-KR")}원`, tone: "paid" };
return { text: "—", tone: "none" };
}
// 소스·몰명 중복 제거 — 몰명이 없거나 소스 라벨과 같으면 소스만.
const sourceMall = (source: string, mall?: string) => {
const sl = sourceLabel(source);
return mall && mall !== sl ? `${sl} · ${mall}` : sl;
};
// 파이프라인 단계 한글 라벨 — result.stages 의 stage 키 매핑.
const STAGE_LABEL: Record<string, string> = {
@ -102,54 +129,48 @@ export default function TestSearch() {
};
return (
<div className="space-y-4">
<h1 className="text-[17px] font-bold tracking-tight">테스트 검색</h1>
<p className="max-w-2xl text-[12px] leading-relaxed text-ink-500">
임의의 상품을 실제 검색 파이프라인에 흘려보내 셀렉터·프록시·AI 매칭이 정상인지 점검합니다.
워커가 실제로 크롤하므로 <b>프록시 대역폭 비용이 실제로 발생</b>하고, 결과는 비용 통계에 집계됩니다
(상품코드는 <code className="rounded bg-surface-2 px-1">TEST-</code> 접두로 구분).
</p>
<div className="space-y-3">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<PageHeader title="테스트 검색" />
<p className="text-[11px] text-ink-400">
실제 크롤이라 <b className="font-semibold text-warn-700">프록시 비용 발생</b> · 비용 통계 집계 (상품코드 <code className="rounded bg-surface-2 px-1">TEST-</code>)
</p>
</div>
<div className="grid gap-4 lg:grid-cols-[380px_1fr]">
<Card title="검색 조건">
<form
className="space-y-3"
onSubmit={(e) => { e.preventDefault(); if (form.product_name.trim()) submit.mutate(); }}
>
<Field id="pn" label="상품명" required value={form.product_name}
onChange={(v) => setForm((f) => ({ ...f, product_name: v }))} placeholder="예: 맥심 모카골드 커피믹스" />
<div className="grid grid-cols-2 gap-3">
<Field id="model" label="모델명" value={form.model} onChange={(v) => setForm((f) => ({ ...f, model: v }))} placeholder="선택" />
<Field id="company" label="제조사" value={form.company} onChange={(v) => setForm((f) => ({ ...f, company: v }))} placeholder="선택" />
</div>
<Field id="spec" label="규격" value={form.specification}
onChange={(v) => setForm((f) => ({ ...f, specification: v }))} placeholder="선택 — 예: 1박스 160개입" />
<Field id="price" label="현재가(원)" value={form.price} inputMode="numeric"
onChange={(v) => setForm((f) => ({ ...f, price: v.replace(/[^0-9]/g, "") }))} placeholder="선택 — 있으면 가격밴드 필터 기준" />
<div className="flex gap-2 pt-1">
{/* 앰버(warn) — 실제 크롤·프록시 비용이 드는 실행 액션임을 색으로 신호(모니터링 CTA 와 구분). */}
<button type="submit" disabled={!form.product_name.trim() || submit.isPending || (!!jobId && status !== "DONE" && status !== "DEAD")}
className="flex-1 rounded-lg bg-warn-700 px-3 py-2 text-[13px] font-semibold text-white hover:bg-warn-600 disabled:opacity-50">
{submit.isPending ? "제출 중…" : "검색 실행"}
</button>
{jobId && (
<button type="button" onClick={reset}
className="rounded-lg border border-line-200 px-3 py-2 text-[13px] font-semibold text-ink-700 hover:bg-surface-2">
초기화
<div className="grid gap-3 lg:grid-cols-[360px_1fr]">
{/* 왼쪽: 검색 조건 + 진행 상태(함께 sticky — 결과를 스크롤해도 폼·상태가 붙어 있음) */}
<div className="space-y-3 lg:sticky lg:top-0 lg:self-start">
<Card compact title="검색 조건">
<form
className="space-y-3"
onSubmit={(e) => { e.preventDefault(); if (form.product_name.trim() && !jobId) submit.mutate(); }}
>
<Field id="pn" label="상품명" required value={form.product_name}
onChange={(v) => setForm((f) => ({ ...f, product_name: v }))} placeholder="예: 맥심 모카골드 커피믹스" />
<div className="grid grid-cols-2 gap-3">
<Field id="model" label="모델명" value={form.model} onChange={(v) => setForm((f) => ({ ...f, model: v }))} placeholder="선택" />
<Field id="company" label="제조사" value={form.company} onChange={(v) => setForm((f) => ({ ...f, company: v }))} placeholder="선택" />
</div>
<Field id="spec" label="규격" value={form.specification}
onChange={(v) => setForm((f) => ({ ...f, specification: v }))} placeholder="선택 — 예: 1박스 160개입" />
<Field id="price" label="현재가(원)" value={form.price} inputMode="numeric"
onChange={(v) => setForm((f) => ({ ...f, price: v.replace(/[^0-9]/g, "") }))} placeholder="선택 — 있으면 가격밴드 필터 기준" />
<div className="flex gap-2 pt-1">
{/* 앰버(warn) — 실제 크롤·프록시 비용이 드는 실행 액션. 제출 후엔 초기화 전까지 재검색 차단(중복 비용 방지). */}
<button type="submit" disabled={!form.product_name.trim() || submit.isPending || !!jobId}
className="flex-1 rounded-lg bg-warn-700 px-3 py-2 text-[13px] font-semibold text-white hover:bg-warn-600 disabled:opacity-50">
{submit.isPending ? "제출 중…" : jobId ? "실행됨 — 초기화 후 재검색" : "검색 실행"}
</button>
)}
</div>
{submit.isError && <ErrorNote error={submit.error} />}
</form>
</Card>
<div className="space-y-4">
{!jobId && (
<Card title="결과"><Empty>왼쪽에서 상품명을 입력하고 검색을 실행하세요</Empty></Card>
)}
{jobId && (
<Button type="button" variant="outline" size="lg" onClick={reset}>초기화</Button>
)}
</div>
{submit.isError && <ErrorNote error={submit.error} />}
</form>
</Card>
{jobId && (
<Card title="진행 상태" hint={testCode ?? undefined}>
<Card compact title="진행 상태" hint={testCode ?? undefined}>
<Progress status={status} elapsed={elapsed} />
{job.isError && <div className="mt-2"><ErrorNote error={job.error} /></div>}
{pendingTooLong && (
@ -164,8 +185,21 @@ export default function TestSearch() {
)}
</Card>
)}
</div>
{status === "DONE" && output && <ResultView output={output} />}
{/* 오른쪽: 결과 */}
<div className="space-y-3">
{status === "DONE" && output ? (
<ResultView output={output} />
) : (
<Card compact title="결과">
<Empty>
{!jobId ? "왼쪽에서 상품명을 입력하고 검색을 실행하세요"
: status === "DEAD" ? "검색이 실패했습니다 — 왼쪽 진행 상태를 확인하세요"
: "검색 진행 중 — 완료되면 결과가 여기 표시됩니다"}
</Empty>
</Card>
)}
</div>
</div>
</div>
@ -207,9 +241,20 @@ function Progress({ status, elapsed }: { status?: string; elapsed: number }) {
function ResultView({ output }: { output: JobOutput }) {
const found = output.outcome === "found";
const m = output.metrics;
const [showJson, setShowJson] = useState(false);
const [copied, setCopied] = useState(false);
const jsonText = JSON.stringify(output, null, 2);
const copyJson = async () => {
try {
await navigator.clipboard.writeText(jsonText);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch { /* 클립보드 권한 없음 — 무시 */ }
};
return (
<>
<Card title="판정" hint={output.round ? `${output.round} 라운드에서 매칭` : output.cached ? "네거티브 캐시" : undefined}>
<Card compact className={found ? "border-l-[3px] border-l-primary-500" : ""}
title="판정" hint={output.round ? `${output.round} 라운드에서 매칭` : output.cached ? "네거티브 캐시" : undefined}>
{found ? (
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="rounded-full bg-ok-50 px-2 py-0.5 text-[12px] font-semibold text-ok-600">같은 상품 찾음</span>
@ -217,7 +262,7 @@ function ResultView({ output }: { output: JobOutput }) {
<span className="text-[15px] font-bold">
최저가 {won(output.lowest.price)}
<span className="ml-1.5 text-[12px] font-medium text-ink-400">
({sourceLabel(output.lowest.source)}{output.lowest.mall_name ? ` · ${output.lowest.mall_name}` : ""})
({sourceMall(output.lowest.source, output.lowest.mall_name)})
</span>
</span>
)}
@ -231,15 +276,20 @@ function ResultView({ output }: { output: JobOutput }) {
</Card>
{output.stages && output.stages.length > 0 && (
<Card title="파이프라인 퍼널" hint="각 단계가 몇 건을 남겼나">
<Card compact title="파이프라인 퍼널" hint="각 단계가 몇 건을 남겼나">
<FunnelChart stages={output.stages} totalFound={output.total_found} />
</Card>
)}
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-3 md:grid-cols-2">
{output.sources && Object.keys(output.sources).length > 0 && (
<Card title="소스별 수집" hint="검색어 매칭 전 원본 건수">
<ul className="space-y-1.5 text-[12px]">
<Card compact title="소스별 수집" hint="검색어 매칭 전 원본 건수">
{output.query && (
<p className="mb-2 rounded-md bg-surface-2 px-2 py-1 text-[11px] text-ink-500">
각 사이트 검색어 <span className="font-semibold text-ink-700">"{output.query}"</span>
</p>
)}
<ul className="space-y-1 text-[12px]">
{Object.entries(output.sources).map(([s, v]) => (
<li key={s} className="flex items-baseline justify-between gap-2">
<span className="font-semibold">{sourceLabel(s)}</span>
@ -258,8 +308,8 @@ function ResultView({ output }: { output: JobOutput }) {
)}
{m && (
<Card title="검색 원가" hint={m.duration_ms != null ? `${(m.duration_ms / 1000).toFixed(1)}초 소요` : undefined}>
<ul className="space-y-1.5 text-[12px]">
<Card compact title="검색 원가" hint={m.duration_ms != null ? `${(m.duration_ms / 1000).toFixed(1)}초 소요` : undefined}>
<ul className="space-y-1 text-[12px]">
<li className="flex justify-between"><span className="text-ink-500">총 비용</span><span className="tnum font-bold">{usd(m.cost?.total_usd)}</span></li>
<li className="flex justify-between"><span className="text-ink-500">AI 판정·검색어</span><span className="tnum">{usd(m.cost?.ai_usd)}</span></li>
<li className="flex justify-between"><span className="text-ink-500">프록시 대역폭</span><span className="tnum">{usd(m.cost?.proxy_usd)}</span></li>
@ -275,38 +325,52 @@ function ResultView({ output }: { output: JobOutput }) {
</div>
{output.top && output.top.length > 0 && (
<Card title="매칭된 상품" hint={`최저가순 상위 ${output.top.length}건`}>
<div className="overflow-x-auto">
<table className="w-full text-left text-[12px]">
<thead>
<tr className="border-b border-line-200 text-[11px] text-ink-400">
<th className="pb-1.5 pr-3 font-semibold">소스</th>
<th className="pb-1.5 pr-3 font-semibold">상품명</th>
<th className="pb-1.5 pr-3 text-right font-semibold">가격</th>
<th className="pb-1.5 font-semibold">링크</th>
<Card compact title="매칭된 상품" hint={`최저가순 상위 ${output.top.length}건`}>
<ScrollTable height="auto" dense columns={TOP_COLS}>
{output.top.map((p, i) => {
const src = srcColor(p.source);
const ship = shipInfo(p.shipping_fee, p.shipping_type);
return (
<tr key={i} className="border-b border-line-100 hover:bg-surface-2">
<td className="py-1.5 pr-2 align-top">
<span className="inline-block whitespace-nowrap rounded px-1.5 py-px text-[10px] font-bold" style={{ color: src, background: `color-mix(in srgb, ${src} 14%, transparent)` }}>{sourceLabel(p.source)}</span>
</td>
<td className="max-w-md truncate py-1.5 pr-2 align-top">{p.name}</td>
<td className="py-1.5 pr-2 text-right align-top whitespace-nowrap">
<span className={`tnum font-bold ${i === 0 ? "text-ok-600" : ""}`}>{won(p.price)}</span>
{i === 0 && <span className="ml-1 whitespace-nowrap rounded bg-ok-50 px-1 text-[10px] font-bold text-ok-600">최저</span>}
</td>
<td className="py-1.5 pr-2 text-right align-top">
{ship.tone === "rocket" ? (
<span className="inline-flex items-center gap-0.5 whitespace-nowrap rounded px-1.5 py-px text-[10px] font-bold text-run-600"
style={{ background: "color-mix(in srgb, var(--color-run-600) 12%, transparent)" }}>🚀 {ship.text}</span>
) : (
<span className={`tnum whitespace-nowrap text-[11px] ${ship.tone === "free" ? "font-semibold text-ok-600" : "text-ink-400"}`}>{ship.text}</span>
)}
</td>
<td className="py-1.5 text-right align-top">
{p.detail_url && (
<a href={p.detail_url} target="_blank" rel="noreferrer"
className="inline-flex items-center gap-1 whitespace-nowrap rounded-md border border-line-200 px-2 py-0.5 text-[11px] font-semibold text-ink-700 transition-colors hover:bg-line-200 hover:text-ink-900">사이트 →</a>
)}
</td>
</tr>
</thead>
<tbody>
{output.top.map((p, i) => (
<tr key={i} className="border-b border-line-100">
<td className="py-1.5 pr-3 font-semibold">{sourceLabel(p.source)}</td>
<td className="max-w-md truncate py-1.5 pr-3">{p.name}</td>
<td className="tnum py-1.5 pr-3 text-right font-semibold">{won(p.price)}</td>
<td className="py-1.5">
{p.detail_url && <a href={p.detail_url} target="_blank" rel="noreferrer" className="text-primary-600 hover:underline">열기 ↗</a>}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
})}
</ScrollTable>
</Card>
)}
<details>
<summary className="cursor-pointer text-[12px] font-semibold text-ink-400">원본 결과(JSON)</summary>
<pre className="mt-1 max-h-72 overflow-auto rounded-lg bg-surface-2 p-2 text-[11px] leading-relaxed">{JSON.stringify(output, null, 2)}</pre>
</details>
<div>
<Button variant="outline" size="sm" onClick={() => setShowJson(true)}>원본 결과(JSON) 보기</Button>
</div>
{showJson && (
<Modal title="원본 결과 (JSON)" onClose={() => setShowJson(false)}
actions={<Button variant="outline" size="sm" onClick={copyJson}>{copied ? "복사됨 ✓" : "복사"}</Button>}>
<pre className="w-max min-w-full rounded-lg bg-surface-2 p-3 text-[11px] leading-relaxed">{jsonText}</pre>
</Modal>
)}
</>
);
}
@ -319,16 +383,27 @@ function FunnelChart({ stages, totalFound }: { stages: { stage: string; in: numb
];
const max = Math.max(1, ...rows.map((r) => r.value));
return (
<ul className="space-y-1.5">
{rows.map((r, i) => (
<li key={i} className="flex items-center gap-2 text-[12px]">
<span className="w-28 shrink-0 text-ink-500">{r.label}</span>
<div className="h-5 flex-1 overflow-hidden rounded bg-surface-2">
<div className="h-full rounded bg-primary-600" style={{ width: `${(r.value / max) * 100}%`, minWidth: r.value > 0 ? "2px" : 0 }} aria-hidden />
</div>
<span className="tnum w-10 shrink-0 text-right font-semibold">{r.value}</span>
</li>
))}
<ul className="space-y-1">
{rows.map((r, i) => {
const prev = i > 0 ? rows[i - 1].value : null;
const drop = prev != null ? r.value - prev : null; // 이전 단계 대비 증감(보통 감소)
const dropPct = prev && prev > 0 && drop != null && drop < 0 ? Math.round((drop / prev) * 100) : null;
return (
// 막대는 고정 폭(14rem)으로 짧게, 값·감소량은 우측 넓은 영역에 한 줄로.
<li key={i} className="grid grid-cols-[6.5rem_14rem_2.25rem_1fr] items-center gap-x-2 text-[12px]">
<span className="truncate text-ink-500">{r.label}</span>
<div className="h-4 overflow-hidden rounded bg-surface-2">
<div className="h-full rounded bg-gradient-to-r from-primary-400 to-primary-600"
style={{ width: `${(r.value / max) * 100}%`, minWidth: r.value > 0 ? "3px" : 0 }} aria-hidden />
</div>
<span className="tnum text-right font-semibold">{r.value}</span>
{/* 이전 단계 대비 감소량 — 어디서 확 걸러졌는지 한눈에 */}
<span className={`tnum whitespace-nowrap text-right text-[11px] ${drop != null && drop < 0 ? "font-semibold text-dead-600" : "text-ink-400"}`}>
{drop == null ? "" : drop < 0 ? `−${-drop}${dropPct != null ? ` (${dropPct}%)` : ""}` : "—"}
</span>
</li>
);
})}
</ul>
);
}