[feat] negodata: 통계 페이지(성과 분석) 추가 — 파생 집계 API + recharts 차트

- 백엔드 GET /v1/statistics/summary: 회사/내견적 스코프, 최근 6개월 파생 집계(저장 X, DDL 0)
- 지표: 총절감/절감률/낙찰률/앵커도달률/마감수/재견적라운드 + 월별추이·마감결과분해·참여율·유형별(협상/견적)·카테고리·카드빈도
- 프론트 recharts+shadcn Chart, 사이드바 통계 nav, /statistics

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-07-07 16:18:33 +09:00
parent dc0e78ec73
commit c32ee300e6
39 changed files with 2140 additions and 1 deletions

View File

@ -0,0 +1,214 @@
from abc import ABC, abstractmethod
from typing import Tuple
from sqlalchemy import select, func, and_, case
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import quotations, sessions, items, chats, users
from common.enums import ErrorType, QuotationStatus, CloseReason, SessionStatus
from common.logger import LOG
# 통계 유니버스 = 현재 마감사유 5코드로 마감된 견적. 레거시 REGEN_*(2·3·4) 은 제외해
# KPI(낙찰률·마감수)와 유형별/결과분해의 분모를 일치시킨다(프로덕션엔 레거시 없어 전체 마감과 동일).
CURRENT_CLOSE_REASONS = [
CloseReason.AWARDED.value,
CloseReason.OPEN_PRICE.value,
CloseReason.OPEN_EQUAL.value,
CloseReason.OPEN_NOSHOW.value,
CloseReason.OPEN_REJECT.value,
]
# 통계 집계 CRUD. 대시보드와 동일하게 회사 스코프(작성자 user_id→users.company_id)로 건다.
# owner(user_id) 가 주어지면 '내가 만든 견적'으로 더 좁힌다. quotations 엔 company_id 컬럼이 없어 서브쿼리로.
def _company_scope(company_id, owner) -> list:
conds = [
quotations.deleted == False, # noqa: E712
quotations.user_id.in_(select(users.user_id).where(users.company_id == company_id)),
]
if owner is not None:
conds.append(quotations.user_id == owner)
return conds
class IStatisticsCRUD(ABC):
@abstractmethod
async def winning_sessions(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
pass
@abstractmethod
async def outcome_counts(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
pass
@abstractmethod
async def type_counts(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
pass
@abstractmethod
async def participation_counts(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
pass
@abstractmethod
async def regen_avg_round(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, float]:
pass
@abstractmethod
async def card_usage(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
pass
class StatisticsCRUD(IStatisticsCRUD):
async def winning_sessions(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
# 낙찰 마감 견적의 '낙찰 세션'(supplier_id=preferred_sp_id) 행 — 절감/추이/유형/카테고리/앵커도달률의 단일 원천.
# 파생: 저장 안 하고 조회 때 조인. category 는 items LEFT JOIN(자유텍스트·NULL 허용).
try:
stmt = (
select(
quotations.updated_at,
quotations.type,
items.category,
sessions.target_price,
sessions.bid_price,
sessions.anchoring_price,
)
.select_from(quotations)
.join(
sessions,
and_(
sessions.quotation_id == quotations.qt_id,
sessions.supplier_id == quotations.preferred_sp_id,
sessions.bid_price.isnot(None),
sessions.deleted == False, # noqa: E712
),
)
.join(items, items.item_id == sessions.item_id, isouter=True)
.where(
and_(
*_company_scope(company_id, owner),
quotations.status == QuotationStatus.CLOSED.value,
quotations.close_reason == CloseReason.AWARDED.value,
quotations.updated_at >= since,
)
)
)
err, rows = await DB_SESSION_MNG.execute(cdb, stmt)
return (err, list(rows) if err == ErrorType.SUCCESS else [])
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, []
async def outcome_counts(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
# 마감 결과 분해: close_reason 별 건수. 낙찰률·마감건수도 여기서 파생.
try:
stmt = (
select(quotations.close_reason, func.count())
.where(
and_(
*_company_scope(company_id, owner),
quotations.status == QuotationStatus.CLOSED.value,
quotations.close_reason.in_(CURRENT_CLOSE_REASONS),
quotations.updated_at >= since,
)
)
.group_by(quotations.close_reason)
)
err, rows = await DB_SESSION_MNG.execute(cdb, stmt)
return (err, list(rows) if err == ErrorType.SUCCESS else [])
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, []
async def type_counts(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
# 유형별(협상/경매) 마감 건수 + 낙찰 건수 → 유형별 낙찰률.
try:
awarded = func.sum(case((quotations.close_reason == CloseReason.AWARDED.value, 1), else_=0))
stmt = (
select(quotations.type, func.count(), awarded)
.where(
and_(
*_company_scope(company_id, owner),
quotations.status == QuotationStatus.CLOSED.value,
quotations.close_reason.in_(CURRENT_CLOSE_REASONS),
quotations.updated_at >= since,
)
)
.group_by(quotations.type)
)
err, rows = await DB_SESSION_MNG.execute(cdb, stmt)
return (err, list(rows) if err == ErrorType.SUCCESS else [])
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, []
async def participation_counts(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
# 협력사 참여: 회사 견적(창 내 생성)의 세션을 status 별 집계(응찰/미응찰/거부).
try:
conds = [
sessions.deleted == False, # noqa: E712
quotations.deleted == False, # noqa: E712
quotations.created_at >= since,
quotations.user_id.in_(select(users.user_id).where(users.company_id == company_id)),
]
if owner is not None:
conds.append(quotations.user_id == owner)
stmt = (
select(sessions.status, func.count())
.select_from(sessions)
.join(quotations, quotations.qt_id == sessions.quotation_id)
.where(and_(*conds))
.group_by(sessions.status)
)
err, rows = await DB_SESSION_MNG.execute(cdb, stmt)
return (err, list(rows) if err == ErrorType.SUCCESS else [])
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, []
async def regen_avg_round(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, float]:
# 평균 재견적 라운드. TODO: 체인키 없어 avg(round) 단순버전 — 체인당 최대 라운드 정의는 root_qt_id 도입 후.
try:
stmt = select(func.avg(quotations.round)).where(
and_(
*_company_scope(company_id, owner),
quotations.status == QuotationStatus.CLOSED.value,
quotations.close_reason.in_(CURRENT_CLOSE_REASONS),
quotations.updated_at >= since,
)
)
err, rows = await DB_SESSION_MNG.execute(cdb, stmt)
if err != ErrorType.SUCCESS:
return err, 0.0
# 단일컬럼 집계는 execute 가 스칼라 리스트를 반환한다(대시보드 _count 와 동일). rows[0] 이 곧 avg 값.
val = rows[0] if rows else None
return ErrorType.SUCCESS, float(val) if val is not None else 0.0
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0.0
async def card_usage(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
# 카드 유형별 사용 빈도: card_used_yn=True 채팅을 card_type 별 집계(협상형 견적에서만 채팅 생성).
try:
conds = [
chats.deleted == False, # noqa: E712
chats.card_used_yn.is_(True),
quotations.deleted == False, # noqa: E712
quotations.created_at >= since,
quotations.user_id.in_(select(users.user_id).where(users.company_id == company_id)),
]
if owner is not None:
conds.append(quotations.user_id == owner)
stmt = (
select(chats.card_type, func.count())
.select_from(chats)
.join(sessions, sessions.session_id == chats.session_id)
.join(quotations, quotations.qt_id == sessions.quotation_id)
.where(and_(*conds))
.group_by(chats.card_type)
)
err, rows = await DB_SESSION_MNG.execute(cdb, stmt)
return (err, list(rows) if err == ErrorType.SUCCESS else [])
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, []

View File

@ -19,6 +19,7 @@ import router.v1.card.card
import router.v1.quotation.quotation
import router.v1.quotation_setting.quotation_setting
import router.v1.dashboard.dashboard
import router.v1.statistics.statistics
import router.v1.notification.notification
API_SERVER_START_TIME = GTime.UTCStr()
@ -74,4 +75,5 @@ app.include_router(router.v1.card.card.router)
app.include_router(router.v1.quotation.quotation.router)
app.include_router(router.v1.quotation_setting.quotation_setting.router)
app.include_router(router.v1.dashboard.dashboard.router)
app.include_router(router.v1.statistics.statistics.router)
app.include_router(router.v1.notification.notification.router)

View File

@ -0,0 +1,72 @@
from pydantic import Field
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
class StatisticsProtocol(WebPacketProtocol):
pass
class StatKpi(WebPacketProtocol):
total_savings: int = 0 # 총 절감액(목표가 대비, 낙찰세션 target−bid 합)
savings_rate: float = 0.0 # 평균 절감률 = 절감합/목표합
award_rate: float = 0.0 # 낙찰률 = 낙찰 마감 / 전체 마감
anchor_reach_rate: float = 0.0 # 평균 앵커 도달률 = (목표−투찰)/(목표−앵커)
savings_delta_mom: int = 0 # 전월 대비 절감액 증감
closed_count: int = 0 # 마감 견적 수(창)
regen_avg_round: float = 0.0 # 평균 재견적 라운드
class StatMonthPoint(WebPacketProtocol):
month: str # 'YYYY-MM'
savings: int = 0
rate: float = 0.0
class StatOutcome(WebPacketProtocol):
awarded: int = 0 # CloseReason 1
open_price: int = 0 # 5 가격 미달
open_equal: int = 0 # 6 동가
open_noshow: int = 0 # 7 미응찰
open_reject: int = 0 # 8 거부
class StatParticipation(WebPacketProtocol):
bid: int = 0 # 응찰(SessionStatus DONE)
no_participate: int = 0 # 미응찰(NOT_PARTICIPATED)
rejected: int = 0 # 거부(REJECTED)
class StatTypeRow(WebPacketProtocol):
label: str
award_rate: float = 0.0
avg_savings: int = 0
count: int = 0
class StatCategory(WebPacketProtocol):
category: str
savings: int = 0
count: int = 0
class StatCardUsage(WebPacketProtocol):
type: str # 'nego' | 'wild'
label: str
uses: int = 0
avg_drop: int = 0 # 사용 직후 상대 제시가 평균 하락. TODO: v1은 0(유형별 빈도만) — chats seq 델타 계산은 다음 단계.
class StatScope(WebPacketProtocol):
kpi: StatKpi = Field(default_factory=StatKpi)
trend: list[StatMonthPoint] = []
outcome: StatOutcome = Field(default_factory=StatOutcome)
participation: StatParticipation = Field(default_factory=StatParticipation)
type_split: list[StatTypeRow] = []
categories: list[StatCategory] = []
cards: list[StatCardUsage] = []
class Res_StatisticsSummary(Res_WebPacketProtocol):
company: StatScope = Field(default_factory=StatScope) # 회사 전체(company_id 스코프)
mine: StatScope = Field(default_factory=StatScope) # 내가 만든 견적(user_id 추가 스코프)

View File

@ -0,0 +1,14 @@
from fastapi import APIRouter, Depends
from common.models.gmodel import UserInfo
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
from services.statistics_service import StatisticsService
from .protocol import Res_StatisticsSummary
# 라우터(컨트롤러). 인증(Depends(IsValidAccessToken))의 UserInfo 로 company/user 스코프 집계를 한 번에 내린다.
router = APIRouter(prefix="/v1/statistics", tags=["Statistics"], responses={404: {"description": "Not found"}})
@router.get(path="/summary", response_model=Res_StatisticsSummary, summary="통계 요약(회사 전체 + 내 견적)")
async def get_statistics_summary(service: StatisticsService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.get_summary(user_info.company_id, user_info.user_id))

View File

@ -0,0 +1,193 @@
import uuid
from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import quotations
from common.enums import DBWRType, ErrorType, QuotationType, SessionStatus, CardType, CloseReason
from common.utils.gtime import GTime
from crud.statistics_crud import StatisticsCRUD, IStatisticsCRUD
from router.v1.statistics.protocol import (
Res_StatisticsSummary,
StatScope,
StatKpi,
StatMonthPoint,
StatOutcome,
StatParticipation,
StatTypeRow,
StatCategory,
StatCardUsage,
)
WINDOW_MONTHS = 6 # 최근 6개월(당월 포함) 창
class StatisticsService:
"""통계(성과 분석) 집계. 회사 전체(company)와 내 견적(mine) 두 스코프를 한 응답으로 내린다.
전부 파생(저장 안 함) — 조회 때 sessions/items/chats 조인 집계한다. 읽기 전용.
절감 원천은 '낙찰 세션'(preferred_sp_id) 이며, 여기서 총절감·추이·유형·카테고리·앵커도달률을 모두 파생한다.
"""
def __init__(self, stat_crud: IStatisticsCRUD = Depends(StatisticsCRUD)):
self.stat_crud = stat_crud
async def get_summary(self, company_id: str, user_id: str) -> Res_StatisticsSummary:
res = Res_StatisticsSummary()
company_uuid = uuid.UUID(company_id)
user_uuid = uuid.UUID(user_id)
labels, window_start = self._window(GTime.UTC())
res.company = await self._scope(company_uuid, None, labels, window_start)
res.mine = await self._scope(company_uuid, user_uuid, labels, window_start)
return res
# ── 스코프 집계 ─────────────────────────────────────────────
async def _scope(self, company_uuid, owner_uuid, labels, since) -> StatScope:
scope = StatScope()
win_rows = await self._read(lambda s: self.stat_crud.winning_sessions(s, company_uuid, owner_uuid, since))
outcome_rows = await self._read(lambda s: self.stat_crud.outcome_counts(s, company_uuid, owner_uuid, since))
type_rows = await self._read(lambda s: self.stat_crud.type_counts(s, company_uuid, owner_uuid, since))
part_rows = await self._read(lambda s: self.stat_crud.participation_counts(s, company_uuid, owner_uuid, since))
regen = await self._read_scalar(lambda s: self.stat_crud.regen_avg_round(s, company_uuid, owner_uuid, since))
card_rows = await self._read(lambda s: self.stat_crud.card_usage(s, company_uuid, owner_uuid, since))
scope.trend = self._trend(win_rows, labels)
scope.categories = self._categories(win_rows)
scope.outcome = self._outcome(outcome_rows)
scope.participation = self._participation(part_rows)
scope.type_split = self._type_split(type_rows, win_rows)
scope.cards = self._cards(card_rows)
scope.kpi = self._kpi(win_rows, scope.trend, scope.outcome, regen)
return scope
# ── 파생 계산 ───────────────────────────────────────────────
def _kpi(self, win_rows, trend, outcome, regen) -> StatKpi:
k = StatKpi()
total_saving = sum(int(r.target_price) - int(r.bid_price) for r in win_rows)
total_target = sum(int(r.target_price) for r in win_rows)
k.total_savings = total_saving
k.savings_rate = (total_saving / total_target) if total_target else 0.0
k.anchor_reach_rate = self._anchor_reach(win_rows)
closed = outcome.awarded + outcome.open_price + outcome.open_equal + outcome.open_noshow + outcome.open_reject
k.closed_count = closed
k.award_rate = (outcome.awarded / closed) if closed else 0.0
k.regen_avg_round = round(regen, 2)
# 전월 대비: 마지막 두 달 절감액 차(창에 2개월 미만이면 0).
k.savings_delta_mom = (trend[-1].savings - trend[-2].savings) if len(trend) >= 2 else 0
return k
def _anchor_reach(self, win_rows) -> float:
# (목표−투찰)/(목표−앵커), 앵커 있고 목표>앵커인 세션만 평균.
# 세션별로 [0,100%] 클램프 후 평균 — '도달률'이라 앵커 도달=100% 상한(앵커 뚫어도 100%로).
# (앵커 gap 이 작으면 원비율이 100% 훌쩍 넘어 평균이 왜곡되므로 캡한다.)
vals = []
for r in win_rows:
if r.anchoring_price is None:
continue
target, bid, anchor = int(r.target_price), int(r.bid_price), int(r.anchoring_price)
span = target - anchor
if span > 0:
reach = (target - bid) / span
vals.append(min(1.0, max(0.0, reach)))
return (sum(vals) / len(vals)) if vals else 0.0
def _trend(self, win_rows, labels) -> list:
bucket = {m: {"savings": 0, "target": 0} for m in labels}
for r in win_rows:
m = f"{r.updated_at.year:04d}-{r.updated_at.month:02d}"
if m in bucket:
bucket[m]["savings"] += int(r.target_price) - int(r.bid_price)
bucket[m]["target"] += int(r.target_price)
out = []
for m in labels:
b = bucket[m]
rate = (b["savings"] / b["target"]) if b["target"] else 0.0
out.append(StatMonthPoint(month=m, savings=b["savings"], rate=rate))
return out
def _categories(self, win_rows) -> list:
# TODO: items.category 자유텍스트 그룹 — 표기 흔들리면 지저분. 카테고리 정규화(코드/테이블) 후 개선.
agg: dict = {}
for r in win_rows:
key = r.category or "미분류"
a = agg.setdefault(key, {"savings": 0, "count": 0})
a["savings"] += int(r.target_price) - int(r.bid_price)
a["count"] += 1
rows = [StatCategory(category=k, savings=v["savings"], count=v["count"]) for k, v in agg.items()]
rows.sort(key=lambda x: x.savings, reverse=True)
return rows
def _outcome(self, outcome_rows) -> StatOutcome:
by = {int(cr): int(n) for cr, n in outcome_rows if cr is not None}
return StatOutcome(
awarded=by.get(CloseReason.AWARDED.value, 0),
open_price=by.get(CloseReason.OPEN_PRICE.value, 0),
open_equal=by.get(CloseReason.OPEN_EQUAL.value, 0),
open_noshow=by.get(CloseReason.OPEN_NOSHOW.value, 0),
open_reject=by.get(CloseReason.OPEN_REJECT.value, 0),
)
def _participation(self, part_rows) -> StatParticipation:
by = {int(st): int(n) for st, n in part_rows if st is not None}
return StatParticipation(
bid=by.get(SessionStatus.DONE.value, 0),
no_participate=by.get(SessionStatus.NOT_PARTICIPATED.value, 0),
rejected=by.get(SessionStatus.REJECTED.value, 0),
)
def _type_split(self, type_rows, win_rows) -> list:
# 4개 코드(협상 1·3 / 견적 2·4=1:N)를 2그룹으로 묶는다. 낙찰률·건수=type_counts, 평균절감=낙찰세션.
grp = {"nego": {"count": 0, "awarded": 0}, "auction": {"count": 0, "awarded": 0}}
for t, cnt, awarded in type_rows:
g = "auction" if QuotationType.is_auction(int(t)) else "nego"
grp[g]["count"] += int(cnt or 0)
grp[g]["awarded"] += int(awarded or 0)
sav = {"nego": [], "auction": []}
for r in win_rows:
g = "auction" if QuotationType.is_auction(int(r.type)) else "nego"
sav[g].append(int(r.target_price) - int(r.bid_price))
out = []
for g, label in (("nego", "협상 (1:1)"), ("auction", "견적 (1:N)")):
cnt = grp[g]["count"]
rate = (grp[g]["awarded"] / cnt) if cnt else 0.0
avg = int(sum(sav[g]) / len(sav[g])) if sav[g] else 0
out.append(StatTypeRow(label=label, award_rate=rate, avg_savings=avg, count=cnt))
return out
def _cards(self, card_rows) -> list:
by = {int(ct): int(n) for ct, n in card_rows if ct is not None}
return [
StatCardUsage(type="nego", label="협상카드", uses=by.get(CardType.NEGO.value, 0), avg_drop=0),
StatCardUsage(type="wild", label="와일드카드", uses=by.get(CardType.WILD.value, 0), avg_drop=0),
]
# ── 창(최근 6개월) ─────────────────────────────────────────
def _window(self, now):
yy, mm = now.year, now.month
mm -= (WINDOW_MONTHS - 1)
while mm <= 0:
mm += 12
yy -= 1
window_start = now.replace(year=yy, month=mm, day=1, hour=0, minute=0, second=0, microsecond=0)
labels, ly, lm = [], yy, mm
for _ in range(WINDOW_MONTHS):
labels.append(f"{ly:04d}-{lm:02d}")
lm += 1
if lm > 12:
lm = 1
ly += 1
return labels, window_start
# ── DB 실행 헬퍼 ───────────────────────────────────────────
async def _read(self, fn) -> list:
err, rows = await DB_SESSION_MNG.execute_lambda(quotations.DBType(), DBWRType.DB_READ.value, fn)
return rows if err == ErrorType.SUCCESS else []
async def _read_scalar(self, fn) -> float:
err, val = await DB_SESSION_MNG.execute_lambda(quotations.DBType(), DBWRType.DB_READ.value, fn)
return val if err == ErrorType.SUCCESS else 0.0

View File

@ -25,6 +25,7 @@
"react-dom": "^19.0.1",
"react-hook-form": "^7.79.0",
"react-router": "^7.17.0",
"recharts": "^3.8.0",
"shadcn": "^4.11.0",
"slate": "^0.118.1",
"slate-dom": "^0.119.0",
@ -2431,6 +2432,42 @@
}
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.11",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.11.tgz",
"integrity": "sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
@ -2829,6 +2866,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
@ -3639,6 +3682,69 @@
"@types/node": "*"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/es-aggregate-error": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/es-aggregate-error/-/es-aggregate-error-1.0.6.tgz",
@ -3808,6 +3914,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/validate-npm-package-name": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
@ -4699,6 +4811,127 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
@ -4779,6 +5012,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/dedent": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
@ -5249,6 +5488,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es-toolkit": {
"version": "1.49.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz",
"integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/es6-promise": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz",
@ -5354,6 +5603,12 @@
"node": ">=6"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/eventsource": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
@ -6319,6 +6574,15 @@
"node": ">= 0.4"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
@ -8836,6 +9100,36 @@
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-is": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz",
"integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
"license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
@ -8979,6 +9273,67 @@
"node": ">= 4"
}
},
"node_modules/recharts": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
"integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts/node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/recharts/node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@ -11043,6 +11398,28 @@
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",

View File

@ -29,6 +29,7 @@
"react-dom": "^19.0.1",
"react-hook-form": "^7.79.0",
"react-router": "^7.17.0",
"recharts": "^3.8.0",
"shadcn": "^4.11.0",
"slate": "^0.118.1",
"slate-dom": "^0.119.0",

View File

@ -9,6 +9,7 @@ export * from './bodyUploadItemImageV1ItemImagePost';
export * from './cardData';
export * from './cardDataCondition';
export * from './cardDataCreatedAt';
export * from './cardDataCreatorName';
export * from './cardDataEditScript';
export * from './cardDataMemo';
export * from './cardDataName';
@ -53,6 +54,7 @@ export * from './itemData';
export * from './itemDataCategory';
export * from './itemDataCode';
export * from './itemDataCreatedAt';
export * from './itemDataCreatorName';
export * from './itemDataDeliveryFeeYn';
export * from './itemDataDeliveryType';
export * from './itemDataImageUrl';
@ -120,6 +122,7 @@ export * from './quotationSettingDataUpdatedAt';
export * from './quotationSettingDataUserId';
export * from './quotationStatus';
export * from './quotationType';
export * from './reqAwardQuotation';
export * from './reqBulkMapByNames';
export * from './reqCheckCodes';
export * from './reqCreateCard';
@ -319,6 +322,8 @@ export * from './resRefreshTokenMsg';
export * from './resSessionChat';
export * from './resSessionChatMsg';
export * from './resSessionChatSessionId';
export * from './resStatisticsSummary';
export * from './resStatisticsSummaryMsg';
export * from './resSupplier';
export * from './resSupplierItem';
export * from './resSupplierItemList';
@ -348,9 +353,18 @@ export * from './sessionDataRejectDeliveryType';
export * from './sessionDataRejectPrice';
export * from './sessionDataRejectReason';
export * from './sessionStatus';
export * from './statCardUsage';
export * from './statCategory';
export * from './statKpi';
export * from './statMonthPoint';
export * from './statOutcome';
export * from './statParticipation';
export * from './statScope';
export * from './statTypeRow';
export * from './supplierData';
export * from './supplierDataCode';
export * from './supplierDataCreatedAt';
export * from './supplierDataCreatorName';
export * from './supplierDataManagerContactNumber';
export * from './supplierDataManagerEmail';
export * from './supplierDataManagerName';

View File

@ -0,0 +1,16 @@
/**
* 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 { ResStatisticsSummaryMsg } from './resStatisticsSummaryMsg';
import type { StatScope } from './statScope';
export interface ResStatisticsSummary {
result?: ErrorInfo;
msg?: ResStatisticsSummaryMsg;
company?: StatScope;
mine?: StatScope;
}

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 ResStatisticsSummaryMsg = string | null;

View File

@ -0,0 +1,13 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export interface StatCardUsage {
type: string;
label: string;
uses?: number;
avg_drop?: number;
}

View File

@ -0,0 +1,12 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export interface StatCategory {
category: string;
savings?: number;
count?: number;
}

View File

@ -0,0 +1,16 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export interface StatKpi {
total_savings?: number;
savings_rate?: number;
award_rate?: number;
anchor_reach_rate?: number;
savings_delta_mom?: number;
closed_count?: number;
regen_avg_round?: number;
}

View File

@ -0,0 +1,12 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export interface StatMonthPoint {
month: string;
savings?: number;
rate?: number;
}

View File

@ -0,0 +1,14 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export interface StatOutcome {
awarded?: number;
open_price?: number;
open_equal?: number;
open_noshow?: number;
open_reject?: number;
}

View File

@ -0,0 +1,12 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export interface StatParticipation {
bid?: number;
no_participate?: number;
rejected?: number;
}

View File

@ -0,0 +1,23 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { StatKpi } from './statKpi';
import type { StatMonthPoint } from './statMonthPoint';
import type { StatOutcome } from './statOutcome';
import type { StatParticipation } from './statParticipation';
import type { StatTypeRow } from './statTypeRow';
import type { StatCategory } from './statCategory';
import type { StatCardUsage } from './statCardUsage';
export interface StatScope {
kpi?: StatKpi;
trend?: StatMonthPoint[];
outcome?: StatOutcome;
participation?: StatParticipation;
type_split?: StatTypeRow[];
categories?: StatCategory[];
cards?: StatCardUsage[];
}

View File

@ -0,0 +1,13 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export interface StatTypeRow {
label: string;
award_rate?: number;
avg_savings?: number;
count?: number;
}

View File

@ -0,0 +1,124 @@
/**
* 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 {
ResStatisticsSummary
} from '.././model';
import { customFetch } from '../../mutator/custom-fetch';
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
/**
* @summary 통계 요약(회사 전체 + 내 견적)
*/
export const getStatisticsSummary = (
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResStatisticsSummary>(
{url: `/v1/statistics/summary`, method: 'GET', signal
},
options);
}
export const getGetStatisticsSummaryQueryKey = () => {
return [
`/v1/statistics/summary`
] as const;
}
export const getGetStatisticsSummaryQueryOptions = <TData = Awaited<ReturnType<typeof getStatisticsSummary>>, TError = void>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getStatisticsSummary>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetStatisticsSummaryQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof getStatisticsSummary>>> = ({ signal }) => getStatisticsSummary(requestOptions, signal);
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getStatisticsSummary>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type GetStatisticsSummaryQueryResult = NonNullable<Awaited<ReturnType<typeof getStatisticsSummary>>>
export type GetStatisticsSummaryQueryError = void
export function useGetStatisticsSummary<TData = Awaited<ReturnType<typeof getStatisticsSummary>>, TError = void>(
options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getStatisticsSummary>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof getStatisticsSummary>>,
TError,
Awaited<ReturnType<typeof getStatisticsSummary>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetStatisticsSummary<TData = Awaited<ReturnType<typeof getStatisticsSummary>>, TError = void>(
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getStatisticsSummary>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof getStatisticsSummary>>,
TError,
Awaited<ReturnType<typeof getStatisticsSummary>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetStatisticsSummary<TData = Awaited<ReturnType<typeof getStatisticsSummary>>, TError = void>(
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getStatisticsSummary>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary 통계 요약(회사 전체 + 내 견적)
*/
export function useGetStatisticsSummary<TData = Awaited<ReturnType<typeof getStatisticsSummary>>, TError = void>(
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getStatisticsSummary>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getGetStatisticsSummaryQueryOptions(options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey ;
return query;
}

View File

@ -4,6 +4,7 @@ import {isLoggedIn, hasRole} from '../stores/auth';
import AuthenticatedLayout from '@/components/layout/AuthenticatedLayout';
import LoginPage from '../pages/login';
import DashboardPage from '../pages/dashboard';
import StatisticsPage from '../pages/statistics';
import ForbiddenPage from '../pages/forbidden';
import NotFoundPage from '../pages/not-found';
import ProductsPage from '../pages/products';
@ -56,6 +57,7 @@ export const router = createBrowserRouter([
Component: AuthenticatedLayout,
children: [
{path: 'dashboard', Component: DashboardPage},
{path: 'statistics', Component: StatisticsPage},
{path: 'products', Component: ProductsPage},
{path: 'partners', Component: PartnersPage},
{path: 'quotation', Component: QuotationPage},

View File

@ -6,6 +6,7 @@ import {showToast} from '@/lib/notify';
const PAGE_TO_PATH: Record<PageType, string> = {
DASHBOARD: '/dashboard',
STATISTICS: '/statistics',
PRODUCTS: '/products',
PARTNERS: '/partners',
QUOTATION: '/quotation',

View File

@ -9,6 +9,7 @@ import { cn } from '@/lib/utils';
import { NotificationBell } from './NotificationBell';
import {
LayoutDashboard,
BarChart3,
Briefcase,
Users,
UserCog,
@ -36,6 +37,7 @@ type SidebarUser = ReturnType<typeof useAuth>['user'];
// ownerOnly 항목은 최고관리자에게만 노출된다(렌더 시 user.role 로 필터).
const menuItems: { type: PageType; label: string; icon: ElementType; id: string; ownerOnly?: boolean }[] = [
{ type: 'DASHBOARD', label: '대시보드', icon: LayoutDashboard, id: 'sidebar-dashboard' },
{ type: 'STATISTICS', label: '통계', icon: BarChart3, id: 'sidebar-statistics' },
{ type: 'PRODUCTS', label: '상품관리', icon: Briefcase, id: 'sidebar-products' },
{ type: 'PARTNERS', label: '협력사관리', icon: Users, id: 'sidebar-partners' },
{ type: 'QUOTATION', label: '견적관리', icon: FileSpreadsheet, id: 'sidebar-quotation' },
@ -45,6 +47,7 @@ const menuItems: { type: PageType; label: string; icon: ElementType; id: string;
const pageLabelMap: Record<PageType, string> = {
DASHBOARD: '대시보드',
STATISTICS: '통계',
PRODUCTS: '상품관리',
PARTNERS: '협력사관리',
QUOTATION: '견적관리',

View File

@ -0,0 +1,281 @@
import * as React from 'react';
import * as RechartsPrimitive from 'recharts';
import { cn } from '@/lib/utils';
// shadcn Chart 래퍼 (Recharts 기반). CLI 대화형 프롬프트 회피 위해 직접 작성.
// config 의 각 키 색을 [data-chart] 스코프의 --color-<key> CSS 변수로 주입 → 다크모드/토큰 일관.
const THEMES = { light: '', dark: '.dark' } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = { config: ChartConfig };
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error('useChart must be used within a <ChartContainer />');
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<'div'> & {
config: ChartConfig;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>['children'];
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-none [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-none",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([, c]) => c.theme || c.color);
if (!colorConfig.length) return null;
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.filter(Boolean)
.join('\n')}
}
`,
)
.join('\n'),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type TooltipContentProps = {
active?: boolean;
payload?: any[];
label?: unknown;
className?: string;
indicator?: 'line' | 'dot' | 'dashed';
hideLabel?: boolean;
hideIndicator?: boolean;
labelFormatter?: (value: unknown, payload: any[]) => React.ReactNode;
labelClassName?: string;
formatter?: (value: unknown, name: unknown, item: any, index: number, payload: any) => React.ReactNode;
color?: string;
nameKey?: string;
labelKey?: string;
};
function ChartTooltipContent({
active,
payload,
className,
indicator = 'dot',
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: TooltipContentProps) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) return null;
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === 'string'
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return <div className={cn('font-medium', labelClassName)}>{labelFormatter(value, payload)}</div>;
}
if (!value) return null;
return <div className={cn('font-medium', labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
if (!active || !payload?.length) return null;
const nestLabel = payload.length === 1 && indicator !== 'dot';
return (
<div
className={cn(
'border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl',
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload?.fill || item.color;
return (
<div
key={item.dataKey ?? index}
className={cn(
'flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground',
indicator === 'dot' && 'items-center',
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn('shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)', {
'h-2.5 w-2.5': indicator === 'dot',
'w-1': indicator === 'line',
'w-0 border-[1.5px] border-dashed bg-transparent': indicator === 'dashed',
})}
style={
{
'--color-bg': indicatorColor,
'--color-border': indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div className={cn('flex flex-1 justify-between leading-none', nestLabel ? 'items-end' : 'items-center')}>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">{itemConfig?.label || item.name}</span>
</div>
{item.value !== undefined && (
<span className="text-foreground font-mono font-medium tabular-nums">
{typeof item.value === 'number' ? item.value.toLocaleString() : String(item.value)}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
type LegendContentProps = {
className?: string;
hideIcon?: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payload?: any[];
verticalAlign?: 'top' | 'bottom' | 'middle';
nameKey?: string;
};
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = 'bottom',
nameKey,
}: LegendContentProps) {
const { config } = useChart();
if (!payload?.length) return null;
return (
<div className={cn('flex items-center justify-center gap-4', verticalAlign === 'top' ? 'pb-3' : 'pt-3', className)}>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div key={String(item.value)} className="flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground">
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div className="h-2 w-2 shrink-0 rounded-[2px]" style={{ backgroundColor: item.color }} />
)}
<span className="text-muted-foreground">{itemConfig?.label}</span>
</div>
);
})}
</div>
);
}
// payload 항목에서 config 매칭 키를 찾는다(shadcn 원본 로직).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function getPayloadConfigFromPayload(config: ChartConfig, payload: any, key: string) {
if (typeof payload !== 'object' || payload === null) return undefined;
const payloadPayload =
'payload' in payload && typeof payload.payload === 'object' && payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (key in payload && typeof payload[key] === 'string') {
configLabelKey = payload[key];
} else if (payloadPayload && key in payloadPayload && typeof payloadPayload[key] === 'string') {
configLabelKey = payloadPayload[key];
}
return configLabelKey in config ? config[configLabelKey] : config[key];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};

View File

@ -0,0 +1,69 @@
import { Award, Percent, RefreshCw, Target, TrendingDown, CircleCheckBig } from 'lucide-react';
import { Panel } from './components/Panel';
import { StatTile } from './components/StatTile';
import { SavingsTrendChart } from './components/SavingsTrendChart';
import { OutcomeChart } from './components/OutcomeChart';
import { ParticipationChart } from './components/ParticipationChart';
import { TypeSplitChart } from './components/TypeSplitChart';
import { CategoryChart } from './components/CategoryChart';
import { CardEffectChart } from './components/CardEffectChart';
import { wonCompact, pct, signedWonCompact } from './fmt';
import type { StatData } from './types';
// 통계 본문. KPI 요약 + 절감 분석 + 성사/프로세스 + 카드 효과. scope(회사/내견적)별로 동일 레이아웃.
export function StatisticsView({ data }: { data: StatData }) {
const k = data.kpi;
return (
<div className="space-y-4">
{/* 임팩트 요약 KPI */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6">
<StatTile
label="총 절감액 (목표가 대비)"
value={wonCompact(k.totalSavings)}
icon={TrendingDown}
tone="emerald"
delta={{ text: `${signedWonCompact(k.savingsDeltaMoM)} 전월비`, good: k.savingsDeltaMoM >= 0 }}
/>
<StatTile label="평균 절감률" value={pct(k.savingsRate)} icon={Percent} tone="emerald" />
<StatTile label="낙찰률" value={pct(k.awardRate)} icon={Award} tone="blue" />
<StatTile label="평균 앵커 도달률" value={pct(k.anchorReachRate)} icon={Target} tone="purple" />
<StatTile label="마감 견적" value={`${k.closedCount}건`} icon={CircleCheckBig} tone="zinc" />
<StatTile label="평균 재견적 라운드" value={k.regenAvgRound.toFixed(1)} icon={RefreshCw} tone="amber" />
</div>
{/* 절감 분석 */}
<div className="grid gap-4 lg:grid-cols-3">
<Panel
title="월별 절감 추이"
subtitle="목표가 대비 절감액 · 막대에 마우스를 올리면 절감률"
className="lg:col-span-2"
>
<SavingsTrendChart data={data.trend} />
</Panel>
<Panel title="마감 결과" subtitle="낙찰 vs 개찰 사유 4종">
<OutcomeChart data={data.outcome} />
</Panel>
</div>
{/* 성사 · 프로세스 */}
<div className="grid gap-4 lg:grid-cols-2">
<Panel title="협력사 참여" subtitle="초대 세션 대비 응찰·미응찰·거부">
<ParticipationChart data={data.participation} />
</Panel>
<Panel title="유형별 성과" subtitle="협상(1:1) vs 견적(1:N) 낙찰률">
<TypeSplitChart data={data.typeSplit} />
</Panel>
</div>
{/* 카테고리 · 카드 */}
<div className="grid gap-4 lg:grid-cols-2">
<Panel title="카테고리별 절감" subtitle="어디서 절감이 났나">
<CategoryChart data={data.categories} />
</Panel>
<Panel title="협상카드 효과" subtitle="유형별 사용빈도 + 사용 직후 평균 제시가 하락">
<CardEffectChart data={data.cards} />
</Panel>
</div>
</div>
);
}

View File

@ -0,0 +1,59 @@
import { useGetStatisticsSummary } from '@/api/generated/statistics/statistics';
import type { StatScope as ApiScope } from '@/api/generated/model/statScope';
import type { StatData } from './types';
// 생성 API(snake_case, 전부 optional) → 도메인 StatData(camelCase) 매퍼.
// api/generated 는 수정 금지라 여기서 한 번에 흡수한다(mapXxx 관례). 차트 컴포넌트는 StatData만 안다.
function mapScope(s?: ApiScope): StatData {
const k = s?.kpi ?? {};
const o = s?.outcome ?? {};
const p = s?.participation ?? {};
return {
kpi: {
totalSavings: k.total_savings ?? 0,
savingsRate: k.savings_rate ?? 0,
awardRate: k.award_rate ?? 0,
anchorReachRate: k.anchor_reach_rate ?? 0,
savingsDeltaMoM: k.savings_delta_mom ?? 0,
closedCount: k.closed_count ?? 0,
regenAvgRound: k.regen_avg_round ?? 0,
},
trend: (s?.trend ?? []).map((t) => ({ month: t.month, savings: t.savings ?? 0, rate: t.rate ?? 0 })),
outcome: {
awarded: o.awarded ?? 0,
openPrice: o.open_price ?? 0,
openEqual: o.open_equal ?? 0,
openNoshow: o.open_noshow ?? 0,
openReject: o.open_reject ?? 0,
},
participation: {
bid: p.bid ?? 0,
noParticipate: p.no_participate ?? 0,
rejected: p.rejected ?? 0,
},
typeSplit: (s?.type_split ?? []).map((r) => ({
label: r.label,
awardRate: r.award_rate ?? 0,
avgSavings: r.avg_savings ?? 0,
count: r.count ?? 0,
})),
categories: (s?.categories ?? []).map((c) => ({ category: c.category, savings: c.savings ?? 0, count: c.count ?? 0 })),
cards: (s?.cards ?? []).map((c) => ({
type: c.type === 'wild' ? 'wild' : 'nego',
label: c.label,
uses: c.uses ?? 0,
avgDrop: c.avg_drop ?? 0,
})),
};
}
// 통계 요약 훅. company/mine 두 스코프를 매핑해 함께 돌려준다.
export function useStatistics() {
const q = useGetStatisticsSummary();
return {
isLoading: q.isLoading,
isError: q.isError,
company: mapScope(q.data?.company),
mine: mapScope(q.data?.mine),
};
}

View File

@ -0,0 +1,70 @@
import { Bar, BarChart, CartesianGrid, Cell, XAxis, YAxis } from 'recharts';
import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/ui/chart';
import { Typography } from '@/components/ui/typography';
import { cn } from '@/lib/utils';
import { SERIES_BG, themeOf, type SeriesKey } from '../palette';
import { won } from '../fmt';
import type { CardTypeUsage } from '../types';
// 카드 유형별 사용빈도(막대) + 효과(평균 제시가 하락, avgDrop=B안). 유형별 색.
const TONE: Record<CardTypeUsage['type'], SeriesKey> = { nego: 'blue', wild: 'purple' };
export function CardEffectChart({ data }: { data: CardTypeUsage[] }) {
const rows = data.map((d) => ({ ...d, key: d.type, tone: TONE[d.type] }));
const config = Object.fromEntries(rows.map((r) => [r.key, { label: r.label, theme: themeOf(r.tone) }])) satisfies ChartConfig;
return (
<div className="flex flex-col gap-3">
<ChartContainer config={config} className="aspect-auto h-44 w-full">
<BarChart data={rows} margin={{ top: 8, right: 8, left: 4, bottom: 0 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="label" tickLine={false} axisLine={false} tickMargin={8} />
<YAxis tickLine={false} axisLine={false} width={32} />
<ChartTooltip cursor={false} content={<CardTooltip />} />
<Bar dataKey="uses" radius={[4, 4, 0, 0]} maxBarSize={64}>
{rows.map((r) => (
<Cell key={r.key} fill={`var(--color-${r.key})`} />
))}
</Bar>
</BarChart>
</ChartContainer>
<div className="grid grid-cols-2 gap-2">
{rows.map((r) => (
<div key={r.key} className="flex items-center gap-1.5 rounded border border-border bg-muted/30 px-3 py-1.5">
<span className={cn('size-2.5 shrink-0 rounded-[3px]', SERIES_BG[r.tone])} />
<div className="min-w-0">
<Typography as="p" variant="caption" className="truncate">
{r.label} 평균 하락
</Typography>
<Typography as="p" variant="small" className="font-mono text-[12px] font-bold text-foreground">
{/* TODO: 제시가 하락 델타 미배선 — 백엔드 avg_drop=0 동안 '측정 예정' */}
{r.avgDrop > 0 ? won(r.avgDrop) : '측정 예정'}
</Typography>
</div>
</div>
))}
</div>
</div>
);
}
function CardTooltip({ active, payload }: { active?: boolean; payload?: { payload: CardTypeUsage }[] }) {
if (!active || !payload?.length) return null;
const p = payload[0].payload;
return (
<div className="rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl">
<Typography as="p" variant="caption" className="mb-0.5 font-medium text-foreground">
{p.label}
</Typography>
<Typography as="p" variant="caption">
사용 <span className="font-mono text-foreground">{p.uses.toLocaleString()}회</span>
</Typography>
{p.avgDrop > 0 && (
<Typography as="p" variant="caption">
평균 제시가 하락 <span className="font-mono text-foreground">{won(p.avgDrop)}</span>
</Typography>
)}
</div>
);
}

View File

@ -0,0 +1,30 @@
import { Bar, BarChart, LabelList, XAxis, YAxis } from 'recharts';
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart';
import { themeOf } from '../palette';
import { won, wonCompact } from '../fmt';
import type { CategorySaving } from '../types';
// 카테고리별 절감액(가로 막대, 단일 hue=크기). 막대 끝 직접 라벨.
const config = {
savings: { label: '절감액', theme: themeOf('blue') },
} satisfies ChartConfig;
export function CategoryChart({ data }: { data: CategorySaving[] }) {
const rows = [...data].sort((a, b) => b.savings - a.savings);
return (
<ChartContainer config={config} className="aspect-auto h-56 w-full">
<BarChart data={rows} layout="vertical" margin={{ top: 4, right: 44, left: 4, bottom: 0 }}>
<XAxis type="number" hide />
<YAxis type="category" dataKey="category" tickLine={false} axisLine={false} width={56} />
<ChartTooltip
cursor={false}
content={<ChartTooltipContent nameKey="savings" formatter={(value) => `절감액 ${won(Number(value))}`} />}
/>
<Bar dataKey="savings" fill="var(--color-savings)" radius={[0, 4, 4, 0]} maxBarSize={26}>
<LabelList dataKey="savings" position="right" className="fill-foreground" fontSize={11} formatter={(v: number) => wonCompact(v)} />
</Bar>
</BarChart>
</ChartContainer>
);
}

View File

@ -0,0 +1,61 @@
import { Cell, Pie, PieChart } from 'recharts';
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart';
import { Typography } from '@/components/ui/typography';
import { cn } from '@/lib/utils';
import { SERIES_BG, themeOf, type SeriesKey } from '../palette';
import { pct } from '../fmt';
import type { OutcomeBreakdown } from '../types';
// 마감 결과 분해: 낙찰 + 개찰 4종(도넛). 가운데 낙찰률, 하단 범례+건수.
const SLICES: { key: keyof OutcomeBreakdown; label: string; tone: SeriesKey }[] = [
{ key: 'awarded', label: '낙찰', tone: 'emerald' },
{ key: 'openPrice', label: '개찰·가격', tone: 'amber' },
{ key: 'openEqual', label: '개찰·동가', tone: 'purple' },
{ key: 'openNoshow', label: '개찰·미응찰', tone: 'zinc' },
{ key: 'openReject', label: '개찰·거부', tone: 'rose' },
];
const config = Object.fromEntries(SLICES.map((s) => [s.key, { label: s.label, theme: themeOf(s.tone) }])) satisfies ChartConfig;
export function OutcomeChart({ data }: { data: OutcomeBreakdown }) {
const rows = SLICES.map((s) => ({ ...s, value: data[s.key] }));
const total = rows.reduce((sum, r) => sum + r.value, 0) || 1;
const awardRate = data.awarded / total;
return (
<div className="flex flex-col gap-3">
<div className="relative mx-auto">
<ChartContainer config={config} className="aspect-square h-44">
<PieChart>
<ChartTooltip cursor={false} content={<ChartTooltipContent nameKey="key" hideLabel />} />
<Pie data={rows} dataKey="value" nameKey="key" innerRadius={52} outerRadius={72} strokeWidth={2} paddingAngle={2}>
{rows.map((r) => (
<Cell key={r.key} fill={`var(--color-${r.key})`} className="stroke-background" />
))}
</Pie>
</PieChart>
</ChartContainer>
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<Typography variant="h3" className="leading-none">
{pct(awardRate, 0)}
</Typography>
<Typography variant="caption">낙찰률</Typography>
</div>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5">
{rows.map((r) => (
<div key={r.key} className="flex items-center gap-1.5">
<span className={cn('size-2.5 shrink-0 rounded-[3px]', SERIES_BG[r.tone])} />
<Typography as="span" variant="caption" className="flex-1 truncate">
{r.label}
</Typography>
<Typography as="span" variant="caption" className={cn('font-mono', r.tone === 'emerald' && 'text-foreground')}>
{r.value}
</Typography>
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,38 @@
import type { ReactNode } from 'react';
import { Card } from '@/components/ui/card';
import { Typography } from '@/components/ui/typography';
import { cn } from '@/lib/utils';
// 통계 섹션 카드. 제목/부제 + 우측 슬롯(범례 등) + 본문.
export function Panel({
title,
subtitle,
right,
className,
children,
}: {
title: string;
subtitle?: string;
right?: ReactNode;
className?: string;
children: ReactNode;
}) {
return (
<Card className={cn('flex flex-col gap-3 p-4', className)}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<Typography variant="h4" className="text-[15px]">
{title}
</Typography>
{subtitle && (
<Typography variant="caption" className="mt-0.5 block">
{subtitle}
</Typography>
)}
</div>
{right}
</div>
{children}
</Card>
);
}

View File

@ -0,0 +1,58 @@
import { Bar, BarChart, XAxis, YAxis } from 'recharts';
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart';
import { Typography } from '@/components/ui/typography';
import { cn } from '@/lib/utils';
import { SERIES_BG, themeOf, type SeriesKey } from '../palette';
import { pct } from '../fmt';
import type { ParticipationBreakdown } from '../types';
// 협력사 참여: 초대 세션 대비 응찰/미응찰/거부(가로 100% 스택). 응찰=참여율.
const PARTS: { key: keyof ParticipationBreakdown; label: string; tone: SeriesKey }[] = [
{ key: 'bid', label: '응찰', tone: 'emerald' },
{ key: 'noParticipate', label: '미응찰', tone: 'zinc' },
{ key: 'rejected', label: '거부', tone: 'rose' },
];
const config = Object.fromEntries(PARTS.map((p) => [p.key, { label: p.label, theme: themeOf(p.tone) }])) satisfies ChartConfig;
export function ParticipationChart({ data }: { data: ParticipationBreakdown }) {
const total = PARTS.reduce((sum, p) => sum + data[p.key], 0) || 1;
const rate = data.bid / total;
const row = [{ name: '세션', ...data }];
return (
<div className="flex flex-col gap-3">
<div className="flex items-baseline gap-2">
<Typography variant="h3" className="leading-none">
{pct(rate)}
</Typography>
<Typography variant="caption">응찰 참여율 · 전체 {total.toLocaleString()}건</Typography>
</div>
<ChartContainer config={config} className="aspect-auto h-12 w-full">
<BarChart data={row} layout="vertical" margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
<XAxis type="number" hide />
<YAxis type="category" dataKey="name" hide />
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
<Bar dataKey="bid" stackId="a" fill="var(--color-bid)" radius={[6, 0, 0, 6]} />
<Bar dataKey="noParticipate" stackId="a" fill="var(--color-noParticipate)" />
<Bar dataKey="rejected" stackId="a" fill="var(--color-rejected)" radius={[0, 6, 6, 0]} />
</BarChart>
</ChartContainer>
<div className="flex flex-wrap gap-x-4 gap-y-1.5">
{PARTS.map((p) => (
<div key={p.key} className="flex items-center gap-1.5">
<span className={cn('size-2.5 shrink-0 rounded-[3px]', SERIES_BG[p.tone])} />
<Typography as="span" variant="caption">
{p.label}
</Typography>
<Typography as="span" variant="caption" className="font-mono text-foreground">
{data[p.key].toLocaleString()}
</Typography>
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,41 @@
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts';
import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/ui/chart';
import { Typography } from '@/components/ui/typography';
import { themeOf } from '../palette';
import { won, wonCompact, pct, monthLabel } from '../fmt';
import type { MonthPoint } from '../types';
// 월별 목표가 대비 절감액(단일 시리즈=크기). 한 축 원칙 — 절감률은 이중축 대신 툴팁에 얹는다.
const config = {
savings: { label: '절감액', theme: themeOf('emerald') },
} satisfies ChartConfig;
export function SavingsTrendChart({ data }: { data: MonthPoint[] }) {
return (
<ChartContainer config={config} className="aspect-auto h-56 w-full">
<BarChart data={data} margin={{ top: 8, right: 8, left: 4, bottom: 0 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="month" tickLine={false} axisLine={false} tickMargin={8} tickFormatter={monthLabel} />
<YAxis tickLine={false} axisLine={false} width={44} tickFormatter={(v) => wonCompact(Number(v))} />
<ChartTooltip cursor={false} content={<TrendTooltip />} />
<Bar dataKey="savings" fill="var(--color-savings)" radius={[4, 4, 0, 0]} maxBarSize={48} />
</BarChart>
</ChartContainer>
);
}
// 절감액(₩) + 절감률을 한 툴팁에. payload[0].payload = MonthPoint.
function TrendTooltip({ active, payload }: { active?: boolean; payload?: { payload: MonthPoint }[] }) {
if (!active || !payload?.length) return null;
const p = payload[0].payload;
return (
<div className="rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl">
<Typography as="p" variant="caption" className="mb-0.5 font-medium text-foreground">
{monthLabel(p.month)}
</Typography>
<Typography as="p" variant="caption" className="font-mono text-foreground">
{won(p.savings)} · {pct(p.rate)}
</Typography>
</div>
);
}

View File

@ -0,0 +1,50 @@
import type { ElementType } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Typography } from '@/components/ui/typography';
import { cn } from '@/lib/utils';
import { TONE_CHIP, type Tone } from '@/features/dashboard/tones';
// 통계 상단 KPI 타일. 컬러 아이콘칩 + 큰 값(문자열) + 라벨 + 옵션 증감칩.
// 대시보드 KpiTile 은 숫자 전용이라, 문자열 값(₩/%)·증감을 받는 통계용 변형으로 둔다.
export function StatTile({
label,
value,
icon: Icon,
tone,
delta,
}: {
label: string;
value: string;
icon: ElementType;
tone: Tone;
delta?: { text: string; good?: boolean };
}) {
return (
<Card className="gap-0 py-0">
<CardContent className="flex items-center gap-3 px-4 py-3.5">
<div className={cn('flex size-9 shrink-0 items-center justify-center rounded-lg', TONE_CHIP[tone])}>
<Icon size={18} />
</div>
<div className="min-w-0 space-y-0.5">
<div className="flex items-baseline gap-1.5">
<Typography variant="h3" className="leading-none">
{value}
</Typography>
{delta && (
<Typography
as="span"
variant="caption"
className={cn('font-medium', delta.good ? 'text-emerald-600 dark:text-emerald-400' : 'text-muted-foreground')}
>
{delta.text}
</Typography>
)}
</div>
<Typography variant="caption" className="block truncate">
{label}
</Typography>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,52 @@
import { Bar, BarChart, CartesianGrid, Cell, XAxis, YAxis } from 'recharts';
import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/ui/chart';
import { Typography } from '@/components/ui/typography';
import { themeOf, type SeriesKey } from '../palette';
import { won, pct } from '../fmt';
import type { TypeSplitRow } from '../types';
// 견적 유형(협상 1:1 / 경매 1:N)별 낙찰률 비교(막대=낙찰률, 한 축). 평균 절감·건수는 툴팁.
// 유형마다 색이 달라 --color-<key> 를 유형별 키로 주입한다(다크모드 자동 전환).
const TONES: SeriesKey[] = ['blue', 'purple'];
export function TypeSplitChart({ data }: { data: TypeSplitRow[] }) {
const rows = data.map((d, i) => ({ ...d, key: `t${i}`, tone: TONES[i % TONES.length] }));
const config = Object.fromEntries(rows.map((r) => [r.key, { label: r.label, theme: themeOf(r.tone) }])) satisfies ChartConfig;
return (
<ChartContainer config={config} className="aspect-auto h-56 w-full">
<BarChart data={rows} margin={{ top: 8, right: 8, left: 4, bottom: 0 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="label" tickLine={false} axisLine={false} tickMargin={8} />
<YAxis tickLine={false} axisLine={false} width={40} domain={[0, 1]} tickFormatter={(v) => pct(Number(v), 0)} />
<ChartTooltip cursor={false} content={<TypeTooltip />} />
<Bar dataKey="awardRate" radius={[4, 4, 0, 0]} maxBarSize={72}>
{rows.map((r) => (
<Cell key={r.key} fill={`var(--color-${r.key})`} />
))}
</Bar>
</BarChart>
</ChartContainer>
);
}
function TypeTooltip({ active, payload }: { active?: boolean; payload?: { payload: TypeSplitRow }[] }) {
if (!active || !payload?.length) return null;
const p = payload[0].payload;
return (
<div className="rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl">
<Typography as="p" variant="caption" className="mb-0.5 font-medium text-foreground">
{p.label}
</Typography>
<Typography as="p" variant="caption">
낙찰률 <span className="font-mono text-foreground">{pct(p.awardRate)}</span>
</Typography>
<Typography as="p" variant="caption">
평균 절감 <span className="font-mono text-foreground">{won(p.avgSavings)}</span>
</Typography>
<Typography as="p" variant="caption">
건수 <span className="font-mono text-foreground">{p.count}</span>
</Typography>
</div>
);
}

View File

@ -0,0 +1,21 @@
// 통계 표기 포맷터. 금액 단위 원, 비율 0~1.
export const won = (n: number) => `₩${Math.round(n).toLocaleString()}`;
// 축/타일용 압축 표기: 억/만 (한글). 큰 금액을 좁은 폭에 담을 때.
export function wonCompact(n: number): string {
const abs = Math.abs(n);
const sign = n < 0 ? '−' : '';
if (abs >= 100_000_000) return `${sign}${(abs / 100_000_000).toFixed(1)}억`;
if (abs >= 10_000) return `${sign}${Math.round(abs / 10_000).toLocaleString()}만`;
return `${sign}${abs.toLocaleString()}`;
}
// 비율 → %. 소수 자릿수 기본 1.
export const pct = (r: number, digits = 1) => `${(r * 100).toFixed(digits)}%`;
// 증감 표기(+/−). 절감 증가는 긍정이라 + 를 명시한다.
export const signedWonCompact = (n: number) => `${n >= 0 ? '+' : ''}${wonCompact(n)}`;
// 'YYYY-MM' → 'M월'
export const monthLabel = (ym: string) => `${parseInt(ym.slice(5, 7), 10)}월`;

View File

@ -0,0 +1,3 @@
export { StatisticsView } from './StatisticsView';
export { useStatistics } from './api';
export { type Scope, type StatData } from './types';

View File

@ -0,0 +1,27 @@
// 통계 차트 시리즈 색. features/dashboard/tones 와 같은 계열(Tailwind 램프)을 라이트/다크 한 쌍으로 둔다.
// shadcn ChartConfig 의 theme({light,dark}) 로 주입 → [data-chart] 스코프 --color-<key> CSS 변수 → 다크모드 자동 전환.
// 색 단독 식별 금지 — 모든 차트는 범례/값을 함께 노출한다(색맹 안전, dataviz 6단계).
export type SeriesKey = 'emerald' | 'amber' | 'blue' | 'purple' | 'rose' | 'zinc';
export const SERIES_COLOR: Record<SeriesKey, { light: string; dark: string }> = {
emerald: { light: '#10b981', dark: '#34d399' }, // 절감/낙찰/응찰 = 긍정
amber: { light: '#f59e0b', dark: '#fbbf24' }, // 개찰(가격) = 주의
blue: { light: '#3b82f6', dark: '#60a5fa' }, // 협상/카테고리 기본
purple: { light: '#a855f7', dark: '#c084fc' }, // 경매/동가
rose: { light: '#f43f5e', dark: '#fb7185' }, // 거부
zinc: { light: '#d4d4d8', dark: '#52525b' }, // 미응찰/중립
};
// shadcn ChartConfig 의 theme 슬롯용.
export const themeOf = (k: SeriesKey) => ({ light: SERIES_COLOR[k].light, dark: SERIES_COLOR[k].dark });
// 차트 밖(범례 점 등) DOM 요소용 Tailwind bg 클래스. --color-<key> CSS 변수는 [data-chart] 스코프 안에만 있어
// 차트 바깥에선 못 쓰므로, 여기서 라이트/다크 램프 스텝을 직접 건다.
export const SERIES_BG: Record<SeriesKey, string> = {
emerald: 'bg-emerald-500 dark:bg-emerald-400',
amber: 'bg-amber-500 dark:bg-amber-400',
blue: 'bg-blue-500 dark:bg-blue-400',
purple: 'bg-purple-500 dark:bg-purple-400',
rose: 'bg-rose-500 dark:bg-rose-400',
zinc: 'bg-zinc-300 dark:bg-zinc-600',
};

View File

@ -0,0 +1,65 @@
// 통계 도메인 타입. 백엔드 생성타입(snake_case)을 api.ts mapScope 가 이 camelCase 형태로 변환한다.
// 금액 단위: 원. 비율: 0~1.
export type Scope = 'company' | 'mine';
export interface StatKpi {
totalSavings: number; // 총 절감액(목표가 대비)
savingsRate: number; // 평균 절감률
awardRate: number; // 낙찰률(마감 견적 중 낙찰 비율)
anchorReachRate: number; // 평균 앵커 도달률 (목표−투찰)/(목표−앵커)
savingsDeltaMoM: number; // 전월 대비 절감액 증감
closedCount: number; // 마감 견적 수(창)
regenAvgRound: number; // 평균 재견적 라운드(1=재견적 없음)
}
export interface MonthPoint {
month: string; // 'YYYY-MM'
savings: number;
rate: number;
}
export interface OutcomeBreakdown {
awarded: number;
openPrice: number; // 가격 미달
openEqual: number; // 동가
openNoshow: number; // 미응찰
openReject: number; // 협상 거부
}
export interface ParticipationBreakdown {
bid: number; // 응찰(완료)
noParticipate: number; // 미응찰
rejected: number; // 거부
}
export interface TypeSplitRow {
label: string;
awardRate: number;
avgSavings: number;
count: number;
}
export interface CategorySaving {
category: string;
savings: number;
count: number; // 낙찰 세션 건수
}
// avgDrop = 카드 사용 직후 상대 제시가 평균 하락액(B안). 미배선 시 0 → 프론트 '측정 예정'.
export interface CardTypeUsage {
type: 'nego' | 'wild';
label: string;
uses: number;
avgDrop: number;
}
export interface StatData {
kpi: StatKpi;
trend: MonthPoint[];
outcome: OutcomeBreakdown;
participation: ParticipationBreakdown;
typeSplit: TypeSplitRow[];
categories: CategorySaving[];
cards: CardTypeUsage[];
}

View File

@ -0,0 +1,56 @@
import { useState } from 'react';
import { PageContainer } from '@/components/layout/PageContainer';
import { Typography } from '@/components/ui/typography';
import { Button } from '@/components/ui/button';
import { useAuth } from '@/features/auth/useAuth';
import { StatisticsView, useStatistics, type Scope } from '@/features/statistics';
// 통계(성과 분석). 회사 전체 스코프는 최고관리자만, 일반 사용자는 '내 견적'만 본다.
// 데이터는 백엔드 파생 집계(/v1/statistics/summary) — 최근 6개월 창.
export default function StatisticsPage() {
const { user } = useAuth();
const isOwner = user?.role === '최고관리자';
const [scope, setScope] = useState<Scope>('company');
const activeScope: Scope = isOwner ? scope : 'mine';
const { company, mine, isLoading, isError } = useStatistics();
const data = activeScope === 'company' ? company : mine;
return (
<PageContainer>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Typography variant="h2">통계</Typography>
{isOwner && (
<div className="flex gap-1 rounded-lg border border-border bg-card p-1">
<ScopeButton active={scope === 'company'} onClick={() => setScope('company')}>
회사 전체
</ScopeButton>
<ScopeButton active={scope === 'mine'} onClick={() => setScope('mine')}>
내 견적
</ScopeButton>
</div>
)}
</div>
{isLoading ? (
<Typography variant="muted">통계를 불러오는 중…</Typography>
) : isError ? (
<Typography variant="muted">통계를 불러오지 못했습니다.</Typography>
) : (
<StatisticsView data={data} />
)}
</PageContainer>
);
}
// 스코프 세그먼트 토글 한 칸.
function ScopeButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: string }) {
return (
<Button variant={active ? 'default' : 'ghost'} size="sm" onClick={onClick}>
<Typography as="span" variant="caption" className={active ? 'text-primary-foreground' : 'text-muted-foreground'}>
{children}
</Typography>
</Button>
);
}

View File

@ -22,6 +22,7 @@ export type Partner = SupplierData & {
export interface NegotiationCard {
id: string;
isWildcard: boolean;
isShared: boolean; // 전체(공용, user_id NULL) 카드 여부 — 개인 카드는 false
usageType: number; // usage_type(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용
code: string;
title: string;
@ -30,6 +31,7 @@ export interface NegotiationCard {
status: 'ACTIVE' | 'INACTIVE';
triggerCondition?: string;
memo?: string;
creatorName?: string; // 등록자(작성자) 이름. 공용(user_id NULL) 카드는 없음
}
export type PageType = 'DASHBOARD' | 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS' | 'MEMBERS' | 'NOTIFICATIONS';
export type PageType = 'DASHBOARD' | 'STATISTICS' | 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS' | 'MEMBERS' | 'NOTIFICATIONS';