[feat] negodata: 협상 학습 메뉴 신설 — 카드별 학습 성적 · 앵커링 현황/조정 이력
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 통과.
This commit is contained in:
parent
37fa65707f
commit
e816bfbba4
153
negodata/backend/crud/learning_crud.py
Normal file
153
negodata/backend/crud/learning_crud.py
Normal file
@ -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 [])
|
||||||
@ -20,6 +20,7 @@ import router.v1.card.card
|
|||||||
import router.v1.quotation.quotation
|
import router.v1.quotation.quotation
|
||||||
import router.v1.quotation_setting.quotation_setting
|
import router.v1.quotation_setting.quotation_setting
|
||||||
import router.v1.dashboard.dashboard
|
import router.v1.dashboard.dashboard
|
||||||
|
import router.v1.learning.learning
|
||||||
import router.v1.statistics.statistics
|
import router.v1.statistics.statistics
|
||||||
import router.v1.notification.notification
|
import router.v1.notification.notification
|
||||||
import router.v1.renegotiation.renegotiation
|
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.quotation_setting.quotation_setting.router)
|
||||||
app.include_router(router.v1.dashboard.dashboard.router)
|
app.include_router(router.v1.dashboard.dashboard.router)
|
||||||
app.include_router(router.v1.statistics.statistics.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.notification.notification.router)
|
||||||
app.include_router(router.v1.renegotiation.renegotiation.router)
|
app.include_router(router.v1.renegotiation.renegotiation.router)
|
||||||
|
|||||||
0
negodata/backend/router/v1/learning/__init__.py
Normal file
0
negodata/backend/router/v1/learning/__init__.py
Normal file
19
negodata/backend/router/v1/learning/learning.py
Normal file
19
negodata/backend/router/v1/learning/learning.py
Normal file
@ -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))
|
||||||
59
negodata/backend/router/v1/learning/protocol.py
Normal file
59
negodata/backend/router/v1/learning/protocol.py
Normal file
@ -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
|
||||||
100
negodata/backend/services/learning_service.py
Normal file
100
negodata/backend/services/learning_service.py
Normal file
@ -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
|
||||||
217
negodata/front/src/api/generated/learning/learning.ts
Normal file
217
negodata/front/src/api/generated/learning/learning.ts
Normal file
@ -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<T extends (...args: never) => unknown> = Parameters<T>[1];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary 협상카드 학습 현황
|
||||||
|
*/
|
||||||
|
export const getLearningStatus = (
|
||||||
|
|
||||||
|
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
|
||||||
|
) => {
|
||||||
|
|
||||||
|
|
||||||
|
return customFetch<ResLearningStatus>(
|
||||||
|
{url: `/v1/learning/cards`, method: 'GET', signal
|
||||||
|
},
|
||||||
|
options);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getGetLearningStatusQueryKey = () => {
|
||||||
|
return [
|
||||||
|
`/v1/learning/cards`
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getGetLearningStatusQueryOptions = <TData = Awaited<ReturnType<typeof getLearningStatus>>, TError = void>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getLearningStatus>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
) => {
|
||||||
|
|
||||||
|
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||||
|
|
||||||
|
const queryKey = queryOptions?.queryKey ?? getGetLearningStatusQueryKey();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const queryFn: QueryFunction<Awaited<ReturnType<typeof getLearningStatus>>> = ({ signal }) => getLearningStatus(requestOptions, signal);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getLearningStatus>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetLearningStatusQueryResult = NonNullable<Awaited<ReturnType<typeof getLearningStatus>>>
|
||||||
|
export type GetLearningStatusQueryError = void
|
||||||
|
|
||||||
|
|
||||||
|
export function useGetLearningStatus<TData = Awaited<ReturnType<typeof getLearningStatus>>, TError = void>(
|
||||||
|
options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getLearningStatus>>, TError, TData>> & Pick<
|
||||||
|
DefinedInitialDataOptions<
|
||||||
|
Awaited<ReturnType<typeof getLearningStatus>>,
|
||||||
|
TError,
|
||||||
|
Awaited<ReturnType<typeof getLearningStatus>>
|
||||||
|
> , 'initialData'
|
||||||
|
>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
, queryClient?: QueryClient
|
||||||
|
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||||
|
export function useGetLearningStatus<TData = Awaited<ReturnType<typeof getLearningStatus>>, TError = void>(
|
||||||
|
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getLearningStatus>>, TError, TData>> & Pick<
|
||||||
|
UndefinedInitialDataOptions<
|
||||||
|
Awaited<ReturnType<typeof getLearningStatus>>,
|
||||||
|
TError,
|
||||||
|
Awaited<ReturnType<typeof getLearningStatus>>
|
||||||
|
> , 'initialData'
|
||||||
|
>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
, queryClient?: QueryClient
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||||
|
export function useGetLearningStatus<TData = Awaited<ReturnType<typeof getLearningStatus>>, TError = void>(
|
||||||
|
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getLearningStatus>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
, queryClient?: QueryClient
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||||
|
/**
|
||||||
|
* @summary 협상카드 학습 현황
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function useGetLearningStatus<TData = Awaited<ReturnType<typeof getLearningStatus>>, TError = void>(
|
||||||
|
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getLearningStatus>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
, queryClient?: QueryClient
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
|
||||||
|
|
||||||
|
const queryOptions = getGetLearningStatusQueryOptions(options)
|
||||||
|
|
||||||
|
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||||
|
|
||||||
|
query.queryKey = queryOptions.queryKey ;
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary 앵커링 현황·조정 이력
|
||||||
|
*/
|
||||||
|
export const getAnchoringStatus = (
|
||||||
|
|
||||||
|
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
|
||||||
|
) => {
|
||||||
|
|
||||||
|
|
||||||
|
return customFetch<ResAnchoringStatus>(
|
||||||
|
{url: `/v1/learning/anchoring`, method: 'GET', signal
|
||||||
|
},
|
||||||
|
options);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getGetAnchoringStatusQueryKey = () => {
|
||||||
|
return [
|
||||||
|
`/v1/learning/anchoring`
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getGetAnchoringStatusQueryOptions = <TData = Awaited<ReturnType<typeof getAnchoringStatus>>, TError = void>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getAnchoringStatus>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
) => {
|
||||||
|
|
||||||
|
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||||
|
|
||||||
|
const queryKey = queryOptions?.queryKey ?? getGetAnchoringStatusQueryKey();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const queryFn: QueryFunction<Awaited<ReturnType<typeof getAnchoringStatus>>> = ({ signal }) => getAnchoringStatus(requestOptions, signal);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getAnchoringStatus>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetAnchoringStatusQueryResult = NonNullable<Awaited<ReturnType<typeof getAnchoringStatus>>>
|
||||||
|
export type GetAnchoringStatusQueryError = void
|
||||||
|
|
||||||
|
|
||||||
|
export function useGetAnchoringStatus<TData = Awaited<ReturnType<typeof getAnchoringStatus>>, TError = void>(
|
||||||
|
options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getAnchoringStatus>>, TError, TData>> & Pick<
|
||||||
|
DefinedInitialDataOptions<
|
||||||
|
Awaited<ReturnType<typeof getAnchoringStatus>>,
|
||||||
|
TError,
|
||||||
|
Awaited<ReturnType<typeof getAnchoringStatus>>
|
||||||
|
> , 'initialData'
|
||||||
|
>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
, queryClient?: QueryClient
|
||||||
|
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||||
|
export function useGetAnchoringStatus<TData = Awaited<ReturnType<typeof getAnchoringStatus>>, TError = void>(
|
||||||
|
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getAnchoringStatus>>, TError, TData>> & Pick<
|
||||||
|
UndefinedInitialDataOptions<
|
||||||
|
Awaited<ReturnType<typeof getAnchoringStatus>>,
|
||||||
|
TError,
|
||||||
|
Awaited<ReturnType<typeof getAnchoringStatus>>
|
||||||
|
> , 'initialData'
|
||||||
|
>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
, queryClient?: QueryClient
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||||
|
export function useGetAnchoringStatus<TData = Awaited<ReturnType<typeof getAnchoringStatus>>, TError = void>(
|
||||||
|
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getAnchoringStatus>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
, queryClient?: QueryClient
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||||
|
/**
|
||||||
|
* @summary 앵커링 현황·조정 이력
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function useGetAnchoringStatus<TData = Awaited<ReturnType<typeof getAnchoringStatus>>, TError = void>(
|
||||||
|
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getAnchoringStatus>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
, queryClient?: QueryClient
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
|
||||||
|
|
||||||
|
const queryOptions = getGetAnchoringStatusQueryOptions(options)
|
||||||
|
|
||||||
|
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||||
|
|
||||||
|
query.queryKey = queryOptions.queryKey ;
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
15
negodata/front/src/api/generated/model/anchoringCell.ts
Normal file
15
negodata/front/src/api/generated/model/anchoringCell.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
18
negodata/front/src/api/generated/model/cardPerformanceRow.ts
Normal file
18
negodata/front/src/api/generated/model/cardPerformanceRow.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
@ -5,6 +5,10 @@
|
|||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export * from './anchoringCell';
|
||||||
|
export * from './anchoringCellLastAdjustedAt';
|
||||||
|
export * from './anchoringHistoryRow';
|
||||||
|
export * from './anchoringHistoryRowCreatedAt';
|
||||||
export * from './bodyUploadItemImageV1ItemImagePost';
|
export * from './bodyUploadItemImageV1ItemImagePost';
|
||||||
export * from './cardData';
|
export * from './cardData';
|
||||||
export * from './cardDataCondition';
|
export * from './cardDataCondition';
|
||||||
@ -18,6 +22,8 @@ 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 './cardUsageType';
|
export * from './cardUsageType';
|
||||||
@ -77,6 +83,8 @@ export * from './itemDataVatYn';
|
|||||||
export * from './itemSupplyType';
|
export * from './itemSupplyType';
|
||||||
export * from './itemSupplyTypeSupplierItemId';
|
export * from './itemSupplyTypeSupplierItemId';
|
||||||
export * from './itemSupplyTypeSupplierName';
|
export * from './itemSupplyTypeSupplierName';
|
||||||
|
export * from './learningKpi';
|
||||||
|
export * from './learningKpiLastLearnedAt';
|
||||||
export * from './listCardsParams';
|
export * from './listCardsParams';
|
||||||
export * from './listItemsParams';
|
export * from './listItemsParams';
|
||||||
export * from './listNotificationsParams';
|
export * from './listNotificationsParams';
|
||||||
@ -274,6 +282,8 @@ export * from './reqUpdateSupplierManagerName';
|
|||||||
export * from './reqUpdateSupplierName';
|
export * from './reqUpdateSupplierName';
|
||||||
export * from './reqUpdateSupplierTotalRevenue';
|
export * from './reqUpdateSupplierTotalRevenue';
|
||||||
export * from './reqUpdateSupplyType';
|
export * from './reqUpdateSupplyType';
|
||||||
|
export * from './resAnchoringStatus';
|
||||||
|
export * from './resAnchoringStatusMsg';
|
||||||
export * from './resBulkMapByNames';
|
export * from './resBulkMapByNames';
|
||||||
export * from './resBulkMapByNamesMsg';
|
export * from './resBulkMapByNamesMsg';
|
||||||
export * from './resCard';
|
export * from './resCard';
|
||||||
@ -327,6 +337,8 @@ export * from './resItemListMsg';
|
|||||||
export * from './resItemMsg';
|
export * from './resItemMsg';
|
||||||
export * from './resItemSupplyTypeList';
|
export * from './resItemSupplyTypeList';
|
||||||
export * from './resItemSupplyTypeListMsg';
|
export * from './resItemSupplyTypeListMsg';
|
||||||
|
export * from './resLearningStatus';
|
||||||
|
export * from './resLearningStatusMsg';
|
||||||
export * from './resLogin';
|
export * from './resLogin';
|
||||||
export * from './resLoginMsg';
|
export * from './resLoginMsg';
|
||||||
export * from './resLowestPriceResult';
|
export * from './resLowestPriceResult';
|
||||||
|
|||||||
15
negodata/front/src/api/generated/model/learningKpi.ts
Normal file
15
negodata/front/src/api/generated/model/learningKpi.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
18
negodata/front/src/api/generated/model/resAnchoringStatus.ts
Normal file
18
negodata/front/src/api/generated/model/resAnchoringStatus.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
17
negodata/front/src/api/generated/model/resLearningStatus.ts
Normal file
17
negodata/front/src/api/generated/model/resLearningStatus.ts
Normal file
@ -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[];
|
||||||
|
}
|
||||||
@ -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;
|
||||||
@ -5,6 +5,7 @@ import {hasSeenOnboarding} from '../features/onboarding/storage';
|
|||||||
import AuthenticatedLayout from '@/components/layout/AuthenticatedLayout';
|
import AuthenticatedLayout from '@/components/layout/AuthenticatedLayout';
|
||||||
import LoginPage from '../pages/login';
|
import LoginPage from '../pages/login';
|
||||||
import DashboardPage from '../pages/dashboard';
|
import DashboardPage from '../pages/dashboard';
|
||||||
|
import LearningPage from '../pages/learning';
|
||||||
import StatisticsPage from '../pages/statistics';
|
import StatisticsPage from '../pages/statistics';
|
||||||
import ForbiddenPage from '../pages/forbidden';
|
import ForbiddenPage from '../pages/forbidden';
|
||||||
import DevDesignPage from '../pages/dev-design';
|
import DevDesignPage from '../pages/dev-design';
|
||||||
@ -69,6 +70,7 @@ 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: 'products', Component: ProductsPage},
|
{path: 'products', Component: ProductsPage},
|
||||||
{path: 'partners', Component: PartnersPage},
|
{path: 'partners', Component: PartnersPage},
|
||||||
{path: 'quotation', Component: QuotationPage},
|
{path: 'quotation', Component: QuotationPage},
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {showToast} from '@/lib/notify';
|
|||||||
const PAGE_TO_PATH: Record<PageType, string> = {
|
const PAGE_TO_PATH: Record<PageType, string> = {
|
||||||
DASHBOARD: '/dashboard',
|
DASHBOARD: '/dashboard',
|
||||||
STATISTICS: '/statistics',
|
STATISTICS: '/statistics',
|
||||||
|
LEARNING: '/learning',
|
||||||
PRODUCTS: '/products',
|
PRODUCTS: '/products',
|
||||||
PARTNERS: '/partners',
|
PARTNERS: '/partners',
|
||||||
QUOTATION: '/quotation',
|
QUOTATION: '/quotation',
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
Brain,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
Users,
|
Users,
|
||||||
UserCog,
|
UserCog,
|
||||||
@ -56,6 +57,7 @@ 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' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -96,6 +98,7 @@ const canSee = (item: MenuItem, role?: string): boolean => {
|
|||||||
const pageLabelMap: Record<PageType, string> = {
|
const pageLabelMap: Record<PageType, string> = {
|
||||||
DASHBOARD: '대시보드',
|
DASHBOARD: '대시보드',
|
||||||
STATISTICS: '통계',
|
STATISTICS: '통계',
|
||||||
|
LEARNING: '협상 학습',
|
||||||
PRODUCTS: '상품관리',
|
PRODUCTS: '상품관리',
|
||||||
PARTNERS: '협력사관리',
|
PARTNERS: '협력사관리',
|
||||||
QUOTATION: '견적관리',
|
QUOTATION: '견적관리',
|
||||||
|
|||||||
203
negodata/front/src/features/learning/LearningView.tsx
Normal file
203
negodata/front/src/features/learning/LearningView.tsx
Normal file
@ -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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
|
<StatTile label="학습한 협상" value={`${data.learnedSessions}건`} icon={Bot} tone="purple" />
|
||||||
|
<StatTile label="학습 기록" value={`${data.records}건`} icon={Layers} tone="blue" />
|
||||||
|
<StatTile label="타결 협상" value={`${data.settledSessions}건`} icon={CheckCircle2} tone="emerald" />
|
||||||
|
<StatTile label="타결 비율" value={pct(data.settleRate)} icon={Ruler} tone="amber" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Panel
|
||||||
|
title="카드별 학습 성적"
|
||||||
|
subtitle="AI가 협상 결과로 매긴 점수입니다. 점수가 높을수록 그 카드를 쓴 협상이 잘 풀렸다는 뜻입니다."
|
||||||
|
right={
|
||||||
|
data.lastLearnedAt ? (
|
||||||
|
<Typography as="span" variant="small" className="text-[11px] text-muted-foreground">
|
||||||
|
최근 학습 {fmtDateTime(data.lastLearnedAt)}
|
||||||
|
</Typography>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{best.length === 0 ? (
|
||||||
|
<EmptyRow text="아직 학습된 협상이 없습니다. 협상이 진행되면 카드별 성적이 쌓입니다." />
|
||||||
|
) : (
|
||||||
|
<Table className="text-xs">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-24">카드번호</TableHead>
|
||||||
|
<TableHead>카드이름</TableHead>
|
||||||
|
<TableHead className="w-20 text-center">종류</TableHead>
|
||||||
|
<TableHead className="w-24 text-right">사용 협상</TableHead>
|
||||||
|
<TableHead className="w-24 text-right">사용 횟수</TableHead>
|
||||||
|
<TableHead className="w-24 text-right">타결 비율</TableHead>
|
||||||
|
<TableHead className="w-28 text-right">평균 점수</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{best.map((c) => (
|
||||||
|
<TableRow key={c.number}>
|
||||||
|
<TableCell className="font-mono">{c.number}</TableCell>
|
||||||
|
<TableCell className="font-medium">{c.name}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<TypeBadge isWild={c.isWild} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">{c.usedSessions}</TableCell>
|
||||||
|
<TableCell className="text-right">{c.uses}</TableCell>
|
||||||
|
<TableCell className="text-right">{pct(c.settleRate)}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<RewardBar value={c.avgReward} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-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={`${adjusted}개`} icon={CheckCircle2} tone="emerald" />
|
||||||
|
<StatTile label="평균 인하폭" value={permille(avg)} icon={Ruler} tone="blue" />
|
||||||
|
<StatTile label="조정 이력" value={`${data.history.length}건`} icon={Bot} tone="amber" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 xl:grid-cols-2">
|
||||||
|
<Panel title="현재 앵커링 값" subtitle="협력사 유형과 가격대 구간마다 따로 관리합니다.">
|
||||||
|
{data.cells.length === 0 ? (
|
||||||
|
<EmptyRow text="아직 설정된 구간이 없습니다. 협상이 쌓이면 구간별로 값이 만들어집니다." />
|
||||||
|
) : (
|
||||||
|
<Table className="text-xs">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-24">협력사 유형</TableHead>
|
||||||
|
<TableHead className="w-24 text-right">가격대</TableHead>
|
||||||
|
<TableHead className="w-24 text-right">인하폭</TableHead>
|
||||||
|
<TableHead className="text-right">마지막 조정</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.cells.map((c) => (
|
||||||
|
<TableRow key={`${c.supplierType}-${c.priceRange}`}>
|
||||||
|
<TableCell>{c.supplierType}</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">{c.priceRange}</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">{permille(c.value)}</TableCell>
|
||||||
|
<TableCell className="text-right text-muted-foreground">
|
||||||
|
{c.adjustedAt ? fmtDateTime(c.adjustedAt) : '조정 전'}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel title="조정 이력" subtitle="표본이 충분히 쌓인 구간만 성공률을 보고 값을 조정합니다.">
|
||||||
|
{data.history.length === 0 ? (
|
||||||
|
<EmptyRow text="아직 조정된 이력이 없습니다." />
|
||||||
|
) : (
|
||||||
|
<Table className="text-xs">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-24">협력사 유형</TableHead>
|
||||||
|
<TableHead className="w-16 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="text-right">시각</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.history.map((h, i) => (
|
||||||
|
<TableRow key={`${h.supplierType}-${h.priceRange}-${h.at ?? i}`}>
|
||||||
|
<TableCell>{h.supplierType}</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">{h.priceRange}</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">
|
||||||
|
{permille(h.before)} → <span className="font-semibold">{permille(h.after)}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">{h.samples}</TableCell>
|
||||||
|
<TableCell className="text-right">{pct(h.successRate)}</TableCell>
|
||||||
|
<TableCell className="text-right text-muted-foreground">
|
||||||
|
{h.at ? fmtDateTime(h.at) : '-'}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 평균 점수는 −1~1 범위라 숫자만으론 감이 안 온다 — 0 기준 좌우 막대로 방향과 크기를 같이 보여준다.
|
||||||
|
function RewardBar({ value }: { value: number }) {
|
||||||
|
const width = Math.min(Math.abs(value), 1) * 50;
|
||||||
|
const positive = value >= 0;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<div className="relative h-2 w-24 rounded bg-muted">
|
||||||
|
<div
|
||||||
|
className={`absolute top-0 h-2 ${positive ? 'bg-emerald-500' : 'bg-rose-500'}`}
|
||||||
|
style={{ left: positive ? '50%' : `${50 - width}%`, width: `${width}%` }}
|
||||||
|
/>
|
||||||
|
<div className="absolute left-1/2 top-0 h-2 w-px bg-border" />
|
||||||
|
</div>
|
||||||
|
<span className="w-12 text-right font-mono">{value.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TypeBadge({ isWild }: { isWild: boolean }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-semibold ${
|
||||||
|
isWild
|
||||||
|
? 'border-amber-300/45 bg-amber-50 text-amber-700 dark:bg-amber-950/20 dark:text-amber-400'
|
||||||
|
: 'border-primary/25 bg-primary/5 text-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isWild ? '와일드' : '협상'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyRow({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<Typography as="div" variant="small" className="py-10 text-center text-xs text-muted-foreground">
|
||||||
|
{text}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pct(v: number): string {
|
||||||
|
return `${(v * 100).toFixed(0)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 앵커링 값은 천분율(‰) — 목표가에서 이만큼 낮춘 가격이 앵커가가 된다.
|
||||||
|
function permille(v: number): string {
|
||||||
|
return `${v.toFixed(1)}‰`;
|
||||||
|
}
|
||||||
51
negodata/front/src/features/learning/api.ts
Normal file
51
negodata/front/src/features/learning/api.ts
Normal file
@ -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,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
42
negodata/front/src/features/learning/types.ts
Normal file
42
negodata/front/src/features/learning/types.ts
Normal file
@ -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[];
|
||||||
|
};
|
||||||
63
negodata/front/src/pages/learning.tsx
Normal file
63
negodata/front/src/pages/learning.tsx
Normal file
@ -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<Tab>('cards');
|
||||||
|
const learning = useLearningData();
|
||||||
|
const anchoring = useAnchoringData();
|
||||||
|
const isLoading = tab === 'cards' ? learning.isLoading : anchoring.isLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<Typography variant="h2">협상 학습</Typography>
|
||||||
|
<div className="flex gap-1 rounded-lg border border-border bg-card p-1">
|
||||||
|
<TabButton active={tab === 'cards'} onClick={() => setTab('cards')}>
|
||||||
|
협상카드
|
||||||
|
</TabButton>
|
||||||
|
<TabButton active={tab === 'anchoring'} onClick={() => setTab('anchoring')}>
|
||||||
|
앵커링
|
||||||
|
</TabButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Typography variant="muted">학습 현황을 불러오는 중…</Typography>
|
||||||
|
) : tab === 'cards' ? (
|
||||||
|
<CardLearningView data={learning.data} />
|
||||||
|
) : (
|
||||||
|
<AnchoringStatusView data={anchoring.data} />
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabButton({
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={`rounded-md px-3 py-1.5 text-xs font-semibold transition-colors ${
|
||||||
|
active ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -45,4 +45,4 @@ export interface CardTactic {
|
|||||||
offer_variable?: string; // 제시 가격 변수 명시 지정. 없으면 멘트 파싱(마지막 가격 변수) — 멘트에 있는 변수만 허용
|
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';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user