[fix] negodata: 협상 학습 화면 정리 — 내부 점수 제거·카드탭 사용 현황 전환·메뉴 개발자 전용
- 관측 사실 기준으로 재구성: 내부 점수 제거, 앵커링 % 표기, 탭 URL 분리 - 카드탭을 성과 지표에서 사용 현황으로 전환, 지표 설명 말풍선·카드 상세 링크 - 사이드바 devOnly + 라우트 가드로 개발자 전용 처리
This commit is contained in:
parent
e1e519c20c
commit
f1e924931f
@ -14,6 +14,7 @@ _EXPERIENCE_LOGS = table(
|
||||
"experience_logs",
|
||||
column("company_id"), column("session_id"), column("card_id"), column("reward"),
|
||||
column("settled_price"), column("is_invalidated"), column("created_at"),
|
||||
column("turn"),
|
||||
schema="learning",
|
||||
)
|
||||
_ANCHORING_CURRENT = table(
|
||||
@ -45,7 +46,7 @@ class ILearningCRUD(ABC):
|
||||
pass
|
||||
|
||||
@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
|
||||
|
||||
@abstractmethod
|
||||
@ -76,41 +77,43 @@ class LearningCRUD(ILearningCRUD):
|
||||
return err_type, (0, 0, 0, None)
|
||||
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 = (
|
||||
select(
|
||||
_EXPERIENCE_LOGS.c.card_id,
|
||||
func.count(func.distinct(_EXPERIENCE_LOGS.c.session_id)),
|
||||
func.count(),
|
||||
func.avg(_EXPERIENCE_LOGS.c.reward),
|
||||
func.count(func.distinct(_EXPERIENCE_LOGS.c.session_id))
|
||||
.filter(_EXPERIENCE_LOGS.c.settled_price.isnot(None)),
|
||||
func.avg(_EXPERIENCE_LOGS.c.turn),
|
||||
func.max(_EXPERIENCE_LOGS.c.created_at),
|
||||
)
|
||||
.where(and_(*_valid(company_id), _EXPERIENCE_LOGS.c.card_id.isnot(None)))
|
||||
.group_by(_EXPERIENCE_LOGS.c.card_id)
|
||||
.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:
|
||||
return err_type, []
|
||||
return ErrorType.SUCCESS, list(rows or [])
|
||||
|
||||
async def card_names(self, cdb: AsyncSession) -> Tuple[ErrorType, list]:
|
||||
"""카드번호 → (번호, 이름, 와일드 여부). 학습 로그의 card_id 가 카드번호 문자열이다.
|
||||
"""카드번호 → (번호, 이름, 와일드 여부, 카드 PK). 학습 로그의 card_id 가 카드번호 문자열이라
|
||||
상세 화면(/cards?detail=<PK>) 으로 보내려면 PK 를 같이 들고 와야 한다.
|
||||
|
||||
UNION 은 실행기가 SELECT 로 인정하지 않아 두 번 나눠 조회한다.
|
||||
"""
|
||||
out = []
|
||||
for model, is_wild in ((nego_cards, 0), (wild_cards, 1)):
|
||||
query = select(model.number, model.name).where(model.deleted == False) # noqa: E712
|
||||
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, pk).where(model.deleted == False) # noqa: E712
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "card_names failed.", raise_error=False)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
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
|
||||
|
||||
async def anchoring_current(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, list]:
|
||||
|
||||
@ -13,33 +13,37 @@ class LearningProtocol(WebPacketProtocol):
|
||||
class LearningKpi(LearningProtocol):
|
||||
learned_sessions: int = 0 # 학습에 반영된 협상 수
|
||||
records: int = 0 # 학습 기록 수(카드 선택 1회 = 1건)
|
||||
settled_sessions: int = 0 # 그중 타결된 협상 수
|
||||
settle_rate: float = 0.0 # 타결 비율
|
||||
used_cards: int = 0 # 한 번이라도 나간 카드 수
|
||||
unused_cards: int = 0 # 아직 한 번도 안 나간 카드 수
|
||||
top3_share: float = 0.0 # 상위 3장이 차지하는 사용 비중 — 쏠림 정도
|
||||
last_learned_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class CardPerformanceRow(LearningProtocol):
|
||||
class CardUsageRow(LearningProtocol):
|
||||
number: str # 카드번호(NGC-001 등)
|
||||
name: Optional[str] = None
|
||||
card_id: Optional[str] = None # 카드 PK — 협상카드 상세(/cards?detail=) 링크용
|
||||
type: str = "nego" # nego | wild
|
||||
used_sessions: int = 0 # 이 카드를 쓴 협상 수
|
||||
used_sessions: int = 0 # 이 카드가 나간 협상 수
|
||||
uses: int = 0 # 총 사용 횟수
|
||||
avg_reward: float = 0.0 # 평균 보상 — agent 가 협상 결과로 매긴 성적
|
||||
settled_sessions: int = 0
|
||||
settle_rate: float = 0.0 # 이 카드를 쓴 협상의 타결 비율
|
||||
share: float = 0.0 # 전체 카드 사용 중 이 카드의 비중
|
||||
avg_turn: float = 0.0 # 평균 몇 번째 라운드에 나갔는지 — 설정한 국면과 대조용
|
||||
last_used_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class AnchoringCell(LearningProtocol):
|
||||
supplier_type: int = 0
|
||||
supplier_type_label: str = "미지정"
|
||||
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
|
||||
|
||||
|
||||
class AnchoringHistoryRow(LearningProtocol):
|
||||
supplier_type_label: str = "미지정"
|
||||
price_range_index: int = 0
|
||||
price_range_label: str = ""
|
||||
value_before: float = 0.0
|
||||
value_after: float = 0.0
|
||||
sample_count: int = 0 # 조정 판단에 쓴 협상 표본 수
|
||||
@ -50,7 +54,7 @@ class AnchoringHistoryRow(LearningProtocol):
|
||||
|
||||
class Res_LearningStatus(Res_WebPacketProtocol):
|
||||
kpi: LearningKpi = Field(default_factory=LearningKpi)
|
||||
cards: list[CardPerformanceRow] = []
|
||||
cards: list[CardUsageRow] = []
|
||||
|
||||
|
||||
class Res_AnchoringStatus(Res_WebPacketProtocol):
|
||||
|
||||
@ -1,13 +1,16 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import quotations
|
||||
from common.anchoring.constants import UPPER_BOUNDS
|
||||
from common.enums import DBWRType, ErrorType, SupplierType
|
||||
from crud.learning_crud import ILearningCRUD, LearningCRUD
|
||||
from router.v1.learning.protocol import (
|
||||
AnchoringCell,
|
||||
AnchoringHistoryRow,
|
||||
CardPerformanceRow,
|
||||
CardUsageRow,
|
||||
LearningKpi,
|
||||
Res_AnchoringStatus,
|
||||
Res_LearningStatus,
|
||||
@ -15,6 +18,14 @@ from router.v1.learning.protocol import (
|
||||
|
||||
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 = {
|
||||
SupplierType.NONE.value: "미지정",
|
||||
SupplierType.DISTRIBUTION.value: "유통",
|
||||
@ -33,41 +44,51 @@ class LearningService:
|
||||
self.crud = crud
|
||||
|
||||
async def get_learning_status(self, company_id: str) -> Res_LearningStatus:
|
||||
"""카드 사용 현황 — 담아둔 카드가 실제로 나가는지, 어느 국면에 나가는지, 쏠리지는 않는지.
|
||||
|
||||
협상 성과(타결·가격)는 카드별로 나누지 않는다 — 한 협상에 여러 장이 나가 어느 장의 몫인지
|
||||
가릴 수 없고, 카드 배정도 무작위가 아니라 국면에 따라 정해지기 때문이다.
|
||||
"""
|
||||
res = Res_LearningStatus()
|
||||
# learning·anchoring 의 company_id 는 agent 가 테넌트 키를 그대로 넣는 문자열 컬럼이다(UUID 타입 아님).
|
||||
# learning 의 company_id 는 agent 가 테넌트 키를 그대로 넣는 문자열 컬럼이다(UUID 타입 아님).
|
||||
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 = summary
|
||||
res.kpi = LearningKpi(
|
||||
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=[])
|
||||
sessions, records, _settled, last_at = await self._read(
|
||||
lambda s: self.crud.learning_summary(s, cid), default=(0, 0, 0, None))
|
||||
rows = await self._read(lambda s: self.crud.card_usage(s, cid), 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}
|
||||
for card_id, used_sessions, uses, avg_reward, settled_sessions in rows:
|
||||
name_map = {str(number): (name, is_wild, card_pk) for number, name, is_wild, card_pk in names if number}
|
||||
|
||||
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)
|
||||
name, is_wild = name_map.get(number, (None, 0))
|
||||
res.cards.append(CardPerformanceRow(
|
||||
name, is_wild, card_pk = name_map.get(number, (None, 0, None))
|
||||
res.cards.append(CardUsageRow(
|
||||
number=number,
|
||||
name=name,
|
||||
card_id=str(card_pk) if card_pk else None,
|
||||
type="wild" if is_wild else "nego",
|
||||
used_sessions=int(used_sessions or 0),
|
||||
uses=int(uses or 0),
|
||||
avg_reward=round(float(avg_reward), 3) if avg_reward is not None else 0.0,
|
||||
settled_sessions=int(settled_sessions or 0),
|
||||
settle_rate=round((settled_sessions or 0) / used_sessions, 3) if used_sessions else 0.0,
|
||||
share=round(int(uses or 0) / total_uses, 3),
|
||||
avg_turn=round(float(avg_turn), 1) if avg_turn is not None 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
|
||||
|
||||
async def get_anchoring_status(self, company_id: str) -> 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=[])
|
||||
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_label=_SUPPLIER_TYPE_LABEL.get(int(supplier_type 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,
|
||||
last_adjusted_at=adjusted_at,
|
||||
))
|
||||
@ -84,6 +106,7 @@ class LearningService:
|
||||
res.history.append(AnchoringHistoryRow(
|
||||
supplier_type_label=_SUPPLIER_TYPE_LABEL.get(int(st 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_after=float(after) if after is not None else 0.0,
|
||||
sample_count=int(sample or 0),
|
||||
|
||||
@ -10,6 +10,7 @@ export interface AnchoringCell {
|
||||
supplier_type?: number;
|
||||
supplier_type_label?: string;
|
||||
price_range_index?: number;
|
||||
price_range_label?: string;
|
||||
anchoring_value?: number;
|
||||
last_adjusted_at?: AnchoringCellLastAdjustedAt;
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ import type { AnchoringHistoryRowCreatedAt } from './anchoringHistoryRowCreatedA
|
||||
export interface AnchoringHistoryRow {
|
||||
supplier_type_label?: string;
|
||||
price_range_index?: number;
|
||||
price_range_label?: string;
|
||||
value_before?: number;
|
||||
value_after?: number;
|
||||
sample_count?: number;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
21
negodata/front/src/api/generated/model/cardUsageRow.ts
Normal file
21
negodata/front/src/api/generated/model/cardUsageRow.ts
Normal 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;
|
||||
}
|
||||
@ -5,4 +5,4 @@
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type CardPerformanceRowName = string | null;
|
||||
export type CardUsageRowCardId = string | null;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -22,10 +22,12 @@ export * from './cardDataScript';
|
||||
export * from './cardDataTactic';
|
||||
export * from './cardDataUpdatedAt';
|
||||
export * from './cardDataUserId';
|
||||
export * from './cardPerformanceRow';
|
||||
export * from './cardPerformanceRowName';
|
||||
export * from './cardStatus';
|
||||
export * from './cardType';
|
||||
export * from './cardUsageRow';
|
||||
export * from './cardUsageRowCardId';
|
||||
export * from './cardUsageRowLastUsedAt';
|
||||
export * from './cardUsageRowName';
|
||||
export * from './cardUsageType';
|
||||
export * from './chatMessageData';
|
||||
export * from './chatMessageDataCardId';
|
||||
|
||||
@ -9,7 +9,8 @@ import type { LearningKpiLastLearnedAt } from './learningKpiLastLearnedAt';
|
||||
export interface LearningKpi {
|
||||
learned_sessions?: number;
|
||||
records?: number;
|
||||
settled_sessions?: number;
|
||||
settle_rate?: number;
|
||||
used_cards?: number;
|
||||
unused_cards?: number;
|
||||
top3_share?: number;
|
||||
last_learned_at?: LearningKpiLastLearnedAt;
|
||||
}
|
||||
|
||||
@ -7,11 +7,11 @@
|
||||
import type { ErrorInfo } from './errorInfo';
|
||||
import type { ResLearningStatusMsg } from './resLearningStatusMsg';
|
||||
import type { LearningKpi } from './learningKpi';
|
||||
import type { CardPerformanceRow } from './cardPerformanceRow';
|
||||
import type { CardUsageRow } from './cardUsageRow';
|
||||
|
||||
export interface ResLearningStatus {
|
||||
result?: ErrorInfo;
|
||||
msg?: ResLearningStatusMsg;
|
||||
kpi?: LearningKpi;
|
||||
cards?: CardPerformanceRow[];
|
||||
cards?: CardUsageRow[];
|
||||
}
|
||||
|
||||
@ -70,7 +70,15 @@ export const router = createBrowserRouter([
|
||||
children: [
|
||||
{path: 'dashboard', Component: DashboardPage},
|
||||
{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: 'partners', Component: PartnersPage},
|
||||
{path: 'quotation', Component: QuotationPage},
|
||||
|
||||
@ -57,7 +57,6 @@ const menuGroups: { label?: string; items: MenuItem[] }[] = [
|
||||
items: [
|
||||
{ type: 'DASHBOARD', label: '대시보드', icon: LayoutDashboard, id: 'sidebar-dashboard' },
|
||||
{ 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: '개발자',
|
||||
items: [
|
||||
{ type: 'LEARNING', label: '협상 학습', icon: Brain, id: 'sidebar-learning', devOnly: true },
|
||||
{ type: 'DEV_SETTINGS', label: '고급 설정', icon: SlidersHorizontal, id: 'sidebar-dev-settings', devOnly: true },
|
||||
{ type: 'DESIGN', label: '디자인 시스템', icon: Palette, id: 'sidebar-design', devOnly: true },
|
||||
],
|
||||
|
||||
@ -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 { StatTile } from '@/features/statistics/components/StatTile';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
@ -10,60 +13,85 @@ import type { AnchoringData, LearningData } from './types';
|
||||
// 두 값 모두 agent·anchoring 서비스가 쌓은 결과를 읽기만 한다.
|
||||
|
||||
export function CardLearningView({ data }: { data: LearningData }) {
|
||||
const best = data.cards.filter((c) => c.uses > 0);
|
||||
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.settledSessions}건`} icon={CheckCircle2} tone="emerald" />
|
||||
<StatTile label="타결 비율" value={pct(data.settleRate)} icon={Ruler} tone="amber" />
|
||||
<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="AI가 협상 결과로 매긴 점수입니다. 점수가 높을수록 그 카드를 쓴 협상이 잘 풀렸다는 뜻입니다."
|
||||
title="카드 사용 현황"
|
||||
subtitle="담아둔 카드가 실제로 나가는지, 어느 국면에 나가는지 봅니다. 협상 결과는 여러 장이 함께 만든 것이라 카드별 성과로 나누지 않습니다."
|
||||
right={
|
||||
data.lastLearnedAt ? (
|
||||
<Typography as="span" variant="small" className="text-[11px] text-muted-foreground">
|
||||
최근 학습 {fmtDateTime(data.lastLearnedAt)}
|
||||
최근 사용 {fmtDateTime(data.lastLearnedAt)}
|
||||
</Typography>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{best.length === 0 ? (
|
||||
<EmptyRow text="아직 학습된 협상이 없습니다. 협상이 진행되면 카드별 성적이 쌓입니다." />
|
||||
{data.cards.length === 0 ? (
|
||||
<EmptyRow text="아직 사용된 카드가 없습니다. 협상이 진행되면 여기에 쌓입니다." />
|
||||
) : (
|
||||
<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-24 text-right">타결 비율</TableHead>
|
||||
<TableHead className="w-28 text-right">평균 점수</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{best.map((c) => (
|
||||
<TableRow key={c.number}>
|
||||
<TableCell className="font-mono">{c.number}</TableCell>
|
||||
<TableCell className="font-medium">{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.settleRate)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<RewardBar value={c.avgReward} />
|
||||
</TableCell>
|
||||
<>
|
||||
{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-36 text-right">마지막 사용</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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 text-muted-foreground">
|
||||
{c.lastUsedAt ? fmtDateTime(c.lastUsedAt) : '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
@ -80,12 +108,13 @@ export function AnchoringStatusView({ data }: { data: AnchoringData }) {
|
||||
<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={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" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<Panel title="현재 앵커링 값" subtitle="협력사 유형과 가격대 구간마다 따로 관리합니다.">
|
||||
<Panel title="현재 앵커링 값"
|
||||
subtitle="협력사 유형과 가격대가 이 조합이면 이 인하폭을 적용합니다. 앵커가 = 목표가 − (목표가 × 인하폭).">
|
||||
{data.cells.length === 0 ? (
|
||||
<EmptyRow text="아직 설정된 구간이 없습니다. 협상이 쌓이면 구간별로 값이 만들어집니다." />
|
||||
) : (
|
||||
@ -93,7 +122,7 @@ export function AnchoringStatusView({ data }: { data: AnchoringData }) {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-24">협력사 유형</TableHead>
|
||||
<TableHead className="w-24 text-right">가격대</TableHead>
|
||||
<TableHead>가격대</TableHead>
|
||||
<TableHead className="w-24 text-right">인하폭</TableHead>
|
||||
<TableHead className="text-right">마지막 조정</TableHead>
|
||||
</TableRow>
|
||||
@ -102,8 +131,8 @@ export function AnchoringStatusView({ data }: { data: AnchoringData }) {
|
||||
{data.cells.map((c) => (
|
||||
<TableRow key={`${c.supplierType}-${c.priceRange}`}>
|
||||
<TableCell>{c.supplierType}</TableCell>
|
||||
<TableCell className="text-right font-mono">{c.priceRange}</TableCell>
|
||||
<TableCell className="text-right font-mono">{permille(c.value)}</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>
|
||||
@ -114,7 +143,8 @@ export function AnchoringStatusView({ data }: { data: AnchoringData }) {
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="조정 이력" subtitle="표본이 충분히 쌓인 구간만 성공률을 보고 값을 조정합니다.">
|
||||
<Panel title="조정 이력"
|
||||
subtitle="구간마다 협상 10건이 모이면 평가합니다. 앵커가 이하로 합의된 비율이 높으면 인하폭을 올리고, 낮으면 내립니다.">
|
||||
{data.history.length === 0 ? (
|
||||
<EmptyRow text="아직 조정된 이력이 없습니다." />
|
||||
) : (
|
||||
@ -122,7 +152,7 @@ export function AnchoringStatusView({ data }: { data: AnchoringData }) {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-24">협력사 유형</TableHead>
|
||||
<TableHead className="w-16 text-right">가격대</TableHead>
|
||||
<TableHead>가격대</TableHead>
|
||||
<TableHead className="w-28 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) => (
|
||||
<TableRow key={`${h.supplierType}-${h.priceRange}-${h.at ?? i}`}>
|
||||
<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">
|
||||
{permille(h.before)} → <span className="font-semibold">{permille(h.after)}</span>
|
||||
{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>
|
||||
@ -153,21 +183,80 @@ export function AnchoringStatusView({ data }: { data: AnchoringData }) {
|
||||
);
|
||||
}
|
||||
|
||||
// 평균 점수는 −1~1 범위라 숫자만으론 감이 안 온다 — 0 기준 좌우 막대로 방향과 크기를 같이 보여준다.
|
||||
function RewardBar({ value }: { value: number }) {
|
||||
const width = Math.min(Math.abs(value), 1) * 50;
|
||||
const positive = value >= 0;
|
||||
// 표 헤더의 ? — 지표 정의를 눌러서 확인한다. 부제에 정의를 길게 늘어놓지 않기 위한 것이고,
|
||||
// 여러 줄 설명이라 마우스를 떼면 사라지는 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 (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<div className="relative h-2 w-24 rounded bg-muted">
|
||||
<div
|
||||
className={`absolute top-0 h-2 ${positive ? 'bg-emerald-500' : 'bg-rose-500'}`}
|
||||
style={{ left: positive ? '50%' : `${50 - width}%`, width: `${width}%` }}
|
||||
/>
|
||||
<div className="absolute left-1/2 top-0 h-2 w-px bg-border" />
|
||||
</div>
|
||||
<span className="w-12 text-right font-mono">{value.toFixed(2)}</span>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -197,7 +286,7 @@ function pct(v: number): string {
|
||||
return `${(v * 100).toFixed(0)}%`;
|
||||
}
|
||||
|
||||
// 앵커링 값은 천분율(‰) — 목표가에서 이만큼 낮춘 가격이 앵커가가 된다.
|
||||
function permille(v: number): string {
|
||||
return `${v.toFixed(1)}‰`;
|
||||
// 앵커링 값은 천분율(‰)로 저장된다 — 10 = 1%. 화면은 담당자가 쓰는 단위(%)로 보여준다.
|
||||
function rate(v: number): string {
|
||||
return `${(v / 10).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
@ -10,17 +10,20 @@ export function useLearningData(): { data: LearningData; isLoading: boolean } {
|
||||
data: {
|
||||
learnedSessions: k.learned_sessions ?? 0,
|
||||
records: k.records ?? 0,
|
||||
settledSessions: k.settled_sessions ?? 0,
|
||||
settleRate: k.settle_rate ?? 0,
|
||||
usedCards: k.used_cards ?? 0,
|
||||
unusedCards: k.unused_cards ?? 0,
|
||||
top3Share: k.top3_share ?? 0,
|
||||
lastLearnedAt: k.last_learned_at ?? null,
|
||||
cards: (data?.cards ?? []).map((c) => ({
|
||||
number: c.number,
|
||||
name: c.name ?? c.number,
|
||||
cardId: c.card_id ?? null,
|
||||
isWild: c.type === 'wild',
|
||||
usedSessions: c.used_sessions ?? 0,
|
||||
uses: c.uses ?? 0,
|
||||
avgReward: c.avg_reward ?? 0,
|
||||
settleRate: c.settle_rate ?? 0,
|
||||
share: c.share ?? 0,
|
||||
avgTurn: c.avg_turn ?? 0,
|
||||
lastUsedAt: c.last_used_at ?? null,
|
||||
})),
|
||||
},
|
||||
};
|
||||
@ -33,13 +36,13 @@ export function useAnchoringData(): { data: AnchoringData; isLoading: boolean }
|
||||
data: {
|
||||
cells: (data?.cells ?? []).map((c) => ({
|
||||
supplierType: c.supplier_type_label ?? '미지정',
|
||||
priceRange: c.price_range_index ?? 0,
|
||||
priceRange: c.price_range_label ?? '',
|
||||
value: c.anchoring_value ?? 0,
|
||||
adjustedAt: c.last_adjusted_at ?? null,
|
||||
})),
|
||||
history: (data?.history ?? []).map((h) => ({
|
||||
supplierType: h.supplier_type_label ?? '미지정',
|
||||
priceRange: h.price_range_index ?? 0,
|
||||
priceRange: h.price_range_label ?? '',
|
||||
before: h.value_before ?? 0,
|
||||
after: h.value_after ?? 0,
|
||||
samples: h.sample_count ?? 0,
|
||||
|
||||
@ -3,32 +3,35 @@
|
||||
export type CardRow = {
|
||||
number: string;
|
||||
name: string;
|
||||
cardId: string | null; // 협상카드 상세 링크용 PK
|
||||
isWild: boolean;
|
||||
usedSessions: number; // 이 카드를 쓴 협상 수
|
||||
usedSessions: number; // 이 카드가 나간 협상 수
|
||||
uses: number; // 총 사용 횟수
|
||||
avgReward: number; // 평균 보상 — 협상 결과로 매겨진 성적
|
||||
settleRate: number; // 이 카드를 쓴 협상의 타결 비율
|
||||
share: number; // 전체 카드 사용 중 비중
|
||||
avgTurn: number; // 평균 몇 번째 라운드에 나갔는지
|
||||
lastUsedAt: string | null;
|
||||
};
|
||||
|
||||
export type LearningData = {
|
||||
learnedSessions: number;
|
||||
records: number;
|
||||
settledSessions: number;
|
||||
settleRate: number;
|
||||
usedCards: number;
|
||||
unusedCards: number;
|
||||
top3Share: number; // 상위 3장 사용 비중 — 쏠림
|
||||
lastLearnedAt: string | null;
|
||||
cards: CardRow[];
|
||||
};
|
||||
|
||||
export type AnchorCell = {
|
||||
supplierType: string; // 유통/제조/총판/미지정
|
||||
priceRange: number; // 가격대 구간
|
||||
value: number; // 앵커링 인하폭(‰)
|
||||
priceRange: string; // 가격대 구간(실제 금액 범위)
|
||||
value: number; // 앵커링 인하폭 — 천분율 저장값(10 = 1%)
|
||||
adjustedAt: string | null;
|
||||
};
|
||||
|
||||
export type AnchorHistoryRow = {
|
||||
supplierType: string;
|
||||
priceRange: number;
|
||||
priceRange: string;
|
||||
before: number;
|
||||
after: number;
|
||||
samples: number; // 조정 판단에 쓴 협상 표본 수
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { PageContainer } from '@/components/layout/PageContainer';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { AnchoringStatusView, CardLearningView } from '@/features/learning/LearningView';
|
||||
@ -10,7 +10,10 @@ import { useAnchoringData, useLearningData } from '@/features/learning/api';
|
||||
type Tab = 'cards' | 'anchoring';
|
||||
|
||||
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 anchoring = useAnchoringData();
|
||||
const isLoading = tab === 'cards' ? learning.isLoading : anchoring.isLoading;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user