[fix] negodata: 협상 학습 화면 정리 — 내부 점수 제거·카드탭 사용 현황 전환·메뉴 개발자 전용
- 관측 사실 기준으로 재구성: 내부 점수 제거, 앵커링 % 표기, 탭 URL 분리 - 카드탭을 성과 지표에서 사용 현황으로 전환, 지표 설명 말풍선·카드 상세 링크 - 사이드바 devOnly + 라우트 가드로 개발자 전용 처리
This commit is contained in:
parent
e1e519c20c
commit
f1e924931f
@ -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]:
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
|
* 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 './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';
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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},
|
||||||
|
|||||||
@ -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 },
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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)}%`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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; // 조정 판단에 쓴 협상 표본 수
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user