[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:
Mina Choi 2026-08-10 13:24:32 +09:00
parent 37fa65707f
commit e816bfbba4
28 changed files with 1077 additions and 1 deletions

View 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 [])

View File

@ -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)

View 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))

View 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

View 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

View 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;
}

View 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;
}

View File

@ -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;

View 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 { 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;
}

View File

@ -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;

View 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;
}

View File

@ -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;

View File

@ -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';

View 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;
}

View File

@ -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;

View 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;
}

View File

@ -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;

View 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[];
}

View File

@ -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;

View File

@ -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},

View File

@ -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',

View File

@ -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: '견적관리',

View 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)}`;
}

View 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,
})),
},
};
}

View 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[];
};

View 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>
);
}

View File

@ -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';