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}
setHovered(true)}
onBlur={() => setHovered(false)}
// pointerdown 으로 받는다 — click 은 표 행 리렌더 사이에 끼면 눌러도 안 열리는 경우가 있다.
// 전파를 끊지 않으면 방금 등록된 바깥클릭 핸들러가 같은 이벤트로 곧바로 닫는다.
onPointerDown={(e) => {
e.stopPropagation();
setPinned((v) => !v);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
setPinned((v) => !v);
}
}}
className="text-muted-foreground hover:text-foreground cursor-pointer"
>
{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)}%`;
}