협상 불가 사유를 내면 500 이 나고, 거부 폼은 목록·채팅이 따로 놀았으며,
제출한 내용을 다시 볼 방법이 없었다. 결렬 건은 협력사가 낸 거부가를 계약가로
간주해 낙찰시켜 절감 통계가 음수로 뒤집힐 수 있었고, 견적 상세는 판정 가격이
세 곳에 흩어져 대화 탭에선 아예 보이지 않았다.
agent
- 결렬 종료 로깅 크래시 수정 — _log 를 action_id 기반으로 되돌리고 선택 근거
(Q·UCB·방문수)는 decision/policy 가 있을 때만 채운다. 종료 행은 카드 선택이
없고 policy.update 뒤라 값을 넣으면 학습 화면 집계가 오염된다
negosium
- 거부 폼을 목록·채팅 공용 컴포넌트 하나로 통일(사유 3종 + 공급 희망가·의견 선택)
- 거부 사유 열람 — 목록에 '거부 사유 보기'(부가정보 보기와 같은 규격), 채팅
재진입 시 대화 끝에 거부 내역 카드. 목록·채팅 init 응답에 reject_reason·reject_price 추가
- 자유 입력 거부("협상 포기합니다")가 사유 NULL 로 저장되던 문제 수정 — 폼 마커가
없으면 원문을 사유로 쓰고, 문장 속 숫자를 희망가로 오인하지 않는다
- koreanNumber 를 전역 lib 으로 이동(공용 폼이 쓴다)
negodata
- 직접 낙찰에 계약가 입력 — 결렬·미응찰 건을 오프라인으로 다시 협상한 결과를
담당자가 확정해 넣는다. 후보는 초청 협력사 전부(가격 미제출도 포함),
계약가는 sessions.custom.offline_award 에 근거·작성자·시각과 함께 남긴다
- 통계 계약가 = 담당자 확정가 우선, 없으면 투찰가. 거부가를 계약가로 치던 파생 제거.
KPI 에 오프라인 반영 건수 추가
- 견적 상세 리모델링 — 가격 레일(앵커링가/투찰현황 · 목표가 · 타결 상한가 · 결과가)을
시트에 고정해 접힘·탭 전환에도 남기고, 스펙트럼에 타결 판정선과 구간색 추가.
라벨은 폭을 실측해 두 레인으로 배치(겹침 불가). 상품·마감시각 등 전 행 동일 컬럼 제거,
협상현황에 부가정보 노출, 1:1 은 협력사·세션상태를 결과 밴드로 올림
테스트: negosium 58 · negodata 110 통과. 프론트 빌드/린트 통과.
310 lines
15 KiB
TypeScript
310 lines
15 KiB
TypeScript
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 (
|
||
<div className="space-y-4">
|
||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||
<StatTile label="학습한 협상" value={`${data.learnedSessions}건`} icon={Bot} tone="purple" />
|
||
<StatTile label="카드 사용" value={`${data.records}회`} icon={Layers} tone="blue" />
|
||
<StatTile label="써본 카드" value={`${data.usedCards}종`} icon={CheckCircle2} tone="emerald" />
|
||
<StatTile label="아직 안 쓴 카드" value={`${data.unusedCards}종`} icon={Ruler} tone="amber" />
|
||
</div>
|
||
|
||
<Panel
|
||
title="카드 사용 현황"
|
||
subtitle="담아둔 카드가 실제로 나가는지, 어느 국면에 나가는지 봅니다. 협상 결과는 여러 장이 함께 만든 것이라 카드별 성과로 나누지 않습니다."
|
||
right={
|
||
data.lastLearnedAt ? (
|
||
<Typography as="span" variant="small" className="text-[11px] text-muted-foreground">
|
||
최근 사용 {fmtDateTime(data.lastLearnedAt)}
|
||
</Typography>
|
||
) : undefined
|
||
}
|
||
>
|
||
{data.cards.length === 0 ? (
|
||
<EmptyRow text="아직 사용된 카드가 없습니다. 협상이 진행되면 여기에 쌓입니다." />
|
||
) : (
|
||
<>
|
||
{data.top3Share >= 0.8 && data.usedCards > 3 && (
|
||
<Typography as="div" variant="small" className="mb-2 text-[11px] text-amber-700 dark:text-amber-400">
|
||
상위 3장이 전체 사용의 {pct(data.top3Share)}를 차지합니다 — 나머지 카드는 조건이 맞지 않아
|
||
밀리고 있을 수 있습니다.
|
||
</Typography>
|
||
)}
|
||
<Table className="text-xs">
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead className="w-24">카드번호</TableHead>
|
||
<TableHead>카드이름</TableHead>
|
||
<TableHead className="w-20 text-center">종류</TableHead>
|
||
<TableHead className="w-24 text-right">사용 협상</TableHead>
|
||
<TableHead className="w-24 text-right">사용 횟수</TableHead>
|
||
<TableHead className="w-28 text-right">
|
||
<HeadWithHelp label="사용 비중" help="전체 카드 사용 횟수 중 이 카드가 차지하는 비율입니다. 한두 장에 몰려 있으면 나머지 카드가 발동 조건에 걸려 밀리고 있을 수 있습니다." />
|
||
</TableHead>
|
||
<TableHead className="w-32 text-right">
|
||
<HeadWithHelp label="평균 라운드" help="이 카드가 협상의 몇 번째 라운드에 나갔는지의 평균입니다. 값이 작으면 초반에, 크면 종결 무렵에 나간다는 뜻입니다. 종결 전용으로 설정한 카드가 초반에 나오고 있지는 않은지 확인할 때 봅니다." />
|
||
</TableHead>
|
||
<TableHead className="w-28 text-right">
|
||
<HeadWithHelp label="Q값" help="강화학습이 이 카드에 매긴 값어치입니다(선택 시점 평균). 높을수록 이 국면에서 성과가 좋았다고 학습된 카드입니다. 봇은 Q값에 탐색 보너스를 더한 점수가 가장 높은 카드를 고릅니다." />
|
||
</TableHead>
|
||
<TableHead className="w-28 text-right">
|
||
<HeadWithHelp label="탐색 보너스" help="아직 덜 써봐서 붙는 가산점입니다(UCB − Q). 이 값이 Q값보다 크면 성과가 좋아서가 아니라 '아직 검증이 덜 돼서' 뽑힌 것입니다. 방문수가 쌓이면 0에 수렴합니다." />
|
||
</TableHead>
|
||
<TableHead className="w-24 text-right">
|
||
<HeadWithHelp label="방문수" help="이 카드가 학습에서 선택된 누적 횟수입니다. 적으면 Q값이 아직 추측에 가깝고, 많을수록 검증된 값입니다." />
|
||
</TableHead>
|
||
<TableHead className="w-36 text-right">마지막 사용</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{data.cards.map((c) => (
|
||
<TableRow key={c.number}>
|
||
<TableCell className="font-mono">{c.number}</TableCell>
|
||
<TableCell className="font-medium">
|
||
{c.cardId ? (
|
||
<Link
|
||
to={`/cards?detail=${c.cardId}`}
|
||
className="underline decoration-1 underline-offset-2 hover:text-primary"
|
||
title={`${c.name} — 협상카드 상세로 이동`}
|
||
>
|
||
{c.name}
|
||
</Link>
|
||
) : (
|
||
c.name
|
||
)}
|
||
</TableCell>
|
||
<TableCell className="text-center">
|
||
<TypeBadge isWild={c.isWild} />
|
||
</TableCell>
|
||
<TableCell className="text-right">{c.usedSessions}</TableCell>
|
||
<TableCell className="text-right">{c.uses}</TableCell>
|
||
<TableCell className="text-right">{pct(c.share)}</TableCell>
|
||
<TableCell className="text-right">{c.avgTurn ? `${c.avgTurn}R` : '-'}</TableCell>
|
||
<TableCell className="text-right font-mono">{num4(c.avgQ)}</TableCell>
|
||
<TableCell className="text-right font-mono text-muted-foreground">{num4(c.exploreBonus)}</TableCell>
|
||
<TableCell className="text-right">{c.visits || '-'}</TableCell>
|
||
<TableCell className="text-right text-muted-foreground">
|
||
{c.lastUsedAt ? fmtDateTime(c.lastUsedAt) : '-'}
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</>
|
||
)}
|
||
</Panel>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="space-y-4">
|
||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||
<StatTile label="관리 중인 구간" value={`${data.cells.length}개`} icon={Layers} tone="purple" />
|
||
<StatTile label="조정된 구간" value={`${adjusted}개`} icon={CheckCircle2} tone="emerald" />
|
||
<StatTile label="평균 인하폭 (목표가 대비)" value={rate(avg)} icon={Ruler} tone="blue" />
|
||
<StatTile label="조정 이력" value={`${data.history.length}건`} icon={Bot} tone="amber" />
|
||
</div>
|
||
|
||
<div className="grid gap-4 xl:grid-cols-2">
|
||
<Panel title="현재 앵커링 값"
|
||
subtitle="협력사 유형과 가격대가 이 조합이면 이 인하폭을 적용합니다. 앵커가 = 목표가 − (목표가 × 인하폭).">
|
||
{data.cells.length === 0 ? (
|
||
<EmptyRow text="아직 설정된 구간이 없습니다. 협상이 쌓이면 구간별로 값이 만들어집니다." />
|
||
) : (
|
||
<Table className="text-xs">
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead className="w-24">협력사 유형</TableHead>
|
||
<TableHead>가격대</TableHead>
|
||
<TableHead className="w-24 text-right">인하폭</TableHead>
|
||
<TableHead className="text-right">마지막 조정</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{data.cells.map((c) => (
|
||
<TableRow key={`${c.supplierType}-${c.priceRange}`}>
|
||
<TableCell>{c.supplierType}</TableCell>
|
||
<TableCell className="font-mono">{c.priceRange}</TableCell>
|
||
<TableCell className="text-right font-mono">{rate(c.value)}</TableCell>
|
||
<TableCell className="text-right text-muted-foreground">
|
||
{c.adjustedAt ? fmtDateTime(c.adjustedAt) : '조정 전'}
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
)}
|
||
</Panel>
|
||
|
||
<Panel title="조정 이력"
|
||
subtitle="구간마다 협상 10건이 모이면 평가합니다. 앵커가 이하로 합의된 비율이 높으면 인하폭을 올리고, 낮으면 내립니다.">
|
||
{data.history.length === 0 ? (
|
||
<EmptyRow text="아직 조정된 이력이 없습니다." />
|
||
) : (
|
||
<Table className="text-xs">
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead className="w-24">협력사 유형</TableHead>
|
||
<TableHead>가격대</TableHead>
|
||
<TableHead className="w-28 text-right">변화</TableHead>
|
||
<TableHead className="w-20 text-right">표본</TableHead>
|
||
<TableHead className="w-20 text-right">성공률</TableHead>
|
||
<TableHead className="text-right">시각</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{data.history.map((h, i) => (
|
||
<TableRow key={`${h.supplierType}-${h.priceRange}-${h.at ?? i}`}>
|
||
<TableCell>{h.supplierType}</TableCell>
|
||
<TableCell className="font-mono">{h.priceRange}</TableCell>
|
||
<TableCell className="text-right font-mono">
|
||
{rate(h.before)} → <span className="font-semibold">{rate(h.after)}</span>
|
||
</TableCell>
|
||
<TableCell className="text-right">{h.samples}</TableCell>
|
||
<TableCell className="text-right">{pct(h.successRate)}</TableCell>
|
||
<TableCell className="text-right text-muted-foreground">
|
||
{h.at ? fmtDateTime(h.at) : '-'}
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
)}
|
||
</Panel>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 표 헤더의 ? — 지표 정의를 눌러서 확인한다. 부제에 정의를 길게 늘어놓지 않기 위한 것이고,
|
||
// 여러 줄 설명이라 마우스를 떼면 사라지는 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<HTMLSpanElement>(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 (
|
||
<span
|
||
ref={ref}
|
||
className="inline-flex items-center justify-end gap-1"
|
||
onMouseEnter={() => setHovered(true)}
|
||
onMouseLeave={() => setHovered(false)}
|
||
>
|
||
{label}
|
||
<button
|
||
type="button"
|
||
aria-label={`${label} 설명`}
|
||
aria-expanded={open}
|
||
onFocus={() => 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"
|
||
>
|
||
<HelpCircle size={12} />
|
||
</button>
|
||
{open &&
|
||
createPortal(
|
||
<div
|
||
style={{ top: pos.top, right: pos.right }}
|
||
className="fixed z-50 w-72 whitespace-normal break-keep rounded-lg border border-border bg-popover px-3 py-2 text-left text-xs font-normal leading-relaxed text-popover-foreground normal-case shadow-lg"
|
||
>
|
||
{help}
|
||
</div>,
|
||
document.body,
|
||
)}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function TypeBadge({ isWild }: { isWild: boolean }) {
|
||
return (
|
||
<span
|
||
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-semibold ${
|
||
isWild
|
||
? 'border-amber-300/45 bg-amber-50 text-amber-700 dark:bg-amber-950/20 dark:text-amber-400'
|
||
: 'border-primary/25 bg-primary/5 text-primary'
|
||
}`}
|
||
>
|
||
{isWild ? '와일드' : '협상'}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function EmptyRow({ text }: { text: string }) {
|
||
return (
|
||
<Typography as="div" variant="small" className="py-10 text-center text-xs text-muted-foreground">
|
||
{text}
|
||
</Typography>
|
||
);
|
||
}
|
||
|
||
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)}%`;
|
||
}
|