[fix] negodata: 협상 학습 화면 정리 — 내부 점수 제거·카드탭 사용 현황 전환·메뉴 개발자 전용

- 관측 사실 기준으로 재구성: 내부 점수 제거, 앵커링 % 표기, 탭 URL 분리
- 카드탭을 성과 지표에서 사용 현황으로 전환, 지표 설명 말풍선·카드 상세 링크
- 사이드바 devOnly + 라우트 가드로 개발자 전용 처리
This commit is contained in:
Mina Choi 2026-08-11 08:49:15 +09:00
parent e1e519c20c
commit f1e924931f
19 changed files with 310 additions and 150 deletions

View File

@ -14,6 +14,7 @@ _EXPERIENCE_LOGS = table(
"experience_logs", "experience_logs",
column("company_id"), column("session_id"), column("card_id"), column("reward"), column("company_id"), column("session_id"), column("card_id"), column("reward"),
column("settled_price"), column("is_invalidated"), column("created_at"), column("settled_price"), column("is_invalidated"), column("created_at"),
column("turn"),
schema="learning", schema="learning",
) )
_ANCHORING_CURRENT = table( _ANCHORING_CURRENT = table(
@ -45,7 +46,7 @@ class ILearningCRUD(ABC):
pass pass
@abstractmethod @abstractmethod
async def card_performance(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, list]: async def card_usage(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, list]:
pass pass
@abstractmethod @abstractmethod
@ -76,41 +77,43 @@ class LearningCRUD(ILearningCRUD):
return err_type, (0, 0, 0, None) return err_type, (0, 0, 0, None)
return ErrorType.SUCCESS, tuple(rows[0]) return ErrorType.SUCCESS, tuple(rows[0])
async def card_performance(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, list]: async def card_usage(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, list]:
"""카드별 (카드번호, 사용 협상 수, 사용 횟수, 평균 보상, 타결 협상 수). """카드별 사용 현황 — (카드번호, 사용 협상 수, 사용 횟수, 평균 라운드, 마지막 사용).
보상(reward) agent 협상 결과로 매긴 성적이라 카드의 실제 효과를 비교하는 축이 된다. 협상 결과(타결·가격) 카드 장의 성과로 나눌 없어 다루지 않는다 협상에 여러 장이
나가 어느 장의 몫인지 가릴 근거가 없고, 카드 배정도 국면에 따라 정해져 무작위가 아니다.
평균 라운드 = 카드가 협상의 번째 라운드에 나갔는지(설정한 국면과 실제가 맞는지 대조).
""" """
query = ( query = (
select( select(
_EXPERIENCE_LOGS.c.card_id, _EXPERIENCE_LOGS.c.card_id,
func.count(func.distinct(_EXPERIENCE_LOGS.c.session_id)), func.count(func.distinct(_EXPERIENCE_LOGS.c.session_id)),
func.count(), func.count(),
func.avg(_EXPERIENCE_LOGS.c.reward), func.avg(_EXPERIENCE_LOGS.c.turn),
func.count(func.distinct(_EXPERIENCE_LOGS.c.session_id)) func.max(_EXPERIENCE_LOGS.c.created_at),
.filter(_EXPERIENCE_LOGS.c.settled_price.isnot(None)),
) )
.where(and_(*_valid(company_id), _EXPERIENCE_LOGS.c.card_id.isnot(None))) .where(and_(*_valid(company_id), _EXPERIENCE_LOGS.c.card_id.isnot(None)))
.group_by(_EXPERIENCE_LOGS.c.card_id) .group_by(_EXPERIENCE_LOGS.c.card_id)
.order_by(desc(func.count())) .order_by(desc(func.count()))
) )
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "card_performance failed.", raise_error=False) err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "card_usage failed.", raise_error=False)
if err_type != ErrorType.SUCCESS: if err_type != ErrorType.SUCCESS:
return err_type, [] return err_type, []
return ErrorType.SUCCESS, list(rows or []) return ErrorType.SUCCESS, list(rows or [])
async def card_names(self, cdb: AsyncSession) -> Tuple[ErrorType, list]: async def card_names(self, cdb: AsyncSession) -> Tuple[ErrorType, list]:
"""카드번호 → (번호, 이름, 와일드 여부). 학습 로그의 card_id 가 카드번호 문자열이다. """카드번호 → (번호, 이름, 와일드 여부, 카드 PK). 학습 로그의 card_id 가 카드번호 문자열이라
상세 화면(/cards?detail=<PK>) 으로 보내려면 PK 같이 들고 와야 한다.
UNION 실행기가 SELECT 인정하지 않아 나눠 조회한다. UNION 실행기가 SELECT 인정하지 않아 나눠 조회한다.
""" """
out = [] out = []
for model, is_wild in ((nego_cards, 0), (wild_cards, 1)): for model, pk, is_wild in ((nego_cards, nego_cards.nego_card_id, 0), (wild_cards, wild_cards.wild_card_id, 1)):
query = select(model.number, model.name).where(model.deleted == False) # noqa: E712 query = select(model.number, model.name, pk).where(model.deleted == False) # noqa: E712
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "card_names failed.", raise_error=False) err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "card_names failed.", raise_error=False)
if err_type != ErrorType.SUCCESS: if err_type != ErrorType.SUCCESS:
return err_type, [] return err_type, []
out.extend((number, name, is_wild) for number, name in (rows or [])) out.extend((number, name, is_wild, card_id) for number, name, card_id in (rows or []))
return ErrorType.SUCCESS, out return ErrorType.SUCCESS, out
async def anchoring_current(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, list]: async def anchoring_current(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, list]:

View File

@ -13,33 +13,37 @@ class LearningProtocol(WebPacketProtocol):
class LearningKpi(LearningProtocol): class LearningKpi(LearningProtocol):
learned_sessions: int = 0 # 학습에 반영된 협상 수 learned_sessions: int = 0 # 학습에 반영된 협상 수
records: int = 0 # 학습 기록 수(카드 선택 1회 = 1건) records: int = 0 # 학습 기록 수(카드 선택 1회 = 1건)
settled_sessions: int = 0 # 그중 타결된 협상 수 used_cards: int = 0 # 한 번이라도 나간 카드 수
settle_rate: float = 0.0 # 타결 비율 unused_cards: int = 0 # 아직 한 번도 안 나간 카드 수
top3_share: float = 0.0 # 상위 3장이 차지하는 사용 비중 — 쏠림 정도
last_learned_at: Optional[datetime] = None last_learned_at: Optional[datetime] = None
class CardPerformanceRow(LearningProtocol): class CardUsageRow(LearningProtocol):
number: str # 카드번호(NGC-001 등) number: str # 카드번호(NGC-001 등)
name: Optional[str] = None name: Optional[str] = None
card_id: Optional[str] = None # 카드 PK — 협상카드 상세(/cards?detail=) 링크용
type: str = "nego" # nego | wild type: str = "nego" # nego | wild
used_sessions: int = 0 # 이 카드를 쓴 협상 수 used_sessions: int = 0 # 이 카드가 나간 협상 수
uses: int = 0 # 총 사용 횟수 uses: int = 0 # 총 사용 횟수
avg_reward: float = 0.0 # 평균 보상 — agent 가 협상 결과로 매긴 성적 share: float = 0.0 # 전체 카드 사용 중 이 카드의 비중
settled_sessions: int = 0 avg_turn: float = 0.0 # 평균 몇 번째 라운드에 나갔는지 — 설정한 국면과 대조용
settle_rate: float = 0.0 # 이 카드를 쓴 협상의 타결 비율 last_used_at: Optional[datetime] = None
class AnchoringCell(LearningProtocol): class AnchoringCell(LearningProtocol):
supplier_type: int = 0 supplier_type: int = 0
supplier_type_label: str = "미지정" supplier_type_label: str = "미지정"
price_range_index: int = 0 # 목표가 기준 가격대 구간 price_range_index: int = 0 # 목표가 기준 가격대 구간
anchoring_value: float = 0.0 # 앵커링 인하폭(‰) price_range_label: str = "" # 그 구간의 실제 금액 범위(화면 표기용)
anchoring_value: float = 0.0 # 앵커링 인하폭(‰ — 10 = 1%)
last_adjusted_at: Optional[datetime] = None last_adjusted_at: Optional[datetime] = None
class AnchoringHistoryRow(LearningProtocol): class AnchoringHistoryRow(LearningProtocol):
supplier_type_label: str = "미지정" supplier_type_label: str = "미지정"
price_range_index: int = 0 price_range_index: int = 0
price_range_label: str = ""
value_before: float = 0.0 value_before: float = 0.0
value_after: float = 0.0 value_after: float = 0.0
sample_count: int = 0 # 조정 판단에 쓴 협상 표본 수 sample_count: int = 0 # 조정 판단에 쓴 협상 표본 수
@ -50,7 +54,7 @@ class AnchoringHistoryRow(LearningProtocol):
class Res_LearningStatus(Res_WebPacketProtocol): class Res_LearningStatus(Res_WebPacketProtocol):
kpi: LearningKpi = Field(default_factory=LearningKpi) kpi: LearningKpi = Field(default_factory=LearningKpi)
cards: list[CardPerformanceRow] = [] cards: list[CardUsageRow] = []
class Res_AnchoringStatus(Res_WebPacketProtocol): class Res_AnchoringStatus(Res_WebPacketProtocol):

View File

@ -1,13 +1,16 @@
import uuid
from fastapi import Depends from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import quotations from common.database.model.models import quotations
from common.anchoring.constants import UPPER_BOUNDS
from common.enums import DBWRType, ErrorType, SupplierType from common.enums import DBWRType, ErrorType, SupplierType
from crud.learning_crud import ILearningCRUD, LearningCRUD from crud.learning_crud import ILearningCRUD, LearningCRUD
from router.v1.learning.protocol import ( from router.v1.learning.protocol import (
AnchoringCell, AnchoringCell,
AnchoringHistoryRow, AnchoringHistoryRow,
CardPerformanceRow, CardUsageRow,
LearningKpi, LearningKpi,
Res_AnchoringStatus, Res_AnchoringStatus,
Res_LearningStatus, Res_LearningStatus,
@ -15,6 +18,14 @@ from router.v1.learning.protocol import (
HISTORY_LIMIT = 50 # 앵커링 조정 이력 표시 개수 — 한 화면에서 훑는 용도 HISTORY_LIMIT = 50 # 앵커링 조정 이력 표시 개수 — 한 화면에서 훑는 용도
def _price_range_label(index: int) -> str:
"""가격대 인덱스 → 실제 금액 범위. 사다리는 common.anchoring 정본(UPPER_BOUNDS)만 참조한다."""
if index < 0 or index >= len(UPPER_BOUNDS):
return ""
low = UPPER_BOUNDS[index - 1] if index else 0
return f"{low:,}~{UPPER_BOUNDS[index]:,}"
_SUPPLIER_TYPE_LABEL = { _SUPPLIER_TYPE_LABEL = {
SupplierType.NONE.value: "미지정", SupplierType.NONE.value: "미지정",
SupplierType.DISTRIBUTION.value: "유통", SupplierType.DISTRIBUTION.value: "유통",
@ -33,41 +44,51 @@ class LearningService:
self.crud = crud self.crud = crud
async def get_learning_status(self, company_id: str) -> Res_LearningStatus: async def get_learning_status(self, company_id: str) -> Res_LearningStatus:
"""카드 사용 현황 — 담아둔 카드가 실제로 나가는지, 어느 국면에 나가는지, 쏠리지는 않는지.
협상 성과(타결·가격) 카드별로 나누지 않는다 협상에 여러 장이 나가 어느 장의 몫인지
가릴 없고, 카드 배정도 무작위가 아니라 국면에 따라 정해지기 때문이다.
"""
res = Res_LearningStatus() res = Res_LearningStatus()
# learning·anchoring 의 company_id 는 agent 가 테넌트 키를 그대로 넣는 문자열 컬럼이다(UUID 타입 아님). # learning 의 company_id 는 agent 가 테넌트 키를 그대로 넣는 문자열 컬럼이다(UUID 타입 아님).
cid = str(company_id) cid = str(company_id)
summary = await self._read(lambda s: self.crud.learning_summary(s, cid), default=(0, 0, 0, None)) sessions, records, _settled, last_at = await self._read(
sessions, records, settled, last_at = summary lambda s: self.crud.learning_summary(s, cid), default=(0, 0, 0, None))
res.kpi = LearningKpi( rows = await self._read(lambda s: self.crud.card_usage(s, cid), default=[])
learned_sessions=int(sessions or 0),
records=int(records or 0),
settled_sessions=int(settled or 0),
settle_rate=round((settled or 0) / sessions, 3) if sessions else 0.0,
last_learned_at=last_at,
)
rows = await self._read(lambda s: self.crud.card_performance(s, cid), default=[])
names = await self._read(lambda s: self.crud.card_names(s), default=[]) names = await self._read(lambda s: self.crud.card_names(s), default=[])
name_map = {str(number): (name, is_wild) for number, name, is_wild in names if number} name_map = {str(number): (name, is_wild, card_pk) for number, name, is_wild, card_pk in names if number}
for card_id, used_sessions, uses, avg_reward, settled_sessions in rows:
total_uses = sum(int(uses or 0) for _n, _s, uses, _t, _l in rows) or 1
for card_id, used_sessions, uses, avg_turn, last_used in rows:
number = str(card_id) number = str(card_id)
name, is_wild = name_map.get(number, (None, 0)) name, is_wild, card_pk = name_map.get(number, (None, 0, None))
res.cards.append(CardPerformanceRow( res.cards.append(CardUsageRow(
number=number, number=number,
name=name, name=name,
card_id=str(card_pk) if card_pk else None,
type="wild" if is_wild else "nego", type="wild" if is_wild else "nego",
used_sessions=int(used_sessions or 0), used_sessions=int(used_sessions or 0),
uses=int(uses or 0), uses=int(uses or 0),
avg_reward=round(float(avg_reward), 3) if avg_reward is not None else 0.0, share=round(int(uses or 0) / total_uses, 3),
settled_sessions=int(settled_sessions or 0), avg_turn=round(float(avg_turn), 1) if avg_turn is not None else 0.0,
settle_rate=round((settled_sessions or 0) / used_sessions, 3) if used_sessions else 0.0, last_used_at=last_used,
)) ))
res.kpi = LearningKpi(
learned_sessions=int(sessions or 0),
records=int(records or 0),
used_cards=len(res.cards),
unused_cards=max(0, len(name_map) - len(res.cards)),
top3_share=round(sum(c.uses for c in res.cards[:3]) / total_uses, 3),
last_learned_at=last_at,
)
return res return res
async def get_anchoring_status(self, company_id: str) -> Res_AnchoringStatus: async def get_anchoring_status(self, company_id: str) -> Res_AnchoringStatus:
res = Res_AnchoringStatus() res = Res_AnchoringStatus()
cid = str(company_id) # anchoring 스키마의 company_id 는 UUID 타입 — learning(문자열 테넌트 키)과 다르다.
cid = uuid.UUID(str(company_id))
cells = await self._read(lambda s: self.crud.anchoring_current(s, cid), default=[]) cells = await self._read(lambda s: self.crud.anchoring_current(s, cid), default=[])
for supplier_type, price_range_index, value, adjusted_at in cells: for supplier_type, price_range_index, value, adjusted_at in cells:
@ -75,6 +96,7 @@ class LearningService:
supplier_type=int(supplier_type or 0), supplier_type=int(supplier_type or 0),
supplier_type_label=_SUPPLIER_TYPE_LABEL.get(int(supplier_type or 0), "미지정"), supplier_type_label=_SUPPLIER_TYPE_LABEL.get(int(supplier_type or 0), "미지정"),
price_range_index=int(price_range_index or 0), price_range_index=int(price_range_index or 0),
price_range_label=_price_range_label(int(price_range_index or 0)),
anchoring_value=float(value) if value is not None else 0.0, anchoring_value=float(value) if value is not None else 0.0,
last_adjusted_at=adjusted_at, last_adjusted_at=adjusted_at,
)) ))
@ -84,6 +106,7 @@ class LearningService:
res.history.append(AnchoringHistoryRow( res.history.append(AnchoringHistoryRow(
supplier_type_label=_SUPPLIER_TYPE_LABEL.get(int(st or 0), "미지정"), supplier_type_label=_SUPPLIER_TYPE_LABEL.get(int(st or 0), "미지정"),
price_range_index=int(pri or 0), price_range_index=int(pri or 0),
price_range_label=_price_range_label(int(pri or 0)),
value_before=float(before) if before is not None else 0.0, value_before=float(before) if before is not None else 0.0,
value_after=float(after) if after is not None else 0.0, value_after=float(after) if after is not None else 0.0,
sample_count=int(sample or 0), sample_count=int(sample or 0),

View File

@ -10,6 +10,7 @@ export interface AnchoringCell {
supplier_type?: number; supplier_type?: number;
supplier_type_label?: string; supplier_type_label?: string;
price_range_index?: number; price_range_index?: number;
price_range_label?: string;
anchoring_value?: number; anchoring_value?: number;
last_adjusted_at?: AnchoringCellLastAdjustedAt; last_adjusted_at?: AnchoringCellLastAdjustedAt;
} }

View File

@ -9,6 +9,7 @@ import type { AnchoringHistoryRowCreatedAt } from './anchoringHistoryRowCreatedA
export interface AnchoringHistoryRow { export interface AnchoringHistoryRow {
supplier_type_label?: string; supplier_type_label?: string;
price_range_index?: number; price_range_index?: number;
price_range_label?: string;
value_before?: number; value_before?: number;
value_after?: number; value_after?: number;
sample_count?: number; sample_count?: number;

View File

@ -1,18 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { CardPerformanceRowName } from './cardPerformanceRowName';
export interface CardPerformanceRow {
number: string;
name?: CardPerformanceRowName;
type?: string;
used_sessions?: number;
uses?: number;
avg_reward?: number;
settled_sessions?: number;
settle_rate?: number;
}

View File

@ -0,0 +1,21 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { CardUsageRowName } from './cardUsageRowName';
import type { CardUsageRowCardId } from './cardUsageRowCardId';
import type { CardUsageRowLastUsedAt } from './cardUsageRowLastUsedAt';
export interface CardUsageRow {
number: string;
name?: CardUsageRowName;
card_id?: CardUsageRowCardId;
type?: string;
used_sessions?: number;
uses?: number;
share?: number;
avg_turn?: number;
last_used_at?: CardUsageRowLastUsedAt;
}

View File

@ -5,4 +5,4 @@
* OpenAPI spec version: 0.1.0 * OpenAPI spec version: 0.1.0
*/ */
export type CardPerformanceRowName = string | null; export type CardUsageRowCardId = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type CardUsageRowLastUsedAt = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type CardUsageRowName = string | null;

View File

@ -22,10 +22,12 @@ export * from './cardDataScript';
export * from './cardDataTactic'; export * from './cardDataTactic';
export * from './cardDataUpdatedAt'; export * from './cardDataUpdatedAt';
export * from './cardDataUserId'; export * from './cardDataUserId';
export * from './cardPerformanceRow';
export * from './cardPerformanceRowName';
export * from './cardStatus'; export * from './cardStatus';
export * from './cardType'; export * from './cardType';
export * from './cardUsageRow';
export * from './cardUsageRowCardId';
export * from './cardUsageRowLastUsedAt';
export * from './cardUsageRowName';
export * from './cardUsageType'; export * from './cardUsageType';
export * from './chatMessageData'; export * from './chatMessageData';
export * from './chatMessageDataCardId'; export * from './chatMessageDataCardId';

View File

@ -9,7 +9,8 @@ import type { LearningKpiLastLearnedAt } from './learningKpiLastLearnedAt';
export interface LearningKpi { export interface LearningKpi {
learned_sessions?: number; learned_sessions?: number;
records?: number; records?: number;
settled_sessions?: number; used_cards?: number;
settle_rate?: number; unused_cards?: number;
top3_share?: number;
last_learned_at?: LearningKpiLastLearnedAt; last_learned_at?: LearningKpiLastLearnedAt;
} }

View File

@ -7,11 +7,11 @@
import type { ErrorInfo } from './errorInfo'; import type { ErrorInfo } from './errorInfo';
import type { ResLearningStatusMsg } from './resLearningStatusMsg'; import type { ResLearningStatusMsg } from './resLearningStatusMsg';
import type { LearningKpi } from './learningKpi'; import type { LearningKpi } from './learningKpi';
import type { CardPerformanceRow } from './cardPerformanceRow'; import type { CardUsageRow } from './cardUsageRow';
export interface ResLearningStatus { export interface ResLearningStatus {
result?: ErrorInfo; result?: ErrorInfo;
msg?: ResLearningStatusMsg; msg?: ResLearningStatusMsg;
kpi?: LearningKpi; kpi?: LearningKpi;
cards?: CardPerformanceRow[]; cards?: CardUsageRow[];
} }

View File

@ -70,7 +70,15 @@ export const router = createBrowserRouter([
children: [ children: [
{path: 'dashboard', Component: DashboardPage}, {path: 'dashboard', Component: DashboardPage},
{path: 'statistics', Component: StatisticsPage}, {path: 'statistics', Component: StatisticsPage},
{path: 'learning', Component: LearningPage}, {
// 개발자 전용. 학습·앵커링 내부 동작을 드러내는 화면이라 고객사 관리자에게 열지 않는다.
path: 'learning',
loader: async () => {
await initAuth();
return hasRole('개발자') ? null : redirect('/forbidden');
},
Component: LearningPage,
},
{path: 'products', Component: ProductsPage}, {path: 'products', Component: ProductsPage},
{path: 'partners', Component: PartnersPage}, {path: 'partners', Component: PartnersPage},
{path: 'quotation', Component: QuotationPage}, {path: 'quotation', Component: QuotationPage},

View File

@ -57,7 +57,6 @@ const menuGroups: { label?: string; items: MenuItem[] }[] = [
items: [ items: [
{ type: 'DASHBOARD', label: '대시보드', icon: LayoutDashboard, id: 'sidebar-dashboard' }, { type: 'DASHBOARD', label: '대시보드', icon: LayoutDashboard, id: 'sidebar-dashboard' },
{ type: 'STATISTICS', label: '통계', icon: BarChart3, id: 'sidebar-statistics' }, { type: 'STATISTICS', label: '통계', icon: BarChart3, id: 'sidebar-statistics' },
{ type: 'LEARNING', label: '협상 학습', icon: Brain, id: 'sidebar-learning' },
], ],
}, },
{ {
@ -80,6 +79,7 @@ const menuGroups: { label?: string; items: MenuItem[] }[] = [
{ {
label: '개발자', label: '개발자',
items: [ items: [
{ type: 'LEARNING', label: '협상 학습', icon: Brain, id: 'sidebar-learning', devOnly: true },
{ type: 'DEV_SETTINGS', label: '고급 설정', icon: SlidersHorizontal, id: 'sidebar-dev-settings', devOnly: true }, { type: 'DEV_SETTINGS', label: '고급 설정', icon: SlidersHorizontal, id: 'sidebar-dev-settings', devOnly: true },
{ type: 'DESIGN', label: '디자인 시스템', icon: Palette, id: 'sidebar-design', devOnly: true }, { type: 'DESIGN', label: '디자인 시스템', icon: Palette, id: 'sidebar-design', devOnly: true },
], ],

View File

@ -1,4 +1,7 @@
import { Bot, CheckCircle2, Layers, Ruler } from 'lucide-react'; 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 { Panel } from '@/features/statistics/components/Panel';
import { StatTile } from '@/features/statistics/components/StatTile'; import { StatTile } from '@/features/statistics/components/StatTile';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
@ -10,60 +13,85 @@ import type { AnchoringData, LearningData } from './types';
// 두 값 모두 agent·anchoring 서비스가 쌓은 결과를 읽기만 한다. // 두 값 모두 agent·anchoring 서비스가 쌓은 결과를 읽기만 한다.
export function CardLearningView({ data }: { data: LearningData }) { export function CardLearningView({ data }: { data: LearningData }) {
const best = data.cards.filter((c) => c.uses > 0);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-2 gap-3 lg:grid-cols-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.learnedSessions}`} icon={Bot} tone="purple" />
<StatTile label="학습 기록" value={`${data.records}`} icon={Layers} tone="blue" /> <StatTile label="카드 사용" value={`${data.records}`} icon={Layers} tone="blue" />
<StatTile label="타결 협상" value={`${data.settledSessions}`} icon={CheckCircle2} tone="emerald" /> <StatTile label="써본 카드" value={`${data.usedCards}`} icon={CheckCircle2} tone="emerald" />
<StatTile label="타결 비율" value={pct(data.settleRate)} icon={Ruler} tone="amber" /> <StatTile label="아직 안 쓴 카드" value={`${data.unusedCards}`} icon={Ruler} tone="amber" />
</div> </div>
<Panel <Panel
title="카드별 학습 성적" title="카드 사용 현황"
subtitle="AI가 협상 결과로 매긴 점수입니다. 점수가 높을수록 그 카드를 쓴 협상이 잘 풀렸다는 뜻입니다." subtitle="담아둔 카드가 실제로 나가는지, 어느 국면에 나가는지 봅니다. 협상 결과는 여러 장이 함께 만든 것이라 카드별 성과로 나누지 않습니다."
right={ right={
data.lastLearnedAt ? ( data.lastLearnedAt ? (
<Typography as="span" variant="small" className="text-[11px] text-muted-foreground"> <Typography as="span" variant="small" className="text-[11px] text-muted-foreground">
{fmtDateTime(data.lastLearnedAt)} {fmtDateTime(data.lastLearnedAt)}
</Typography> </Typography>
) : undefined ) : undefined
} }
> >
{best.length === 0 ? ( {data.cards.length === 0 ? (
<EmptyRow text="아직 학습된 협상이 없습니다. 협상이 진행되면 카드별 성적이 쌓입니다." /> <EmptyRow text="아직 사용된 카드가 없습니다. 협상이 진행되면 여기에 쌓입니다." />
) : ( ) : (
<Table className="text-xs"> <>
<TableHeader> {data.top3Share >= 0.8 && data.usedCards > 3 && (
<TableRow> <Typography as="div" variant="small" className="mb-2 text-[11px] text-amber-700 dark:text-amber-400">
<TableHead className="w-24"></TableHead> 3 {pct(data.top3Share)}
<TableHead></TableHead> .
<TableHead className="w-20 text-center"></TableHead> </Typography>
<TableHead className="w-24 text-right"> </TableHead> )}
<TableHead className="w-24 text-right"> </TableHead> <Table className="text-xs">
<TableHead className="w-24 text-right"> </TableHead> <TableHeader>
<TableHead className="w-28 text-right"> </TableHead> <TableRow>
</TableRow> <TableHead className="w-24"></TableHead>
</TableHeader> <TableHead></TableHead>
<TableBody> <TableHead className="w-20 text-center"></TableHead>
{best.map((c) => ( <TableHead className="w-24 text-right"> </TableHead>
<TableRow key={c.number}> <TableHead className="w-24 text-right"> </TableHead>
<TableCell className="font-mono">{c.number}</TableCell> <TableHead className="w-28 text-right">
<TableCell className="font-medium">{c.name}</TableCell> <HeadWithHelp label="사용 비중" help="전체 카드 사용 횟수 중 이 카드가 차지하는 비율입니다. 한두 장에 몰려 있으면 나머지 카드가 발동 조건에 걸려 밀리고 있을 수 있습니다." />
<TableCell className="text-center"> </TableHead>
<TypeBadge isWild={c.isWild} /> <TableHead className="w-32 text-right">
</TableCell> <HeadWithHelp label="평균 라운드" help="이 카드가 협상의 몇 번째 라운드에 나갔는지의 평균입니다. 값이 작으면 초반에, 크면 종결 무렵에 나간다는 뜻입니다. 종결 전용으로 설정한 카드가 초반에 나오고 있지는 않은지 확인할 때 봅니다." />
<TableCell className="text-right">{c.usedSessions}</TableCell> </TableHead>
<TableCell className="text-right">{c.uses}</TableCell> <TableHead className="w-36 text-right"> </TableHead>
<TableCell className="text-right">{pct(c.settleRate)}</TableCell>
<TableCell className="text-right">
<RewardBar value={c.avgReward} />
</TableCell>
</TableRow> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
</Table> {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 text-muted-foreground">
{c.lastUsedAt ? fmtDateTime(c.lastUsedAt) : '-'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>
)} )}
</Panel> </Panel>
</div> </div>
@ -80,12 +108,13 @@ export function AnchoringStatusView({ data }: { data: AnchoringData }) {
<div className="grid grid-cols-2 gap-3 lg:grid-cols-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={`${data.cells.length}`} icon={Layers} tone="purple" />
<StatTile label="조정된 구간" value={`${adjusted}`} icon={CheckCircle2} tone="emerald" /> <StatTile label="조정된 구간" value={`${adjusted}`} icon={CheckCircle2} tone="emerald" />
<StatTile label="평균 인하폭" value={permille(avg)} icon={Ruler} tone="blue" /> <StatTile label="평균 인하폭 (목표가 대비)" value={rate(avg)} icon={Ruler} tone="blue" />
<StatTile label="조정 이력" value={`${data.history.length}`} icon={Bot} tone="amber" /> <StatTile label="조정 이력" value={`${data.history.length}`} icon={Bot} tone="amber" />
</div> </div>
<div className="grid gap-4 xl:grid-cols-2"> <div className="grid gap-4 xl:grid-cols-2">
<Panel title="현재 앵커링 값" subtitle="협력사 유형과 가격대 구간마다 따로 관리합니다."> <Panel title="현재 앵커링 값"
subtitle="협력사 유형과 가격대가 이 조합이면 이 인하폭을 적용합니다. 앵커가 = 목표가 (목표가 × 인하폭).">
{data.cells.length === 0 ? ( {data.cells.length === 0 ? (
<EmptyRow text="아직 설정된 구간이 없습니다. 협상이 쌓이면 구간별로 값이 만들어집니다." /> <EmptyRow text="아직 설정된 구간이 없습니다. 협상이 쌓이면 구간별로 값이 만들어집니다." />
) : ( ) : (
@ -93,7 +122,7 @@ export function AnchoringStatusView({ data }: { data: AnchoringData }) {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-24"> </TableHead> <TableHead className="w-24"> </TableHead>
<TableHead className="w-24 text-right"></TableHead> <TableHead></TableHead>
<TableHead className="w-24 text-right"></TableHead> <TableHead className="w-24 text-right"></TableHead>
<TableHead className="text-right"> </TableHead> <TableHead className="text-right"> </TableHead>
</TableRow> </TableRow>
@ -102,8 +131,8 @@ export function AnchoringStatusView({ data }: { data: AnchoringData }) {
{data.cells.map((c) => ( {data.cells.map((c) => (
<TableRow key={`${c.supplierType}-${c.priceRange}`}> <TableRow key={`${c.supplierType}-${c.priceRange}`}>
<TableCell>{c.supplierType}</TableCell> <TableCell>{c.supplierType}</TableCell>
<TableCell className="text-right font-mono">{c.priceRange}</TableCell> <TableCell className="font-mono">{c.priceRange}</TableCell>
<TableCell className="text-right font-mono">{permille(c.value)}</TableCell> <TableCell className="text-right font-mono">{rate(c.value)}</TableCell>
<TableCell className="text-right text-muted-foreground"> <TableCell className="text-right text-muted-foreground">
{c.adjustedAt ? fmtDateTime(c.adjustedAt) : '조정 전'} {c.adjustedAt ? fmtDateTime(c.adjustedAt) : '조정 전'}
</TableCell> </TableCell>
@ -114,7 +143,8 @@ export function AnchoringStatusView({ data }: { data: AnchoringData }) {
)} )}
</Panel> </Panel>
<Panel title="조정 이력" subtitle="표본이 충분히 쌓인 구간만 성공률을 보고 값을 조정합니다."> <Panel title="조정 이력"
subtitle="구간마다 협상 10건이 모이면 평가합니다. 앵커가 이하로 합의된 비율이 높으면 인하폭을 올리고, 낮으면 내립니다.">
{data.history.length === 0 ? ( {data.history.length === 0 ? (
<EmptyRow text="아직 조정된 이력이 없습니다." /> <EmptyRow text="아직 조정된 이력이 없습니다." />
) : ( ) : (
@ -122,7 +152,7 @@ export function AnchoringStatusView({ data }: { data: AnchoringData }) {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-24"> </TableHead> <TableHead className="w-24"> </TableHead>
<TableHead className="w-16 text-right"></TableHead> <TableHead></TableHead>
<TableHead className="w-28 text-right"></TableHead> <TableHead className="w-28 text-right"></TableHead>
<TableHead className="w-20 text-right"></TableHead> <TableHead className="w-20 text-right"></TableHead>
<TableHead className="w-20 text-right"></TableHead> <TableHead className="w-20 text-right"></TableHead>
@ -133,9 +163,9 @@ export function AnchoringStatusView({ data }: { data: AnchoringData }) {
{data.history.map((h, i) => ( {data.history.map((h, i) => (
<TableRow key={`${h.supplierType}-${h.priceRange}-${h.at ?? i}`}> <TableRow key={`${h.supplierType}-${h.priceRange}-${h.at ?? i}`}>
<TableCell>{h.supplierType}</TableCell> <TableCell>{h.supplierType}</TableCell>
<TableCell className="text-right font-mono">{h.priceRange}</TableCell> <TableCell className="font-mono">{h.priceRange}</TableCell>
<TableCell className="text-right font-mono"> <TableCell className="text-right font-mono">
{permille(h.before)} <span className="font-semibold">{permille(h.after)}</span> {rate(h.before)} <span className="font-semibold">{rate(h.after)}</span>
</TableCell> </TableCell>
<TableCell className="text-right">{h.samples}</TableCell> <TableCell className="text-right">{h.samples}</TableCell>
<TableCell className="text-right">{pct(h.successRate)}</TableCell> <TableCell className="text-right">{pct(h.successRate)}</TableCell>
@ -153,21 +183,80 @@ export function AnchoringStatusView({ data }: { data: AnchoringData }) {
); );
} }
// 평균 점수는 1~1 범위라 숫자만으론 감이 안 온다 — 0 기준 좌우 막대로 방향과 크기를 같이 보여준다. // 표 헤더의 ? — 지표 정의를 눌러서 확인한다. 부제에 정의를 길게 늘어놓지 않기 위한 것이고,
function RewardBar({ value }: { value: number }) { // 여러 줄 설명이라 마우스를 떼면 사라지는 hover 툴팁 대신 클릭으로 여닫는다.
const width = Math.min(Math.abs(value), 1) * 50; function HeadWithHelp({ label, help }: { label: string; help: string }) {
const positive = value >= 0; // 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 ( return (
<div className="flex items-center justify-end gap-2"> <span
<div className="relative h-2 w-24 rounded bg-muted"> ref={ref}
<div className="inline-flex items-center justify-end gap-1"
className={`absolute top-0 h-2 ${positive ? 'bg-emerald-500' : 'bg-rose-500'}`} onMouseEnter={() => setHovered(true)}
style={{ left: positive ? '50%' : `${50 - width}%`, width: `${width}%` }} onMouseLeave={() => setHovered(false)}
/> >
<div className="absolute left-1/2 top-0 h-2 w-px bg-border" /> {label}
</div> <button
<span className="w-12 text-right font-mono">{value.toFixed(2)}</span> type="button"
</div> 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>
); );
} }
@ -197,7 +286,7 @@ function pct(v: number): string {
return `${(v * 100).toFixed(0)}%`; return `${(v * 100).toFixed(0)}%`;
} }
// 앵커링 값은 천분율(‰) — 목표가에서 이만큼 낮춘 가격이 앵커가가 된다. // 앵커링 값은 천분율(‰)로 저장된다 — 10 = 1%. 화면은 담당자가 쓰는 단위(%)로 보여준다.
function permille(v: number): string { function rate(v: number): string {
return `${v.toFixed(1)}`; return `${(v / 10).toFixed(1)}%`;
} }

View File

@ -10,17 +10,20 @@ export function useLearningData(): { data: LearningData; isLoading: boolean } {
data: { data: {
learnedSessions: k.learned_sessions ?? 0, learnedSessions: k.learned_sessions ?? 0,
records: k.records ?? 0, records: k.records ?? 0,
settledSessions: k.settled_sessions ?? 0, usedCards: k.used_cards ?? 0,
settleRate: k.settle_rate ?? 0, unusedCards: k.unused_cards ?? 0,
top3Share: k.top3_share ?? 0,
lastLearnedAt: k.last_learned_at ?? null, lastLearnedAt: k.last_learned_at ?? null,
cards: (data?.cards ?? []).map((c) => ({ cards: (data?.cards ?? []).map((c) => ({
number: c.number, number: c.number,
name: c.name ?? c.number, name: c.name ?? c.number,
cardId: c.card_id ?? null,
isWild: c.type === 'wild', isWild: c.type === 'wild',
usedSessions: c.used_sessions ?? 0, usedSessions: c.used_sessions ?? 0,
uses: c.uses ?? 0, uses: c.uses ?? 0,
avgReward: c.avg_reward ?? 0, share: c.share ?? 0,
settleRate: c.settle_rate ?? 0, avgTurn: c.avg_turn ?? 0,
lastUsedAt: c.last_used_at ?? null,
})), })),
}, },
}; };
@ -33,13 +36,13 @@ export function useAnchoringData(): { data: AnchoringData; isLoading: boolean }
data: { data: {
cells: (data?.cells ?? []).map((c) => ({ cells: (data?.cells ?? []).map((c) => ({
supplierType: c.supplier_type_label ?? '미지정', supplierType: c.supplier_type_label ?? '미지정',
priceRange: c.price_range_index ?? 0, priceRange: c.price_range_label ?? '',
value: c.anchoring_value ?? 0, value: c.anchoring_value ?? 0,
adjustedAt: c.last_adjusted_at ?? null, adjustedAt: c.last_adjusted_at ?? null,
})), })),
history: (data?.history ?? []).map((h) => ({ history: (data?.history ?? []).map((h) => ({
supplierType: h.supplier_type_label ?? '미지정', supplierType: h.supplier_type_label ?? '미지정',
priceRange: h.price_range_index ?? 0, priceRange: h.price_range_label ?? '',
before: h.value_before ?? 0, before: h.value_before ?? 0,
after: h.value_after ?? 0, after: h.value_after ?? 0,
samples: h.sample_count ?? 0, samples: h.sample_count ?? 0,

View File

@ -3,32 +3,35 @@
export type CardRow = { export type CardRow = {
number: string; number: string;
name: string; name: string;
cardId: string | null; // 협상카드 상세 링크용 PK
isWild: boolean; isWild: boolean;
usedSessions: number; // 이 카드를 쓴 협상 수 usedSessions: number; // 이 카드가 나간 협상 수
uses: number; // 총 사용 횟수 uses: number; // 총 사용 횟수
avgReward: number; // 평균 보상 — 협상 결과로 매겨진 성적 share: number; // 전체 카드 사용 중 비중
settleRate: number; // 이 카드를 쓴 협상의 타결 비율 avgTurn: number; // 평균 몇 번째 라운드에 나갔는지
lastUsedAt: string | null;
}; };
export type LearningData = { export type LearningData = {
learnedSessions: number; learnedSessions: number;
records: number; records: number;
settledSessions: number; usedCards: number;
settleRate: number; unusedCards: number;
top3Share: number; // 상위 3장 사용 비중 — 쏠림
lastLearnedAt: string | null; lastLearnedAt: string | null;
cards: CardRow[]; cards: CardRow[];
}; };
export type AnchorCell = { export type AnchorCell = {
supplierType: string; // 유통/제조/총판/미지정 supplierType: string; // 유통/제조/총판/미지정
priceRange: number; // 가격대 구간 priceRange: string; // 가격대 구간(실제 금액 범위)
value: number; // 앵커링 인하폭(‰) value: number; // 앵커링 인하폭 — 천분율 저장값(10 = 1%)
adjustedAt: string | null; adjustedAt: string | null;
}; };
export type AnchorHistoryRow = { export type AnchorHistoryRow = {
supplierType: string; supplierType: string;
priceRange: number; priceRange: string;
before: number; before: number;
after: number; after: number;
samples: number; // 조정 판단에 쓴 협상 표본 수 samples: number; // 조정 판단에 쓴 협상 표본 수

View File

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useSearchParams } from 'react-router';
import { PageContainer } from '@/components/layout/PageContainer'; import { PageContainer } from '@/components/layout/PageContainer';
import { Typography } from '@/components/ui/typography'; import { Typography } from '@/components/ui/typography';
import { AnchoringStatusView, CardLearningView } from '@/features/learning/LearningView'; import { AnchoringStatusView, CardLearningView } from '@/features/learning/LearningView';
@ -10,7 +10,10 @@ import { useAnchoringData, useLearningData } from '@/features/learning/api';
type Tab = 'cards' | 'anchoring'; type Tab = 'cards' | 'anchoring';
export default function LearningPage() { export default function LearningPage() {
const [tab, setTab] = useState<Tab>('cards'); // 탭은 쿼리(?tab=)에 둔다 — 새로고침·뒤로가기·링크 공유가 그대로 되도록.
const [params, setParams] = useSearchParams();
const tab: Tab = params.get('tab') === 'anchoring' ? 'anchoring' : 'cards';
const setTab = (next: Tab) => setParams(next === 'cards' ? {} : { tab: next }, { replace: true });
const learning = useLearningData(); const learning = useLearningData();
const anchoring = useAnchoringData(); const anchoring = useAnchoringData();
const isLoading = tab === 'cards' ? learning.isLoading : anchoring.isLoading; const isLoading = tab === 'cards' ? learning.isLoading : anchoring.isLoading;