From e816bfbba4aa874fe75649643af0541558cd2275 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Mon, 10 Aug 2026 13:24:32 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20negodata:=20=ED=98=91=EC=83=81=20?= =?UTF-8?q?=ED=95=99=EC=8A=B5=20=EB=A9=94=EB=89=B4=20=EC=8B=A0=EC=84=A4=20?= =?UTF-8?q?=E2=80=94=20=EC=B9=B4=EB=93=9C=EB=B3=84=20=ED=95=99=EC=8A=B5=20?= =?UTF-8?q?=EC=84=B1=EC=A0=81=20=C2=B7=20=EC=95=B5=EC=BB=A4=EB=A7=81=20?= =?UTF-8?q?=ED=98=84=ED=99=A9/=EC=A1=B0=EC=A0=95=20=EC=9D=B4=EB=A0=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agent(협상카드 강화학습)와 anchoring(구간별 자동 조정)이 쌓아온 결과를 볼 수 있는 화면이 없어 DB 를 직접 열어야 확인이 됐다. 두 축은 성격이 달라 한 화면에 섞지 않고 탭으로 나눈다. 백엔드(읽기 전용 — 값의 주인은 agent·anchoring): - crud/learning_crud.py: learning·anchoring 스키마 경량 조회(negodata ORM 미매핑 테이블) - services/learning_service.py + router/v1/learning: /v1/learning/cards · /anchoring 프론트: - 사이드바 '협상 학습'(/learning) 추가, 탭 = 협상카드 | 앵커링 - 협상카드: 학습 협상·기록·타결 비율 요약 + 카드별 사용/타결/평균 점수(0 기준 좌우 막대) - 앵커링: 구간별 현재 인하폭 + 조정 이력(표본·성공률·값 변화) 검증: 로컬 실협상 3건으로 데이터 생성 후 두 탭 실화면 확인, tsc·eslint 통과. --- negodata/backend/crud/learning_crud.py | 153 ++++++++++++ negodata/backend/router/router.py | 2 + .../backend/router/v1/learning/__init__.py | 0 .../backend/router/v1/learning/learning.py | 19 ++ .../backend/router/v1/learning/protocol.py | 59 +++++ negodata/backend/services/learning_service.py | 100 ++++++++ .../src/api/generated/learning/learning.ts | 217 ++++++++++++++++++ .../src/api/generated/model/anchoringCell.ts | 15 ++ .../model/anchoringCellLastAdjustedAt.ts | 8 + .../generated/model/anchoringHistoryRow.ts | 18 ++ .../model/anchoringHistoryRowCreatedAt.ts | 8 + .../api/generated/model/cardPerformanceRow.ts | 18 ++ .../generated/model/cardPerformanceRowName.ts | 8 + .../front/src/api/generated/model/index.ts | 12 + .../src/api/generated/model/learningKpi.ts | 15 ++ .../model/learningKpiLastLearnedAt.ts | 8 + .../api/generated/model/resAnchoringStatus.ts | 18 ++ .../generated/model/resAnchoringStatusMsg.ts | 8 + .../api/generated/model/resLearningStatus.ts | 17 ++ .../generated/model/resLearningStatusMsg.ts | 8 + negodata/front/src/app/router.tsx | 2 + .../components/layout/AuthenticatedLayout.tsx | 1 + .../front/src/components/layout/Layout.tsx | 3 + .../src/features/learning/LearningView.tsx | 203 ++++++++++++++++ negodata/front/src/features/learning/api.ts | 51 ++++ negodata/front/src/features/learning/types.ts | 42 ++++ negodata/front/src/pages/learning.tsx | 63 +++++ negodata/front/src/types.ts | 2 +- 28 files changed, 1077 insertions(+), 1 deletion(-) create mode 100644 negodata/backend/crud/learning_crud.py create mode 100644 negodata/backend/router/v1/learning/__init__.py create mode 100644 negodata/backend/router/v1/learning/learning.py create mode 100644 negodata/backend/router/v1/learning/protocol.py create mode 100644 negodata/backend/services/learning_service.py create mode 100644 negodata/front/src/api/generated/learning/learning.ts create mode 100644 negodata/front/src/api/generated/model/anchoringCell.ts create mode 100644 negodata/front/src/api/generated/model/anchoringCellLastAdjustedAt.ts create mode 100644 negodata/front/src/api/generated/model/anchoringHistoryRow.ts create mode 100644 negodata/front/src/api/generated/model/anchoringHistoryRowCreatedAt.ts create mode 100644 negodata/front/src/api/generated/model/cardPerformanceRow.ts create mode 100644 negodata/front/src/api/generated/model/cardPerformanceRowName.ts create mode 100644 negodata/front/src/api/generated/model/learningKpi.ts create mode 100644 negodata/front/src/api/generated/model/learningKpiLastLearnedAt.ts create mode 100644 negodata/front/src/api/generated/model/resAnchoringStatus.ts create mode 100644 negodata/front/src/api/generated/model/resAnchoringStatusMsg.ts create mode 100644 negodata/front/src/api/generated/model/resLearningStatus.ts create mode 100644 negodata/front/src/api/generated/model/resLearningStatusMsg.ts create mode 100644 negodata/front/src/features/learning/LearningView.tsx create mode 100644 negodata/front/src/features/learning/api.ts create mode 100644 negodata/front/src/features/learning/types.ts create mode 100644 negodata/front/src/pages/learning.tsx diff --git a/negodata/backend/crud/learning_crud.py b/negodata/backend/crud/learning_crud.py new file mode 100644 index 0000000..4b3bf62 --- /dev/null +++ b/negodata/backend/crud/learning_crud.py @@ -0,0 +1,153 @@ +from abc import ABC, abstractmethod +from typing import Tuple + +from sqlalchemy import and_, asc, column, desc, func, select, table +from sqlalchemy.ext.asyncio import AsyncSession + +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import nego_cards, wild_cards +from common.enums import ErrorType + +# 학습(learning)·앵커링(anchoring) 스키마는 agent·anchoring 서비스 소유라 negodata ORM 에 없다. +# 조회 전용이므로 필요한 컬럼만 경량 정의한다 — 이 값들의 주인은 negodata 가 아니다(쓰기 금지). +_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"), + schema="learning", +) +_ANCHORING_CURRENT = table( + "current_values", + column("company_id"), column("supplier_type"), column("price_range_index"), + column("anchoring_value"), column("last_adjusted_at"), + schema="anchoring", +) +_ANCHORING_HISTORY = table( + "value_history", + column("company_id"), column("supplier_type"), column("price_range_index"), + column("anchoring_value_before"), column("anchoring_value_after"), + column("sample_count"), column("success_count"), column("success_rate"), column("created_at"), + schema="anchoring", +) + + +def _valid(company_id) -> list: + """유효 학습 기록 — 무효화 표시된 행은 뺀다(협상 취소·재생성 시 agent 가 표시).""" + return [ + _EXPERIENCE_LOGS.c.company_id == company_id, + _EXPERIENCE_LOGS.c.is_invalidated == False, # noqa: E712 + ] + + +class ILearningCRUD(ABC): + @abstractmethod + async def learning_summary(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, tuple]: + pass + + @abstractmethod + async def card_performance(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, list]: + pass + + @abstractmethod + async def card_names(self, cdb: AsyncSession) -> Tuple[ErrorType, list]: + pass + + @abstractmethod + async def anchoring_current(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, list]: + pass + + @abstractmethod + async def anchoring_history(self, cdb: AsyncSession, company_id, limit: int) -> Tuple[ErrorType, list]: + pass + + +class LearningCRUD(ILearningCRUD): + async def learning_summary(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, tuple]: + """(학습 협상 수, 기록 수, 타결 협상 수, 마지막 학습 시각).""" + query = select( + func.count(func.distinct(_EXPERIENCE_LOGS.c.session_id)), + func.count(), + func.count(func.distinct(_EXPERIENCE_LOGS.c.session_id)) + .filter(_EXPERIENCE_LOGS.c.settled_price.isnot(None)), + func.max(_EXPERIENCE_LOGS.c.created_at), + ).where(and_(*_valid(company_id))) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "learning_summary failed.", raise_error=False) + if err_type != ErrorType.SUCCESS or not rows: + 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]: + """카드별 (카드번호, 사용 협상 수, 사용 횟수, 평균 보상, 타결 협상 수). + + 보상(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)), + ) + .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) + 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 가 카드번호 문자열이다. + + 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 + 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 [])) + return ErrorType.SUCCESS, out + + async def anchoring_current(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, list]: + """현재 앵커링 값 — (협력사유형, 가격대 구간, 값, 마지막 조정 시각). 셀 = 유형 × 가격대.""" + query = ( + select( + _ANCHORING_CURRENT.c.supplier_type, + _ANCHORING_CURRENT.c.price_range_index, + _ANCHORING_CURRENT.c.anchoring_value, + _ANCHORING_CURRENT.c.last_adjusted_at, + ) + .where(_ANCHORING_CURRENT.c.company_id == company_id) + .order_by(asc(_ANCHORING_CURRENT.c.supplier_type), asc(_ANCHORING_CURRENT.c.price_range_index)) + ) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "anchoring_current failed.", raise_error=False) + if err_type != ErrorType.SUCCESS: + return err_type, [] + return ErrorType.SUCCESS, list(rows or []) + + async def anchoring_history(self, cdb: AsyncSession, company_id, limit: int) -> Tuple[ErrorType, list]: + """앵커링 조정 이력(최근순) — 조정마다 표본 수·성공률·값 변화가 남는다.""" + query = ( + select( + _ANCHORING_HISTORY.c.supplier_type, + _ANCHORING_HISTORY.c.price_range_index, + _ANCHORING_HISTORY.c.anchoring_value_before, + _ANCHORING_HISTORY.c.anchoring_value_after, + _ANCHORING_HISTORY.c.sample_count, + _ANCHORING_HISTORY.c.success_count, + _ANCHORING_HISTORY.c.success_rate, + _ANCHORING_HISTORY.c.created_at, + ) + .where(_ANCHORING_HISTORY.c.company_id == company_id) + .order_by(desc(_ANCHORING_HISTORY.c.created_at)) + .limit(limit) + ) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "anchoring_history failed.", raise_error=False) + if err_type != ErrorType.SUCCESS: + return err_type, [] + return ErrorType.SUCCESS, list(rows or []) diff --git a/negodata/backend/router/router.py b/negodata/backend/router/router.py index 8a2b4dc..4f54174 100644 --- a/negodata/backend/router/router.py +++ b/negodata/backend/router/router.py @@ -20,6 +20,7 @@ import router.v1.card.card import router.v1.quotation.quotation import router.v1.quotation_setting.quotation_setting import router.v1.dashboard.dashboard +import router.v1.learning.learning import router.v1.statistics.statistics import router.v1.notification.notification import router.v1.renegotiation.renegotiation @@ -79,5 +80,6 @@ app.include_router(router.v1.quotation.quotation.router) app.include_router(router.v1.quotation_setting.quotation_setting.router) app.include_router(router.v1.dashboard.dashboard.router) app.include_router(router.v1.statistics.statistics.router) +app.include_router(router.v1.learning.learning.router) app.include_router(router.v1.notification.notification.router) app.include_router(router.v1.renegotiation.renegotiation.router) diff --git a/negodata/backend/router/v1/learning/__init__.py b/negodata/backend/router/v1/learning/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/negodata/backend/router/v1/learning/learning.py b/negodata/backend/router/v1/learning/learning.py new file mode 100644 index 0000000..e6eb40d --- /dev/null +++ b/negodata/backend/router/v1/learning/learning.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter, Depends + +from common.models.gmodel import UserInfo +from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse +from services.learning_service import LearningService +from .protocol import Res_AnchoringStatus, Res_LearningStatus + +# 협상 학습 현황 — agent(협상카드 학습)·anchoring(앵커링 조정)이 쌓은 결과를 회사 스코프로 읽는다. +router = APIRouter(prefix="/v1/learning", tags=["Learning"], responses={404: {"description": "Not found"}}) + + +@router.get(path="/cards", response_model=Res_LearningStatus, summary="협상카드 학습 현황") +async def get_learning_status(service: LearningService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): + return RemoveNoneResponse(await service.get_learning_status(user_info.company_id)) + + +@router.get(path="/anchoring", response_model=Res_AnchoringStatus, summary="앵커링 현황·조정 이력") +async def get_anchoring_status(service: LearningService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): + return RemoveNoneResponse(await service.get_anchoring_status(user_info.company_id)) diff --git a/negodata/backend/router/v1/learning/protocol.py b/negodata/backend/router/v1/learning/protocol.py new file mode 100644 index 0000000..c1208f4 --- /dev/null +++ b/negodata/backend/router/v1/learning/protocol.py @@ -0,0 +1,59 @@ +from datetime import datetime +from typing import Optional + +from pydantic import Field + +from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol + + +class LearningProtocol(WebPacketProtocol): + pass + + +class LearningKpi(LearningProtocol): + learned_sessions: int = 0 # 학습에 반영된 협상 수 + records: int = 0 # 학습 기록 수(카드 선택 1회 = 1건) + settled_sessions: int = 0 # 그중 타결된 협상 수 + settle_rate: float = 0.0 # 타결 비율 + last_learned_at: Optional[datetime] = None + + +class CardPerformanceRow(LearningProtocol): + number: str # 카드번호(NGC-001 등) + name: Optional[str] = None + type: str = "nego" # nego | wild + used_sessions: int = 0 # 이 카드를 쓴 협상 수 + uses: int = 0 # 총 사용 횟수 + avg_reward: float = 0.0 # 평균 보상 — agent 가 협상 결과로 매긴 성적 + settled_sessions: int = 0 + settle_rate: float = 0.0 # 이 카드를 쓴 협상의 타결 비율 + + +class AnchoringCell(LearningProtocol): + supplier_type: int = 0 + supplier_type_label: str = "미지정" + price_range_index: int = 0 # 목표가 기준 가격대 구간 + anchoring_value: float = 0.0 # 앵커링 인하폭(‰) + last_adjusted_at: Optional[datetime] = None + + +class AnchoringHistoryRow(LearningProtocol): + supplier_type_label: str = "미지정" + price_range_index: int = 0 + value_before: float = 0.0 + value_after: float = 0.0 + sample_count: int = 0 # 조정 판단에 쓴 협상 표본 수 + success_count: int = 0 + success_rate: float = 0.0 + created_at: Optional[datetime] = None + + +class Res_LearningStatus(Res_WebPacketProtocol): + kpi: LearningKpi = Field(default_factory=LearningKpi) + cards: list[CardPerformanceRow] = [] + + +class Res_AnchoringStatus(Res_WebPacketProtocol): + cells: list[AnchoringCell] = [] + history: list[AnchoringHistoryRow] = [] + adjusted_count: int = 0 diff --git a/negodata/backend/services/learning_service.py b/negodata/backend/services/learning_service.py new file mode 100644 index 0000000..365a04e --- /dev/null +++ b/negodata/backend/services/learning_service.py @@ -0,0 +1,100 @@ +from fastapi import Depends + +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import quotations +from common.enums import DBWRType, ErrorType, SupplierType +from crud.learning_crud import ILearningCRUD, LearningCRUD +from router.v1.learning.protocol import ( + AnchoringCell, + AnchoringHistoryRow, + CardPerformanceRow, + LearningKpi, + Res_AnchoringStatus, + Res_LearningStatus, +) + +HISTORY_LIMIT = 50 # 앵커링 조정 이력 표시 개수 — 한 화면에서 훑는 용도 + +_SUPPLIER_TYPE_LABEL = { + SupplierType.NONE.value: "미지정", + SupplierType.DISTRIBUTION.value: "유통", + SupplierType.MANUFACTURE.value: "제조", + SupplierType.SOLE_AGENCY.value: "총판", +} + + +class LearningService: + """협상 학습 현황 — 협상카드 학습(agent Q-learning)과 앵커링 조정 이력을 읽어 보여준다. + + 두 값 모두 negodata 가 만드는 값이 아니라 agent·anchoring 서비스가 쌓은 결과다(읽기 전용). + """ + + def __init__(self, crud: ILearningCRUD = Depends(LearningCRUD)): + self.crud = crud + + async def get_learning_status(self, company_id: str) -> Res_LearningStatus: + res = Res_LearningStatus() + # learning·anchoring 의 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=[]) + 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: + number = str(card_id) + name, is_wild = name_map.get(number, (None, 0)) + res.cards.append(CardPerformanceRow( + number=number, + name=name, + 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, + )) + return res + + async def get_anchoring_status(self, company_id: str) -> Res_AnchoringStatus: + res = Res_AnchoringStatus() + cid = 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: + res.cells.append(AnchoringCell( + 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), + anchoring_value=float(value) if value is not None else 0.0, + last_adjusted_at=adjusted_at, + )) + + history = await self._read(lambda s: self.crud.anchoring_history(s, cid, HISTORY_LIMIT), default=[]) + for st, pri, before, after, sample, success, rate, created_at in history: + res.history.append(AnchoringHistoryRow( + supplier_type_label=_SUPPLIER_TYPE_LABEL.get(int(st or 0), "미지정"), + price_range_index=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), + success_count=int(success or 0), + success_rate=round(float(rate), 3) if rate is not None else 0.0, + created_at=created_at, + )) + res.adjusted_count = len(res.history) + return res + + async def _read(self, fn, default): + """crud 한 건 실행 — 실패해도 화면은 떠야 하므로 기본값으로 떨어진다.""" + err, rows = await DB_SESSION_MNG.execute_lambda(quotations.DBType(), DBWRType.DB_READ.value, fn) + return rows if err == ErrorType.SUCCESS else default diff --git a/negodata/front/src/api/generated/learning/learning.ts b/negodata/front/src/api/generated/learning/learning.ts new file mode 100644 index 0000000..47925fc --- /dev/null +++ b/negodata/front/src/api/generated/learning/learning.ts @@ -0,0 +1,217 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import { + useQuery +} from '@tanstack/react-query'; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseQueryOptions, + UseQueryResult +} from '@tanstack/react-query'; + +import type { + ResAnchoringStatus, + ResLearningStatus +} from '.././model'; + +import { customFetch } from '../../mutator/custom-fetch'; + + +type SecondParameter unknown> = Parameters[1]; + + + +/** + * @summary 협상카드 학습 현황 + */ +export const getLearningStatus = ( + + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/learning/cards`, method: 'GET', signal + }, + options); + } + + + + +export const getGetLearningStatusQueryKey = () => { + return [ + `/v1/learning/cards` + ] as const; + } + + +export const getGetLearningStatusQueryOptions = >, TError = void>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetLearningStatusQueryKey(); + + + + const queryFn: QueryFunction>> = ({ signal }) => getLearningStatus(requestOptions, signal); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type GetLearningStatusQueryResult = NonNullable>> +export type GetLearningStatusQueryError = void + + +export function useGetLearningStatus>, TError = void>( + options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useGetLearningStatus>, TError = void>( + options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useGetLearningStatus>, TError = void>( + options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary 협상카드 학습 현황 + */ + +export function useGetLearningStatus>, TError = void>( + options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getGetLearningStatusQueryOptions(options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + + +/** + * @summary 앵커링 현황·조정 이력 + */ +export const getAnchoringStatus = ( + + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/learning/anchoring`, method: 'GET', signal + }, + options); + } + + + + +export const getGetAnchoringStatusQueryKey = () => { + return [ + `/v1/learning/anchoring` + ] as const; + } + + +export const getGetAnchoringStatusQueryOptions = >, TError = void>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetAnchoringStatusQueryKey(); + + + + const queryFn: QueryFunction>> = ({ signal }) => getAnchoringStatus(requestOptions, signal); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type GetAnchoringStatusQueryResult = NonNullable>> +export type GetAnchoringStatusQueryError = void + + +export function useGetAnchoringStatus>, TError = void>( + options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useGetAnchoringStatus>, TError = void>( + options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useGetAnchoringStatus>, TError = void>( + options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary 앵커링 현황·조정 이력 + */ + +export function useGetAnchoringStatus>, TError = void>( + options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getGetAnchoringStatusQueryOptions(options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + + diff --git a/negodata/front/src/api/generated/model/anchoringCell.ts b/negodata/front/src/api/generated/model/anchoringCell.ts new file mode 100644 index 0000000..f8f8527 --- /dev/null +++ b/negodata/front/src/api/generated/model/anchoringCell.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { AnchoringCellLastAdjustedAt } from './anchoringCellLastAdjustedAt'; + +export interface AnchoringCell { + supplier_type?: number; + supplier_type_label?: string; + price_range_index?: number; + anchoring_value?: number; + last_adjusted_at?: AnchoringCellLastAdjustedAt; +} diff --git a/negodata/front/src/api/generated/model/anchoringCellLastAdjustedAt.ts b/negodata/front/src/api/generated/model/anchoringCellLastAdjustedAt.ts new file mode 100644 index 0000000..81956c5 --- /dev/null +++ b/negodata/front/src/api/generated/model/anchoringCellLastAdjustedAt.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 AnchoringCellLastAdjustedAt = string | null; diff --git a/negodata/front/src/api/generated/model/anchoringHistoryRow.ts b/negodata/front/src/api/generated/model/anchoringHistoryRow.ts new file mode 100644 index 0000000..a6a69bf --- /dev/null +++ b/negodata/front/src/api/generated/model/anchoringHistoryRow.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { AnchoringHistoryRowCreatedAt } from './anchoringHistoryRowCreatedAt'; + +export interface AnchoringHistoryRow { + supplier_type_label?: string; + price_range_index?: number; + value_before?: number; + value_after?: number; + sample_count?: number; + success_count?: number; + success_rate?: number; + created_at?: AnchoringHistoryRowCreatedAt; +} diff --git a/negodata/front/src/api/generated/model/anchoringHistoryRowCreatedAt.ts b/negodata/front/src/api/generated/model/anchoringHistoryRowCreatedAt.ts new file mode 100644 index 0000000..c128b0c --- /dev/null +++ b/negodata/front/src/api/generated/model/anchoringHistoryRowCreatedAt.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 AnchoringHistoryRowCreatedAt = string | null; diff --git a/negodata/front/src/api/generated/model/cardPerformanceRow.ts b/negodata/front/src/api/generated/model/cardPerformanceRow.ts new file mode 100644 index 0000000..df53bd1 --- /dev/null +++ b/negodata/front/src/api/generated/model/cardPerformanceRow.ts @@ -0,0 +1,18 @@ +/** + * 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/cardPerformanceRowName.ts b/negodata/front/src/api/generated/model/cardPerformanceRowName.ts new file mode 100644 index 0000000..e386cee --- /dev/null +++ b/negodata/front/src/api/generated/model/cardPerformanceRowName.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 CardPerformanceRowName = string | null; diff --git a/negodata/front/src/api/generated/model/index.ts b/negodata/front/src/api/generated/model/index.ts index 4304c83..3e0a35f 100644 --- a/negodata/front/src/api/generated/model/index.ts +++ b/negodata/front/src/api/generated/model/index.ts @@ -5,6 +5,10 @@ * OpenAPI spec version: 0.1.0 */ +export * from './anchoringCell'; +export * from './anchoringCellLastAdjustedAt'; +export * from './anchoringHistoryRow'; +export * from './anchoringHistoryRowCreatedAt'; export * from './bodyUploadItemImageV1ItemImagePost'; export * from './cardData'; export * from './cardDataCondition'; @@ -18,6 +22,8 @@ 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 './cardUsageType'; @@ -77,6 +83,8 @@ export * from './itemDataVatYn'; export * from './itemSupplyType'; export * from './itemSupplyTypeSupplierItemId'; export * from './itemSupplyTypeSupplierName'; +export * from './learningKpi'; +export * from './learningKpiLastLearnedAt'; export * from './listCardsParams'; export * from './listItemsParams'; export * from './listNotificationsParams'; @@ -274,6 +282,8 @@ export * from './reqUpdateSupplierManagerName'; export * from './reqUpdateSupplierName'; export * from './reqUpdateSupplierTotalRevenue'; export * from './reqUpdateSupplyType'; +export * from './resAnchoringStatus'; +export * from './resAnchoringStatusMsg'; export * from './resBulkMapByNames'; export * from './resBulkMapByNamesMsg'; export * from './resCard'; @@ -327,6 +337,8 @@ export * from './resItemListMsg'; export * from './resItemMsg'; export * from './resItemSupplyTypeList'; export * from './resItemSupplyTypeListMsg'; +export * from './resLearningStatus'; +export * from './resLearningStatusMsg'; export * from './resLogin'; export * from './resLoginMsg'; export * from './resLowestPriceResult'; diff --git a/negodata/front/src/api/generated/model/learningKpi.ts b/negodata/front/src/api/generated/model/learningKpi.ts new file mode 100644 index 0000000..8efe803 --- /dev/null +++ b/negodata/front/src/api/generated/model/learningKpi.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { LearningKpiLastLearnedAt } from './learningKpiLastLearnedAt'; + +export interface LearningKpi { + learned_sessions?: number; + records?: number; + settled_sessions?: number; + settle_rate?: number; + last_learned_at?: LearningKpiLastLearnedAt; +} diff --git a/negodata/front/src/api/generated/model/learningKpiLastLearnedAt.ts b/negodata/front/src/api/generated/model/learningKpiLastLearnedAt.ts new file mode 100644 index 0000000..502ce8c --- /dev/null +++ b/negodata/front/src/api/generated/model/learningKpiLastLearnedAt.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 LearningKpiLastLearnedAt = string | null; diff --git a/negodata/front/src/api/generated/model/resAnchoringStatus.ts b/negodata/front/src/api/generated/model/resAnchoringStatus.ts new file mode 100644 index 0000000..2ff534c --- /dev/null +++ b/negodata/front/src/api/generated/model/resAnchoringStatus.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ErrorInfo } from './errorInfo'; +import type { ResAnchoringStatusMsg } from './resAnchoringStatusMsg'; +import type { AnchoringCell } from './anchoringCell'; +import type { AnchoringHistoryRow } from './anchoringHistoryRow'; + +export interface ResAnchoringStatus { + result?: ErrorInfo; + msg?: ResAnchoringStatusMsg; + cells?: AnchoringCell[]; + history?: AnchoringHistoryRow[]; + adjusted_count?: number; +} diff --git a/negodata/front/src/api/generated/model/resAnchoringStatusMsg.ts b/negodata/front/src/api/generated/model/resAnchoringStatusMsg.ts new file mode 100644 index 0000000..e46cfac --- /dev/null +++ b/negodata/front/src/api/generated/model/resAnchoringStatusMsg.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 ResAnchoringStatusMsg = string | null; diff --git a/negodata/front/src/api/generated/model/resLearningStatus.ts b/negodata/front/src/api/generated/model/resLearningStatus.ts new file mode 100644 index 0000000..2b3f8dd --- /dev/null +++ b/negodata/front/src/api/generated/model/resLearningStatus.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ErrorInfo } from './errorInfo'; +import type { ResLearningStatusMsg } from './resLearningStatusMsg'; +import type { LearningKpi } from './learningKpi'; +import type { CardPerformanceRow } from './cardPerformanceRow'; + +export interface ResLearningStatus { + result?: ErrorInfo; + msg?: ResLearningStatusMsg; + kpi?: LearningKpi; + cards?: CardPerformanceRow[]; +} diff --git a/negodata/front/src/api/generated/model/resLearningStatusMsg.ts b/negodata/front/src/api/generated/model/resLearningStatusMsg.ts new file mode 100644 index 0000000..a0d2e0e --- /dev/null +++ b/negodata/front/src/api/generated/model/resLearningStatusMsg.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 ResLearningStatusMsg = string | null; diff --git a/negodata/front/src/app/router.tsx b/negodata/front/src/app/router.tsx index df77d1f..c05257a 100644 --- a/negodata/front/src/app/router.tsx +++ b/negodata/front/src/app/router.tsx @@ -5,6 +5,7 @@ import {hasSeenOnboarding} from '../features/onboarding/storage'; import AuthenticatedLayout from '@/components/layout/AuthenticatedLayout'; import LoginPage from '../pages/login'; import DashboardPage from '../pages/dashboard'; +import LearningPage from '../pages/learning'; import StatisticsPage from '../pages/statistics'; import ForbiddenPage from '../pages/forbidden'; import DevDesignPage from '../pages/dev-design'; @@ -69,6 +70,7 @@ export const router = createBrowserRouter([ children: [ {path: 'dashboard', Component: DashboardPage}, {path: 'statistics', Component: StatisticsPage}, + {path: 'learning', Component: LearningPage}, {path: 'products', Component: ProductsPage}, {path: 'partners', Component: PartnersPage}, {path: 'quotation', Component: QuotationPage}, diff --git a/negodata/front/src/components/layout/AuthenticatedLayout.tsx b/negodata/front/src/components/layout/AuthenticatedLayout.tsx index 7f7c1b6..2dd1178 100644 --- a/negodata/front/src/components/layout/AuthenticatedLayout.tsx +++ b/negodata/front/src/components/layout/AuthenticatedLayout.tsx @@ -7,6 +7,7 @@ import {showToast} from '@/lib/notify'; const PAGE_TO_PATH: Record = { DASHBOARD: '/dashboard', STATISTICS: '/statistics', + LEARNING: '/learning', PRODUCTS: '/products', PARTNERS: '/partners', QUOTATION: '/quotation', diff --git a/negodata/front/src/components/layout/Layout.tsx b/negodata/front/src/components/layout/Layout.tsx index fc47e5f..659a11e 100644 --- a/negodata/front/src/components/layout/Layout.tsx +++ b/negodata/front/src/components/layout/Layout.tsx @@ -18,6 +18,7 @@ import { import { LayoutDashboard, BarChart3, + Brain, Briefcase, Users, UserCog, @@ -56,6 +57,7 @@ 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' }, ], }, { @@ -96,6 +98,7 @@ const canSee = (item: MenuItem, role?: string): boolean => { const pageLabelMap: Record = { DASHBOARD: '대시보드', STATISTICS: '통계', + LEARNING: '협상 학습', PRODUCTS: '상품관리', PARTNERS: '협력사관리', QUOTATION: '견적관리', diff --git a/negodata/front/src/features/learning/LearningView.tsx b/negodata/front/src/features/learning/LearningView.tsx new file mode 100644 index 0000000..7c2e504 --- /dev/null +++ b/negodata/front/src/features/learning/LearningView.tsx @@ -0,0 +1,203 @@ +import { Bot, CheckCircle2, Layers, Ruler } from 'lucide-react'; +import { Panel } from '@/features/statistics/components/Panel'; +import { StatTile } from '@/features/statistics/components/StatTile'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Typography } from '@/components/ui/typography'; +import { fmtDateTime } from '@/features/quotations/types'; +import type { AnchoringData, LearningData } from './types'; + +// 협상 학습 현황. 협상카드(강화학습)와 앵커링(자동 조정)은 서로 다른 축이라 탭으로 나눠 각각 한 화면에 담는다. +// 두 값 모두 agent·anchoring 서비스가 쌓은 결과를 읽기만 한다. + +export function CardLearningView({ data }: { data: LearningData }) { + const best = data.cards.filter((c) => c.uses > 0); + return ( +
+
+ + + + +
+ + + 최근 학습 {fmtDateTime(data.lastLearnedAt)} + + ) : undefined + } + > + {best.length === 0 ? ( + + ) : ( + + + + 카드번호 + 카드이름 + 종류 + 사용 협상 + 사용 횟수 + 타결 비율 + 평균 점수 + + + + {best.map((c) => ( + + {c.number} + {c.name} + + + + {c.usedSessions} + {c.uses} + {pct(c.settleRate)} + + + + + ))} + +
+ )} +
+
+ ); +} + +export function AnchoringStatusView({ data }: { data: AnchoringData }) { + const adjusted = data.cells.filter((c) => c.adjustedAt).length; + const avg = data.cells.length + ? data.cells.reduce((sum, c) => sum + c.value, 0) / data.cells.length + : 0; + return ( +
+
+ + + + +
+ +
+ + {data.cells.length === 0 ? ( + + ) : ( + + + + 협력사 유형 + 가격대 + 인하폭 + 마지막 조정 + + + + {data.cells.map((c) => ( + + {c.supplierType} + {c.priceRange} + {permille(c.value)} + + {c.adjustedAt ? fmtDateTime(c.adjustedAt) : '조정 전'} + + + ))} + +
+ )} +
+ + + {data.history.length === 0 ? ( + + ) : ( + + + + 협력사 유형 + 가격대 + 변화 + 표본 + 성공률 + 시각 + + + + {data.history.map((h, i) => ( + + {h.supplierType} + {h.priceRange} + + {permille(h.before)} → {permille(h.after)} + + {h.samples} + {pct(h.successRate)} + + {h.at ? fmtDateTime(h.at) : '-'} + + + ))} + +
+ )} +
+
+
+ ); +} + +// 평균 점수는 −1~1 범위라 숫자만으론 감이 안 온다 — 0 기준 좌우 막대로 방향과 크기를 같이 보여준다. +function RewardBar({ value }: { value: number }) { + const width = Math.min(Math.abs(value), 1) * 50; + const positive = value >= 0; + return ( +
+
+
+
+
+ {value.toFixed(2)} +
+ ); +} + +function TypeBadge({ isWild }: { isWild: boolean }) { + return ( + + {isWild ? '와일드' : '협상'} + + ); +} + +function EmptyRow({ text }: { text: string }) { + return ( + + {text} + + ); +} + +function pct(v: number): string { + return `${(v * 100).toFixed(0)}%`; +} + +// 앵커링 값은 천분율(‰) — 목표가에서 이만큼 낮춘 가격이 앵커가가 된다. +function permille(v: number): string { + return `${v.toFixed(1)}‰`; +} diff --git a/negodata/front/src/features/learning/api.ts b/negodata/front/src/features/learning/api.ts new file mode 100644 index 0000000..e81fc21 --- /dev/null +++ b/negodata/front/src/features/learning/api.ts @@ -0,0 +1,51 @@ +import { useGetAnchoringStatus, useGetLearningStatus } from '@/api/generated/learning/learning'; +import type { AnchoringData, LearningData } from './types'; + +// 생성 API(snake_case·전부 optional) → 화면 타입 매퍼. api/generated 는 수정 금지라 여기서 흡수한다. +export function useLearningData(): { data: LearningData; isLoading: boolean } { + const { data, isLoading } = useGetLearningStatus(); + const k = data?.kpi ?? {}; + return { + isLoading, + data: { + learnedSessions: k.learned_sessions ?? 0, + records: k.records ?? 0, + settledSessions: k.settled_sessions ?? 0, + settleRate: k.settle_rate ?? 0, + lastLearnedAt: k.last_learned_at ?? null, + cards: (data?.cards ?? []).map((c) => ({ + number: c.number, + name: c.name ?? c.number, + isWild: c.type === 'wild', + usedSessions: c.used_sessions ?? 0, + uses: c.uses ?? 0, + avgReward: c.avg_reward ?? 0, + settleRate: c.settle_rate ?? 0, + })), + }, + }; +} + +export function useAnchoringData(): { data: AnchoringData; isLoading: boolean } { + const { data, isLoading } = useGetAnchoringStatus(); + return { + isLoading, + data: { + cells: (data?.cells ?? []).map((c) => ({ + supplierType: c.supplier_type_label ?? '미지정', + priceRange: c.price_range_index ?? 0, + 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, + before: h.value_before ?? 0, + after: h.value_after ?? 0, + samples: h.sample_count ?? 0, + successRate: h.success_rate ?? 0, + at: h.created_at ?? null, + })), + }, + }; +} diff --git a/negodata/front/src/features/learning/types.ts b/negodata/front/src/features/learning/types.ts new file mode 100644 index 0000000..a4526a2 --- /dev/null +++ b/negodata/front/src/features/learning/types.ts @@ -0,0 +1,42 @@ +// 협상 학습 화면 도메인 타입. api/generated(snake_case·전부 optional)를 api.ts 에서 이 형태로 흡수한다. + +export type CardRow = { + number: string; + name: string; + isWild: boolean; + usedSessions: number; // 이 카드를 쓴 협상 수 + uses: number; // 총 사용 횟수 + avgReward: number; // 평균 보상 — 협상 결과로 매겨진 성적 + settleRate: number; // 이 카드를 쓴 협상의 타결 비율 +}; + +export type LearningData = { + learnedSessions: number; + records: number; + settledSessions: number; + settleRate: number; + lastLearnedAt: string | null; + cards: CardRow[]; +}; + +export type AnchorCell = { + supplierType: string; // 유통/제조/총판/미지정 + priceRange: number; // 가격대 구간 + value: number; // 앵커링 인하폭(‰) + adjustedAt: string | null; +}; + +export type AnchorHistoryRow = { + supplierType: string; + priceRange: number; + before: number; + after: number; + samples: number; // 조정 판단에 쓴 협상 표본 수 + successRate: number; + at: string | null; +}; + +export type AnchoringData = { + cells: AnchorCell[]; + history: AnchorHistoryRow[]; +}; diff --git a/negodata/front/src/pages/learning.tsx b/negodata/front/src/pages/learning.tsx new file mode 100644 index 0000000..95e7278 --- /dev/null +++ b/negodata/front/src/pages/learning.tsx @@ -0,0 +1,63 @@ +import { useState } from 'react'; +import { PageContainer } from '@/components/layout/PageContainer'; +import { Typography } from '@/components/ui/typography'; +import { AnchoringStatusView, CardLearningView } from '@/features/learning/LearningView'; +import { useAnchoringData, useLearningData } from '@/features/learning/api'; + +// 협상 학습. AI가 협상을 거치며 쌓은 결과를 보는 화면이다. +// 협상카드 = 어떤 카드가 잘 먹혔는지(강화학습), 앵커링 = 구간별 인하폭이 어떻게 조정됐는지. +// 두 축은 성격이 달라 탭으로 나눈다 — 각각 한 화면에서 끝나야 훑기 좋다. +type Tab = 'cards' | 'anchoring'; + +export default function LearningPage() { + const [tab, setTab] = useState('cards'); + const learning = useLearningData(); + const anchoring = useAnchoringData(); + const isLoading = tab === 'cards' ? learning.isLoading : anchoring.isLoading; + + return ( + +
+ 협상 학습 +
+ setTab('cards')}> + 협상카드 + + setTab('anchoring')}> + 앵커링 + +
+
+ + {isLoading ? ( + 학습 현황을 불러오는 중… + ) : tab === 'cards' ? ( + + ) : ( + + )} +
+ ); +} + +function TabButton({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/negodata/front/src/types.ts b/negodata/front/src/types.ts index 3b85ab1..dcee532 100644 --- a/negodata/front/src/types.ts +++ b/negodata/front/src/types.ts @@ -45,4 +45,4 @@ export interface CardTactic { offer_variable?: string; // 제시 가격 변수 명시 지정. 없으면 멘트 파싱(마지막 가격 변수) — 멘트에 있는 변수만 허용 } -export type PageType = 'DASHBOARD' | 'STATISTICS' | 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS' | 'RENEGOTIATION' | 'MEMBERS' | 'SETTINGS' | 'DEV_SETTINGS' | 'DESIGN' | 'NOTIFICATIONS'; +export type PageType = 'DASHBOARD' | 'STATISTICS' | 'LEARNING' | 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS' | 'RENEGOTIATION' | 'MEMBERS' | 'SETTINGS' | 'DEV_SETTINGS' | 'DESIGN' | 'NOTIFICATIONS';