o2o-negosium-original/negodata/front/src/features/learning/LearningView.tsx
Mina Choi 6eb4dc26f8 [feat] negosium·negodata: 협상 거부 흐름 정리 + 오프라인 계약가 낙찰 + 견적 상세 리모델링
협상 불가 사유를 내면 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 통과. 프론트 빌드/린트 통과.
2026-08-12 10:25:26 +09:00

310 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)}%`;
}