- 공용 프리미티브(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>
427 lines
21 KiB
TypeScript
427 lines
21 KiB
TypeScript
/** 테스트 검색 — 임의 상품을 실제 파이프라인(큐→워커→네이버·쿠팡→AI 매칭)에 흘려보내고
|
||
* 단계별 퍼널·소스별 결과·매칭 상품·검색 원가를 시각화한다. 셀렉터·프록시·매칭 회귀 점검용.
|
||
*
|
||
* product_code 는 TEST-<시각> 으로 자동 생성 — 프로덕션 데이터(잡·이력·비용통계)와 필터로
|
||
* 구분되고, negodata 는 UUID 만 매핑하므로 연동에 영향 없다. 자격증명은 서버에만 있으며
|
||
* 여기 결과엔 집계된 프록시 사용량(바이트·$)만 표시된다. */
|
||
|
||
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 { 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> = {
|
||
filter: "가격/몰 필터",
|
||
outlier: "이상치 제거",
|
||
ai_match: "AI 같은상품 판정",
|
||
top_n: "최저가 top-N",
|
||
};
|
||
|
||
function makeTestCode() {
|
||
// Date.now 로 사람이 읽을 수 있는 시각 코드(중복 방지 + 목록 필터 구분).
|
||
const d = new Date();
|
||
const p = (n: number) => String(n).padStart(2, "0");
|
||
return `TEST-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
||
}
|
||
|
||
interface FormState {
|
||
product_name: string;
|
||
model: string;
|
||
specification: string;
|
||
company: string;
|
||
price: string;
|
||
}
|
||
|
||
export default function TestSearch() {
|
||
const [form, setForm] = useState<FormState>({ product_name: "", model: "", specification: "", company: "", price: "" });
|
||
const [jobId, setJobId] = useState<string | null>(null);
|
||
const [testCode, setTestCode] = useState<string | null>(null);
|
||
const startedAt = useRef<number>(0);
|
||
|
||
const submit = useMutation({
|
||
mutationFn: async () => {
|
||
const code = makeTestCode();
|
||
const res = await post<SearchRes>("/v1/lps/search", {
|
||
data: [{
|
||
product_code: code,
|
||
product_name: form.product_name.trim(),
|
||
job_type: "manual",
|
||
model: form.model.trim(),
|
||
specification: form.specification.trim(),
|
||
company: form.company.trim(),
|
||
price: form.price.trim(),
|
||
}],
|
||
});
|
||
return { res, code };
|
||
},
|
||
onSuccess: ({ res, code }) => {
|
||
const entry = res.items[0];
|
||
setTestCode(code);
|
||
startedAt.current = Date.now();
|
||
setJobId(entry?.job_id ?? null);
|
||
},
|
||
});
|
||
|
||
// 잡 완료까지 2초 폴링. DONE/DEAD 면 멈춘다.
|
||
const job = useQuery({
|
||
queryKey: ["test-job", jobId],
|
||
queryFn: () => get<JobDetailRes>(`/v1/lps/jobs/${jobId}`),
|
||
enabled: !!jobId,
|
||
refetchInterval: (q) => {
|
||
const st = q.state.data?.status;
|
||
return st === "DONE" || st === "DEAD" ? false : 2000;
|
||
},
|
||
});
|
||
|
||
const status = job.data?.status;
|
||
const output = job.data?.output;
|
||
const done = status === "DONE" || status === "DEAD";
|
||
|
||
// 진행 중엔 1초마다 경과 시간을 갱신(폴링 간격과 무관하게 매끄럽게), 완료되면 정지.
|
||
const [elapsed, setElapsed] = useState(0);
|
||
useEffect(() => {
|
||
if (!jobId || done) return;
|
||
const tick = () => setElapsed(Math.floor((Date.now() - startedAt.current) / 1000));
|
||
tick();
|
||
const t = setInterval(tick, 1000);
|
||
return () => clearInterval(t);
|
||
}, [jobId, done]);
|
||
|
||
const pendingTooLong = status === "PENDING" && elapsed > 8;
|
||
|
||
const reset = () => {
|
||
setJobId(null);
|
||
setTestCode(null);
|
||
submit.reset();
|
||
};
|
||
|
||
return (
|
||
<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-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>
|
||
{jobId && (
|
||
<Button type="button" variant="outline" size="lg" onClick={reset}>초기화</Button>
|
||
)}
|
||
</div>
|
||
{submit.isError && <ErrorNote error={submit.error} />}
|
||
</form>
|
||
</Card>
|
||
|
||
{jobId && (
|
||
<Card compact title="진행 상태" hint={testCode ?? undefined}>
|
||
<Progress status={status} elapsed={elapsed} />
|
||
{job.isError && <div className="mt-2"><ErrorNote error={job.error} /></div>}
|
||
{pendingTooLong && (
|
||
<p role="alert" className="mt-3 rounded-lg bg-warn-50 px-3 py-2 text-[12px] font-medium text-warn-700">
|
||
{elapsed}초째 대기 중 — 워커가 실행 중인지 확인하세요 (<code>./run_local_worker.sh</code>). 워커가 없으면 크롤이 시작되지 않습니다.
|
||
</p>
|
||
)}
|
||
{status === "DEAD" && (
|
||
<p role="alert" className="mt-3 rounded-lg bg-dead-50 px-3 py-2 text-[12px] font-medium text-dead-600">
|
||
검색 실패(재시도 소진) — {job.data?.last_error || "차단·오류로 결과를 얻지 못했습니다"}
|
||
</p>
|
||
)}
|
||
</Card>
|
||
)}
|
||
</div>
|
||
|
||
{/* 오른쪽: 결과 */}
|
||
<div className="space-y-3">
|
||
{status === "DONE" && output ? (
|
||
<ResultView output={output} />
|
||
) : (
|
||
<Card compact title="결과">
|
||
<Empty>
|
||
{!jobId ? "왼쪽에서 상품명을 입력하고 검색을 실행하세요"
|
||
: status === "DEAD" ? "검색이 실패했습니다 — 왼쪽 진행 상태를 확인하세요"
|
||
: "검색 진행 중 — 완료되면 결과가 여기 표시됩니다"}
|
||
</Empty>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 진행 스텝퍼 ──────────────────────────────────────────────
|
||
function Progress({ status, elapsed }: { status?: string; elapsed: number }) {
|
||
const steps = [
|
||
{ key: "PENDING", label: "대기" },
|
||
{ key: "RUNNING", label: "크롤·판정 중" },
|
||
{ key: "DONE", label: "완료" },
|
||
];
|
||
const order = ["PENDING", "RUNNING", "DONE"];
|
||
const curIdx = status === "DEAD" ? 1 : Math.max(0, order.indexOf(status ?? "PENDING"));
|
||
return (
|
||
<div className="flex items-center gap-2">
|
||
{steps.map((s, i) => {
|
||
const done = i < curIdx || status === "DONE";
|
||
const active = i === curIdx && status !== "DONE";
|
||
return (
|
||
<div key={s.key} className="flex items-center gap-2">
|
||
<span className={`flex h-6 items-center rounded-full px-2.5 text-[12px] font-semibold ${
|
||
done ? "bg-ok-50 text-ok-600" : active ? "bg-run-50 text-run-600" : "bg-surface-2 text-ink-400"
|
||
}`}>
|
||
{active && <span className="mr-1.5 h-1.5 w-1.5 animate-pulse rounded-full bg-run-600" aria-hidden />}
|
||
{s.label}
|
||
</span>
|
||
{i < steps.length - 1 && <span className="h-px w-4 bg-line-200" aria-hidden />}
|
||
</div>
|
||
);
|
||
})}
|
||
<span className="ml-auto tnum text-[12px] text-ink-400">{elapsed}초</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 결과 뷰: 판정 요약 + 퍼널 + 소스별 + 매칭 상품 + 비용 ──────
|
||
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 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>
|
||
{output.lowest && (
|
||
<span className="text-[15px] font-bold">
|
||
최저가 {won(output.lowest.price)}
|
||
<span className="ml-1.5 text-[12px] font-medium text-ink-400">
|
||
({sourceMall(output.lowest.source, output.lowest.mall_name)})
|
||
</span>
|
||
</span>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<span className="rounded-full bg-surface-2 px-2 py-0.5 text-[12px] font-semibold text-ink-500">
|
||
같은 상품 없음 {output.rounds_tried ? `· ${output.rounds_tried}개 검색어로 시도` : ""}
|
||
</span>
|
||
)}
|
||
{output.query && <p className="mt-2 text-[12px] text-ink-500">최종 검색어: <b>{output.query}</b></p>}
|
||
</Card>
|
||
|
||
{output.stages && output.stages.length > 0 && (
|
||
<Card compact title="파이프라인 퍼널" hint="각 단계가 몇 건을 남겼나">
|
||
<FunnelChart stages={output.stages} totalFound={output.total_found} />
|
||
</Card>
|
||
)}
|
||
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
{output.sources && Object.keys(output.sources).length > 0 && (
|
||
<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>
|
||
<span className="tnum text-right">
|
||
{v.error ? (
|
||
<span className="font-semibold text-dead-600">실패 · {v.error.split(":")[0]}</span>
|
||
) : (
|
||
<>{v.count ?? 0}건</>
|
||
)}
|
||
{m?.source_ms?.[s] != null && <span className="ml-1 text-ink-400">· {(m.source_ms[s] / 1000).toFixed(1)}초</span>}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</Card>
|
||
)}
|
||
|
||
{m && (
|
||
<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>
|
||
{m.crawl && (
|
||
<li className="flex justify-between border-t border-line-100 pt-1.5">
|
||
<span className="text-ink-500">프록시 전송량</span>
|
||
<span className="tnum text-ink-400">{(m.crawl.proxy_bytes / 1024).toFixed(0)} KB · fetch {m.crawl.fetches}회</span>
|
||
</li>
|
||
)}
|
||
</ul>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
|
||
{output.top && output.top.length > 0 && (
|
||
<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>
|
||
);
|
||
})}
|
||
</ScrollTable>
|
||
</Card>
|
||
)}
|
||
|
||
<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>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ── 퍼널: 단계별 남은 건수 가로 막대(수집→최종 매칭) ──────────
|
||
function FunnelChart({ stages, totalFound }: { stages: { stage: string; in: number; out: number }[]; totalFound?: number }) {
|
||
const rows = [
|
||
...(totalFound != null ? [{ label: "수집", value: totalFound }] : []),
|
||
...stages.map((s) => ({ label: STAGE_LABEL[s.stage] ?? s.stage, value: s.out })),
|
||
];
|
||
const max = Math.max(1, ...rows.map((r) => r.value));
|
||
return (
|
||
<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>
|
||
);
|
||
}
|
||
|
||
// ── 폼 필드(라벨-인풋 연결, Settings 패턴 준수) ──────────────
|
||
function Field({ id, label, value, onChange, placeholder, required, inputMode }: {
|
||
id: string; label: string; value: string; onChange: (v: string) => void;
|
||
placeholder?: string; required?: boolean; inputMode?: "numeric";
|
||
}) {
|
||
return (
|
||
<div>
|
||
<label htmlFor={id} className="mb-1 block text-[12px] font-semibold text-ink-700">
|
||
{label}{required && <span className="ml-0.5 text-dead-600">*</span>}
|
||
</label>
|
||
<input id={id} value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder}
|
||
inputMode={inputMode} autoComplete="off"
|
||
className="w-full rounded-lg border border-line-200 bg-surface px-3 py-2 text-[13px] outline-none focus:border-primary-600" />
|
||
</div>
|
||
);
|
||
}
|