From c32ee300e6f1240358c2ce61a3cd72340efeab2b Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Tue, 7 Jul 2026 16:18:33 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[feat]=20negodata:=20=ED=86=B5=EA=B3=84=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80(=EC=84=B1=EA=B3=BC=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D)=20=EC=B6=94=EA=B0=80=20=E2=80=94=20=ED=8C=8C?= =?UTF-8?q?=EC=83=9D=20=EC=A7=91=EA=B3=84=20API=20+=20recharts=20=EC=B0=A8?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 백엔드 GET /v1/statistics/summary: 회사/내견적 스코프, 최근 6개월 파생 집계(저장 X, DDL 0) - 지표: 총절감/절감률/낙찰률/앵커도달률/마감수/재견적라운드 + 월별추이·마감결과분해·참여율·유형별(협상/견적)·카테고리·카드빈도 - 프론트 recharts+shadcn Chart, 사이드바 통계 nav, /statistics Co-Authored-By: Claude Opus 4.8 --- negodata/backend/crud/statistics_crud.py | 214 ++++++++++ negodata/backend/router/router.py | 2 + .../backend/router/v1/statistics/protocol.py | 72 ++++ .../router/v1/statistics/statistics.py | 14 + .../backend/services/statistics_service.py | 193 +++++++++ negodata/front/package-lock.json | 377 ++++++++++++++++++ negodata/front/package.json | 1 + .../front/src/api/generated/model/index.ts | 14 + .../generated/model/resStatisticsSummary.ts | 16 + .../model/resStatisticsSummaryMsg.ts | 8 + .../src/api/generated/model/statCardUsage.ts | 13 + .../src/api/generated/model/statCategory.ts | 12 + .../front/src/api/generated/model/statKpi.ts | 16 + .../src/api/generated/model/statMonthPoint.ts | 12 + .../src/api/generated/model/statOutcome.ts | 14 + .../api/generated/model/statParticipation.ts | 12 + .../src/api/generated/model/statScope.ts | 23 ++ .../src/api/generated/model/statTypeRow.ts | 13 + .../api/generated/statistics/statistics.ts | 124 ++++++ negodata/front/src/app/router.tsx | 2 + .../components/layout/AuthenticatedLayout.tsx | 1 + .../front/src/components/layout/Layout.tsx | 3 + negodata/front/src/components/ui/chart.tsx | 281 +++++++++++++ .../features/statistics/StatisticsView.tsx | 69 ++++ negodata/front/src/features/statistics/api.ts | 59 +++ .../statistics/components/CardEffectChart.tsx | 70 ++++ .../statistics/components/CategoryChart.tsx | 30 ++ .../statistics/components/OutcomeChart.tsx | 61 +++ .../features/statistics/components/Panel.tsx | 38 ++ .../components/ParticipationChart.tsx | 58 +++ .../components/SavingsTrendChart.tsx | 41 ++ .../statistics/components/StatTile.tsx | 50 +++ .../statistics/components/TypeSplitChart.tsx | 52 +++ negodata/front/src/features/statistics/fmt.ts | 21 + .../front/src/features/statistics/index.ts | 3 + .../front/src/features/statistics/palette.ts | 27 ++ .../front/src/features/statistics/types.ts | 65 +++ negodata/front/src/pages/statistics.tsx | 56 +++ negodata/front/src/types.ts | 4 +- 39 files changed, 2140 insertions(+), 1 deletion(-) create mode 100644 negodata/backend/crud/statistics_crud.py create mode 100644 negodata/backend/router/v1/statistics/protocol.py create mode 100644 negodata/backend/router/v1/statistics/statistics.py create mode 100644 negodata/backend/services/statistics_service.py create mode 100644 negodata/front/src/api/generated/model/resStatisticsSummary.ts create mode 100644 negodata/front/src/api/generated/model/resStatisticsSummaryMsg.ts create mode 100644 negodata/front/src/api/generated/model/statCardUsage.ts create mode 100644 negodata/front/src/api/generated/model/statCategory.ts create mode 100644 negodata/front/src/api/generated/model/statKpi.ts create mode 100644 negodata/front/src/api/generated/model/statMonthPoint.ts create mode 100644 negodata/front/src/api/generated/model/statOutcome.ts create mode 100644 negodata/front/src/api/generated/model/statParticipation.ts create mode 100644 negodata/front/src/api/generated/model/statScope.ts create mode 100644 negodata/front/src/api/generated/model/statTypeRow.ts create mode 100644 negodata/front/src/api/generated/statistics/statistics.ts create mode 100644 negodata/front/src/components/ui/chart.tsx create mode 100644 negodata/front/src/features/statistics/StatisticsView.tsx create mode 100644 negodata/front/src/features/statistics/api.ts create mode 100644 negodata/front/src/features/statistics/components/CardEffectChart.tsx create mode 100644 negodata/front/src/features/statistics/components/CategoryChart.tsx create mode 100644 negodata/front/src/features/statistics/components/OutcomeChart.tsx create mode 100644 negodata/front/src/features/statistics/components/Panel.tsx create mode 100644 negodata/front/src/features/statistics/components/ParticipationChart.tsx create mode 100644 negodata/front/src/features/statistics/components/SavingsTrendChart.tsx create mode 100644 negodata/front/src/features/statistics/components/StatTile.tsx create mode 100644 negodata/front/src/features/statistics/components/TypeSplitChart.tsx create mode 100644 negodata/front/src/features/statistics/fmt.ts create mode 100644 negodata/front/src/features/statistics/index.ts create mode 100644 negodata/front/src/features/statistics/palette.ts create mode 100644 negodata/front/src/features/statistics/types.ts create mode 100644 negodata/front/src/pages/statistics.tsx diff --git a/negodata/backend/crud/statistics_crud.py b/negodata/backend/crud/statistics_crud.py new file mode 100644 index 0000000..2e07174 --- /dev/null +++ b/negodata/backend/crud/statistics_crud.py @@ -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, [] diff --git a/negodata/backend/router/router.py b/negodata/backend/router/router.py index 3bc2dd3..93fbf05 100644 --- a/negodata/backend/router/router.py +++ b/negodata/backend/router/router.py @@ -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) diff --git a/negodata/backend/router/v1/statistics/protocol.py b/negodata/backend/router/v1/statistics/protocol.py new file mode 100644 index 0000000..b4c22c9 --- /dev/null +++ b/negodata/backend/router/v1/statistics/protocol.py @@ -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 추가 스코프) diff --git a/negodata/backend/router/v1/statistics/statistics.py b/negodata/backend/router/v1/statistics/statistics.py new file mode 100644 index 0000000..297969b --- /dev/null +++ b/negodata/backend/router/v1/statistics/statistics.py @@ -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)) diff --git a/negodata/backend/services/statistics_service.py b/negodata/backend/services/statistics_service.py new file mode 100644 index 0000000..b612651 --- /dev/null +++ b/negodata/backend/services/statistics_service.py @@ -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 diff --git a/negodata/front/package-lock.json b/negodata/front/package-lock.json index 996f37f..a7d25ad 100644 --- a/negodata/front/package-lock.json +++ b/negodata/front/package-lock.json @@ -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", diff --git a/negodata/front/package.json b/negodata/front/package.json index 51ffdc0..6a2c03a 100644 --- a/negodata/front/package.json +++ b/negodata/front/package.json @@ -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", diff --git a/negodata/front/src/api/generated/model/index.ts b/negodata/front/src/api/generated/model/index.ts index 0a19228..6068205 100644 --- a/negodata/front/src/api/generated/model/index.ts +++ b/negodata/front/src/api/generated/model/index.ts @@ -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'; diff --git a/negodata/front/src/api/generated/model/resStatisticsSummary.ts b/negodata/front/src/api/generated/model/resStatisticsSummary.ts new file mode 100644 index 0000000..b8a6e4f --- /dev/null +++ b/negodata/front/src/api/generated/model/resStatisticsSummary.ts @@ -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; +} diff --git a/negodata/front/src/api/generated/model/resStatisticsSummaryMsg.ts b/negodata/front/src/api/generated/model/resStatisticsSummaryMsg.ts new file mode 100644 index 0000000..0451d80 --- /dev/null +++ b/negodata/front/src/api/generated/model/resStatisticsSummaryMsg.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ResStatisticsSummaryMsg = string | null; diff --git a/negodata/front/src/api/generated/model/statCardUsage.ts b/negodata/front/src/api/generated/model/statCardUsage.ts new file mode 100644 index 0000000..afed500 --- /dev/null +++ b/negodata/front/src/api/generated/model/statCardUsage.ts @@ -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; +} diff --git a/negodata/front/src/api/generated/model/statCategory.ts b/negodata/front/src/api/generated/model/statCategory.ts new file mode 100644 index 0000000..490e143 --- /dev/null +++ b/negodata/front/src/api/generated/model/statCategory.ts @@ -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; +} diff --git a/negodata/front/src/api/generated/model/statKpi.ts b/negodata/front/src/api/generated/model/statKpi.ts new file mode 100644 index 0000000..df2e526 --- /dev/null +++ b/negodata/front/src/api/generated/model/statKpi.ts @@ -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; +} diff --git a/negodata/front/src/api/generated/model/statMonthPoint.ts b/negodata/front/src/api/generated/model/statMonthPoint.ts new file mode 100644 index 0000000..92d1647 --- /dev/null +++ b/negodata/front/src/api/generated/model/statMonthPoint.ts @@ -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; +} diff --git a/negodata/front/src/api/generated/model/statOutcome.ts b/negodata/front/src/api/generated/model/statOutcome.ts new file mode 100644 index 0000000..0ff3e71 --- /dev/null +++ b/negodata/front/src/api/generated/model/statOutcome.ts @@ -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; +} diff --git a/negodata/front/src/api/generated/model/statParticipation.ts b/negodata/front/src/api/generated/model/statParticipation.ts new file mode 100644 index 0000000..0a2dc29 --- /dev/null +++ b/negodata/front/src/api/generated/model/statParticipation.ts @@ -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; +} diff --git a/negodata/front/src/api/generated/model/statScope.ts b/negodata/front/src/api/generated/model/statScope.ts new file mode 100644 index 0000000..0d5a4af --- /dev/null +++ b/negodata/front/src/api/generated/model/statScope.ts @@ -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[]; +} diff --git a/negodata/front/src/api/generated/model/statTypeRow.ts b/negodata/front/src/api/generated/model/statTypeRow.ts new file mode 100644 index 0000000..d36b801 --- /dev/null +++ b/negodata/front/src/api/generated/model/statTypeRow.ts @@ -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; +} diff --git a/negodata/front/src/api/generated/statistics/statistics.ts b/negodata/front/src/api/generated/statistics/statistics.ts new file mode 100644 index 0000000..81ed0ab --- /dev/null +++ b/negodata/front/src/api/generated/statistics/statistics.ts @@ -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 unknown> = Parameters[1]; + + + +/** + * @summary 통계 요약(회사 전체 + 내 견적) + */ +export const getStatisticsSummary = ( + + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/statistics/summary`, method: 'GET', signal + }, + options); + } + + + + +export const getGetStatisticsSummaryQueryKey = () => { + return [ + `/v1/statistics/summary` + ] as const; + } + + +export const getGetStatisticsSummaryQueryOptions = >, TError = void>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetStatisticsSummaryQueryKey(); + + + + const queryFn: QueryFunction>> = ({ signal }) => getStatisticsSummary(requestOptions, signal); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type GetStatisticsSummaryQueryResult = NonNullable>> +export type GetStatisticsSummaryQueryError = void + + +export function useGetStatisticsSummary>, TError = void>( + options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useGetStatisticsSummary>, TError = void>( + options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useGetStatisticsSummary>, TError = void>( + options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary 통계 요약(회사 전체 + 내 견적) + */ + +export function useGetStatisticsSummary>, TError = void>( + options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getGetStatisticsSummaryQueryOptions(options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + + diff --git a/negodata/front/src/app/router.tsx b/negodata/front/src/app/router.tsx index b19501d..72c15cd 100644 --- a/negodata/front/src/app/router.tsx +++ b/negodata/front/src/app/router.tsx @@ -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}, diff --git a/negodata/front/src/components/layout/AuthenticatedLayout.tsx b/negodata/front/src/components/layout/AuthenticatedLayout.tsx index 9bd8977..7c40180 100644 --- a/negodata/front/src/components/layout/AuthenticatedLayout.tsx +++ b/negodata/front/src/components/layout/AuthenticatedLayout.tsx @@ -6,6 +6,7 @@ import {showToast} from '@/lib/notify'; const PAGE_TO_PATH: Record = { DASHBOARD: '/dashboard', + STATISTICS: '/statistics', PRODUCTS: '/products', PARTNERS: '/partners', QUOTATION: '/quotation', diff --git a/negodata/front/src/components/layout/Layout.tsx b/negodata/front/src/components/layout/Layout.tsx index 57bec99..e9589ab 100644 --- a/negodata/front/src/components/layout/Layout.tsx +++ b/negodata/front/src/components/layout/Layout.tsx @@ -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['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 = { DASHBOARD: '대시보드', + STATISTICS: '통계', PRODUCTS: '상품관리', PARTNERS: '협력사관리', QUOTATION: '견적관리', diff --git a/negodata/front/src/components/ui/chart.tsx b/negodata/front/src/components/ui/chart.tsx new file mode 100644 index 0000000..c2351e3 --- /dev/null +++ b/negodata/front/src/components/ui/chart.tsx @@ -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- 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 } + ); +}; + +type ChartContextProps = { config: ChartConfig }; + +const ChartContext = React.createContext(null); + +function useChart() { + const context = React.useContext(ChartContext); + if (!context) { + throw new Error('useChart must be used within a '); + } + return context; +} + +function ChartContainer({ + id, + className, + children, + config, + ...props +}: React.ComponentProps<'div'> & { + config: ChartConfig; + children: React.ComponentProps['children']; +}) { + const uniqueId = React.useId(); + const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`; + + return ( + +
+ + {children} +
+
+ ); +} + +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 ( +