From f1e924931f6087551610993c0463001ff809fdd6 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Tue, 11 Aug 2026 08:49:15 +0900 Subject: [PATCH] =?UTF-8?q?[fix]=20negodata:=20=ED=98=91=EC=83=81=20?= =?UTF-8?q?=ED=95=99=EC=8A=B5=20=ED=99=94=EB=A9=B4=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?=E2=80=94=20=EB=82=B4=EB=B6=80=20=EC=A0=90=EC=88=98=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=C2=B7=EC=B9=B4=EB=93=9C=ED=83=AD=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=20=ED=98=84=ED=99=A9=20=EC=A0=84=ED=99=98=C2=B7=EB=A9=94?= =?UTF-8?q?=EB=89=B4=20=EA=B0=9C=EB=B0=9C=EC=9E=90=20=EC=A0=84=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 관측 사실 기준으로 재구성: 내부 점수 제거, 앵커링 % 표기, 탭 URL 분리 - 카드탭을 성과 지표에서 사용 현황으로 전환, 지표 설명 말풍선·카드 상세 링크 - 사이드바 devOnly + 라우트 가드로 개발자 전용 처리 --- negodata/backend/crud/learning_crud.py | 27 ++- .../backend/router/v1/learning/protocol.py | 22 +- negodata/backend/services/learning_service.py | 65 ++++-- .../src/api/generated/model/anchoringCell.ts | 1 + .../generated/model/anchoringHistoryRow.ts | 1 + .../api/generated/model/cardPerformanceRow.ts | 18 -- .../src/api/generated/model/cardUsageRow.ts | 21 ++ ...rmanceRowName.ts => cardUsageRowCardId.ts} | 2 +- .../generated/model/cardUsageRowLastUsedAt.ts | 8 + .../api/generated/model/cardUsageRowName.ts | 8 + .../front/src/api/generated/model/index.ts | 6 +- .../src/api/generated/model/learningKpi.ts | 5 +- .../api/generated/model/resLearningStatus.ts | 4 +- negodata/front/src/app/router.tsx | 10 +- .../front/src/components/layout/Layout.tsx | 2 +- .../src/features/learning/LearningView.tsx | 219 ++++++++++++------ negodata/front/src/features/learning/api.ts | 15 +- negodata/front/src/features/learning/types.ts | 19 +- negodata/front/src/pages/learning.tsx | 7 +- 19 files changed, 310 insertions(+), 150 deletions(-) delete mode 100644 negodata/front/src/api/generated/model/cardPerformanceRow.ts create mode 100644 negodata/front/src/api/generated/model/cardUsageRow.ts rename negodata/front/src/api/generated/model/{cardPerformanceRowName.ts => cardUsageRowCardId.ts} (70%) create mode 100644 negodata/front/src/api/generated/model/cardUsageRowLastUsedAt.ts create mode 100644 negodata/front/src/api/generated/model/cardUsageRowName.ts diff --git a/negodata/backend/crud/learning_crud.py b/negodata/backend/crud/learning_crud.py index 4b3bf62..d472d21 100644 --- a/negodata/backend/crud/learning_crud.py +++ b/negodata/backend/crud/learning_crud.py @@ -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 를 같이 들고 와야 한다. 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]: diff --git a/negodata/backend/router/v1/learning/protocol.py b/negodata/backend/router/v1/learning/protocol.py index c1208f4..d0eca26 100644 --- a/negodata/backend/router/v1/learning/protocol.py +++ b/negodata/backend/router/v1/learning/protocol.py @@ -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): diff --git a/negodata/backend/services/learning_service.py b/negodata/backend/services/learning_service.py index 365a04e..6f5b4e2 100644 --- a/negodata/backend/services/learning_service.py +++ b/negodata/backend/services/learning_service.py @@ -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), diff --git a/negodata/front/src/api/generated/model/anchoringCell.ts b/negodata/front/src/api/generated/model/anchoringCell.ts index f8f8527..f7851aa 100644 --- a/negodata/front/src/api/generated/model/anchoringCell.ts +++ b/negodata/front/src/api/generated/model/anchoringCell.ts @@ -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; } diff --git a/negodata/front/src/api/generated/model/anchoringHistoryRow.ts b/negodata/front/src/api/generated/model/anchoringHistoryRow.ts index a6a69bf..55f82d6 100644 --- a/negodata/front/src/api/generated/model/anchoringHistoryRow.ts +++ b/negodata/front/src/api/generated/model/anchoringHistoryRow.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/cardPerformanceRow.ts b/negodata/front/src/api/generated/model/cardPerformanceRow.ts deleted file mode 100644 index df53bd1..0000000 --- a/negodata/front/src/api/generated/model/cardPerformanceRow.ts +++ /dev/null @@ -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; -} diff --git a/negodata/front/src/api/generated/model/cardUsageRow.ts b/negodata/front/src/api/generated/model/cardUsageRow.ts new file mode 100644 index 0000000..0f26337 --- /dev/null +++ b/negodata/front/src/api/generated/model/cardUsageRow.ts @@ -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; +} diff --git a/negodata/front/src/api/generated/model/cardPerformanceRowName.ts b/negodata/front/src/api/generated/model/cardUsageRowCardId.ts similarity index 70% rename from negodata/front/src/api/generated/model/cardPerformanceRowName.ts rename to negodata/front/src/api/generated/model/cardUsageRowCardId.ts index e386cee..20aaa9f 100644 --- a/negodata/front/src/api/generated/model/cardPerformanceRowName.ts +++ b/negodata/front/src/api/generated/model/cardUsageRowCardId.ts @@ -5,4 +5,4 @@ * OpenAPI spec version: 0.1.0 */ -export type CardPerformanceRowName = string | null; +export type CardUsageRowCardId = string | null; diff --git a/negodata/front/src/api/generated/model/cardUsageRowLastUsedAt.ts b/negodata/front/src/api/generated/model/cardUsageRowLastUsedAt.ts new file mode 100644 index 0000000..af3e3a6 --- /dev/null +++ b/negodata/front/src/api/generated/model/cardUsageRowLastUsedAt.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/cardUsageRowName.ts b/negodata/front/src/api/generated/model/cardUsageRowName.ts new file mode 100644 index 0000000..1e9c1e9 --- /dev/null +++ b/negodata/front/src/api/generated/model/cardUsageRowName.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/index.ts b/negodata/front/src/api/generated/model/index.ts index 3e0a35f..4234eb9 100644 --- a/negodata/front/src/api/generated/model/index.ts +++ b/negodata/front/src/api/generated/model/index.ts @@ -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'; diff --git a/negodata/front/src/api/generated/model/learningKpi.ts b/negodata/front/src/api/generated/model/learningKpi.ts index 8efe803..014fc90 100644 --- a/negodata/front/src/api/generated/model/learningKpi.ts +++ b/negodata/front/src/api/generated/model/learningKpi.ts @@ -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; } diff --git a/negodata/front/src/api/generated/model/resLearningStatus.ts b/negodata/front/src/api/generated/model/resLearningStatus.ts index 2b3f8dd..0db1a30 100644 --- a/negodata/front/src/api/generated/model/resLearningStatus.ts +++ b/negodata/front/src/api/generated/model/resLearningStatus.ts @@ -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[]; } diff --git a/negodata/front/src/app/router.tsx b/negodata/front/src/app/router.tsx index c05257a..cf6e0c4 100644 --- a/negodata/front/src/app/router.tsx +++ b/negodata/front/src/app/router.tsx @@ -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}, diff --git a/negodata/front/src/components/layout/Layout.tsx b/negodata/front/src/components/layout/Layout.tsx index 659a11e..d1b61e7 100644 --- a/negodata/front/src/components/layout/Layout.tsx +++ b/negodata/front/src/components/layout/Layout.tsx @@ -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 }, ], diff --git a/negodata/front/src/features/learning/LearningView.tsx b/negodata/front/src/features/learning/LearningView.tsx index 7c2e504..7c07380 100644 --- a/negodata/front/src/features/learning/LearningView.tsx +++ b/negodata/front/src/features/learning/LearningView.tsx @@ -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 (
- - - + + +
- 최근 학습 {fmtDateTime(data.lastLearnedAt)} + 최근 사용 {fmtDateTime(data.lastLearnedAt)} ) : undefined } > - {best.length === 0 ? ( - + {data.cards.length === 0 ? ( + ) : ( - - - - 카드번호 - 카드이름 - 종류 - 사용 협상 - 사용 횟수 - 타결 비율 - 평균 점수 - - - - {best.map((c) => ( - - {c.number} - {c.name} - - - - {c.usedSessions} - {c.uses} - {pct(c.settleRate)} - - - + <> + {data.top3Share >= 0.8 && data.usedCards > 3 && ( + + 상위 3장이 전체 사용의 {pct(data.top3Share)}를 차지합니다 — 나머지 카드는 조건이 맞지 않아 + 밀리고 있을 수 있습니다. + + )} +
+ + + 카드번호 + 카드이름 + 종류 + 사용 협상 + 사용 횟수 + + + + + + + 마지막 사용 - ))} - -
+ + + {data.cards.map((c) => ( + + {c.number} + + {c.cardId ? ( + + {c.name} + + ) : ( + c.name + )} + + + + + {c.usedSessions} + {c.uses} + {pct(c.share)} + {c.avgTurn ? `${c.avgTurn}R` : '-'} + + {c.lastUsedAt ? fmtDateTime(c.lastUsedAt) : '-'} + + + ))} + + + )}
@@ -80,12 +108,13 @@ export function AnchoringStatusView({ data }: { data: AnchoringData }) {
- +
- + {data.cells.length === 0 ? ( ) : ( @@ -93,7 +122,7 @@ export function AnchoringStatusView({ data }: { data: AnchoringData }) { 협력사 유형 - 가격대 + 가격대 인하폭 마지막 조정 @@ -102,8 +131,8 @@ export function AnchoringStatusView({ data }: { data: AnchoringData }) { {data.cells.map((c) => ( {c.supplierType} - {c.priceRange} - {permille(c.value)} + {c.priceRange} + {rate(c.value)} {c.adjustedAt ? fmtDateTime(c.adjustedAt) : '조정 전'} @@ -114,7 +143,8 @@ export function AnchoringStatusView({ data }: { data: AnchoringData }) { )} - + {data.history.length === 0 ? ( ) : ( @@ -122,7 +152,7 @@ export function AnchoringStatusView({ data }: { data: AnchoringData }) { 협력사 유형 - 가격대 + 가격대 변화 표본 성공률 @@ -133,9 +163,9 @@ export function AnchoringStatusView({ data }: { data: AnchoringData }) { {data.history.map((h, i) => ( {h.supplierType} - {h.priceRange} + {h.priceRange} - {permille(h.before)} → {permille(h.after)} + {rate(h.before)} → {rate(h.after)} {h.samples} {pct(h.successRate)} @@ -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(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 ( -
-
-
-
-
- {value.toFixed(2)} -
+ setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + {label} + + {open && + createPortal( +
+ {help} +
, + document.body, + )} +
); } @@ -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)}%`; } diff --git a/negodata/front/src/features/learning/api.ts b/negodata/front/src/features/learning/api.ts index e81fc21..33d9db1 100644 --- a/negodata/front/src/features/learning/api.ts +++ b/negodata/front/src/features/learning/api.ts @@ -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, diff --git a/negodata/front/src/features/learning/types.ts b/negodata/front/src/features/learning/types.ts index a4526a2..e91933f 100644 --- a/negodata/front/src/features/learning/types.ts +++ b/negodata/front/src/features/learning/types.ts @@ -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; // 조정 판단에 쓴 협상 표본 수 diff --git a/negodata/front/src/pages/learning.tsx b/negodata/front/src/pages/learning.tsx index 95e7278..11bb15c 100644 --- a/negodata/front/src/pages/learning.tsx +++ b/negodata/front/src/pages/learning.tsx @@ -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('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;