import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { Link } from 'react-router'; import { Bot, CheckCircle2, HelpCircle, Layers, Ruler } from 'lucide-react'; import { Panel } from '@/features/statistics/components/Panel'; import { StatTile } from '@/features/statistics/components/StatTile'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Typography } from '@/components/ui/typography'; import { fmtDateTime } from '@/features/quotations/types'; import type { AnchoringData, LearningData } from './types'; // 협상 학습 현황. 협상카드(강화학습)와 앵커링(자동 조정)은 서로 다른 축이라 탭으로 나눠 각각 한 화면에 담는다. // 두 값 모두 agent·anchoring 서비스가 쌓은 결과를 읽기만 한다. export function CardLearningView({ data }: { data: LearningData }) { return (
최근 사용 {fmtDateTime(data.lastLearnedAt)} ) : undefined } > {data.cards.length === 0 ? ( ) : ( <> {data.top3Share >= 0.8 && data.usedCards > 3 && ( 상위 3장이 전체 사용의 {pct(data.top3Share)}를 차지합니다 — 나머지 카드는 조건이 맞지 않아 밀리고 있을 수 있습니다. )} 카드번호 카드이름 종류 사용 협상 사용 횟수 마지막 사용 {data.cards.map((c) => ( {c.number} {c.cardId ? ( {c.name} ) : ( c.name )} {c.usedSessions} {c.uses} {pct(c.share)} {c.avgTurn ? `${c.avgTurn}R` : '-'} {num4(c.avgQ)} {num4(c.exploreBonus)} {c.visits || '-'} {c.lastUsedAt ? fmtDateTime(c.lastUsedAt) : '-'} ))}
)}
); } export function AnchoringStatusView({ data }: { data: AnchoringData }) { const adjusted = data.cells.filter((c) => c.adjustedAt).length; const avg = data.cells.length ? data.cells.reduce((sum, c) => sum + c.value, 0) / data.cells.length : 0; return (
{data.cells.length === 0 ? ( ) : ( 협력사 유형 가격대 인하폭 마지막 조정 {data.cells.map((c) => ( {c.supplierType} {c.priceRange} {rate(c.value)} {c.adjustedAt ? fmtDateTime(c.adjustedAt) : '조정 전'} ))}
)}
{data.history.length === 0 ? ( ) : ( 협력사 유형 가격대 변화 표본 성공률 시각 {data.history.map((h, i) => ( {h.supplierType} {h.priceRange} {rate(h.before)} → {rate(h.after)} {h.samples} {pct(h.successRate)} {h.at ? fmtDateTime(h.at) : '-'} ))}
)}
); } // 표 헤더의 ? — 지표 정의를 눌러서 확인한다. 부제에 정의를 길게 늘어놓지 않기 위한 것이고, // 여러 줄 설명이라 마우스를 떼면 사라지는 hover 툴팁 대신 클릭으로 여닫는다. function HeadWithHelp({ label, help }: { label: string; help: string }) { // pinned = 클릭으로 고정(다른 곳 누를 때까지 유지), hovered = 마우스 올린 동안만 노출. const [pinned, setPinned] = useState(false); const [hovered, setHovered] = useState(false); const open = pinned || hovered; const [pos, setPos] = useState({ top: 0, right: 0 }); const ref = useRef(null); // 표 컨테이너가 overflow-x-auto 라 말풍선을 그 안에 두면 잘리거나 가로 스크롤이 생긴다. // body 로 띄우고 트리거 좌표에 맞춰 붙인다. useLayoutEffect(() => { if (!open || !ref.current) return; const r = ref.current.getBoundingClientRect(); setPos({ top: r.bottom + 6, right: window.innerWidth - r.right }); }, [open]); useEffect(() => { if (!pinned) return; const close = (e: Event) => { if (!ref.current?.contains(e.target as Node)) setPinned(false); }; const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setPinned(false); document.addEventListener('pointerdown', close); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('pointerdown', close); document.removeEventListener('keydown', onKey); }; }, [pinned]); return ( setHovered(true)} onMouseLeave={() => setHovered(false)} > {label} {open && createPortal(
{help}
, document.body, )}
); } function TypeBadge({ isWild }: { isWild: boolean }) { return ( {isWild ? '와일드' : '협상'} ); } function EmptyRow({ text }: { text: string }) { return ( {text} ); } function pct(v: number): string { return `${(v * 100).toFixed(0)}%`; } // Q값·탐색 보너스는 0에 가까운 소수라 반올림하면 차이가 사라진다 — 네 자리까지 그대로 보여준다. function num4(v: number | null): string { return v == null ? '-' : v.toFixed(4); } // 앵커링 값은 천분율(‰)로 저장된다 — 10 = 1%. 화면은 담당자가 쓰는 단위(%)로 보여준다. function rate(v: number): string { return `${(v / 10).toFixed(1)}%`; }