diff --git a/negodata/backend/crud/dashboard_crud.py b/negodata/backend/crud/dashboard_crud.py new file mode 100644 index 0000000..9a9897c --- /dev/null +++ b/negodata/backend/crud/dashboard_crud.py @@ -0,0 +1,167 @@ +from abc import ABC, abstractmethod +from typing import Tuple + +from sqlalchemy import select, func, and_ +from sqlalchemy.ext.asyncio import AsyncSession + +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import quotations, sessions, suppliers, users +from common.enums import ErrorType, QuotationStatus +from common.logger import LOG + + +# 대시보드 집계 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 IDashboardCRUD(ABC): + @abstractmethod + async def count_in_progress(self, cdb: AsyncSession, company_id, owner) -> Tuple[ErrorType, int]: + pass + + @abstractmethod + async def count_created_since(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, int]: + pass + + @abstractmethod + async def deadline_soon(self, cdb: AsyncSession, company_id, owner, now, horizon, limit) -> Tuple[ErrorType, list, int]: + pass + + @abstractmethod + async def equal_bid(self, cdb: AsyncSession, company_id, owner, limit) -> Tuple[ErrorType, list, int]: + pass + + @abstractmethod + async def ruptured(self, cdb: AsyncSession, company_id, owner, limit) -> Tuple[ErrorType, list, int]: + pass + + @abstractmethod + async def email_unsent(self, cdb: AsyncSession, company_id, owner, limit) -> Tuple[ErrorType, list, int]: + pass + + +class DashboardCRUD(IDashboardCRUD): + async def _count(self, cdb: AsyncSession, where) -> Tuple[ErrorType, int]: + err, rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(quotations).where(where)) + if err != ErrorType.SUCCESS: + return err, 0 + return ErrorType.SUCCESS, (int(rows[0] or 0) if rows else 0) + + async def _list_with_count(self, cdb: AsyncSession, where, cols, order_by, limit) -> Tuple[ErrorType, list, int]: + c_err, total = await self._count(cdb, where) + if c_err != ErrorType.SUCCESS: + return c_err, [], 0 + l_err, rows = await DB_SESSION_MNG.execute(cdb, select(*cols).where(where).order_by(order_by).limit(limit)) + if l_err != ErrorType.SUCCESS: + return l_err, [], 0 + return ErrorType.SUCCESS, list(rows), total + + async def count_in_progress(self, cdb: AsyncSession, company_id, owner) -> Tuple[ErrorType, int]: + try: + where = and_(*_company_scope(company_id, owner), quotations.status != QuotationStatus.CLOSED.value) + return await self._count(cdb, where) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, 0 + + async def count_created_since(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, int]: + try: + where = and_(*_company_scope(company_id, owner), quotations.created_at >= since) + return await self._count(cdb, where) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, 0 + + async def deadline_soon(self, cdb: AsyncSession, company_id, owner, now, horizon, limit) -> Tuple[ErrorType, list, int]: + try: + where = and_( + *_company_scope(company_id, owner), + quotations.status != QuotationStatus.CLOSED.value, + quotations.end_time >= now, + quotations.end_time <= horizon, + ) + cols = (quotations.qt_id, quotations.name, quotations.end_time) + return await self._list_with_count(cdb, where, cols, quotations.end_time.asc(), limit) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [], 0 + + async def equal_bid(self, cdb: AsyncSession, company_id, owner, limit) -> Tuple[ErrorType, list, int]: + try: + where = and_( + *_company_scope(company_id, owner), + quotations.status == QuotationStatus.CLOSED.value, + quotations.equal_bid_yn.is_(True), + ) + cols = (quotations.qt_id, quotations.name) + return await self._list_with_count(cdb, where, cols, quotations.updated_at.desc(), limit) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [], 0 + + async def ruptured(self, cdb: AsyncSession, company_id, owner, limit) -> Tuple[ErrorType, list, int]: + try: + # 결렬 = 마감됐는데 단독낙찰(preferred_sp_yn)도 동가(equal_bid_yn)도 아님 → 둘 다 NULL(거부/한도로 그냥 마감). + where = and_( + *_company_scope(company_id, owner), + quotations.status == QuotationStatus.CLOSED.value, + quotations.preferred_sp_yn.is_(None), + quotations.equal_bid_yn.is_(None), + ) + cols = (quotations.qt_id, quotations.name) + return await self._list_with_count(cdb, where, cols, quotations.updated_at.desc(), limit) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [], 0 + + async def email_unsent(self, cdb: AsyncSession, company_id, owner, limit) -> Tuple[ErrorType, list, int]: + try: + # 미발송 세션 = email_sent_at IS NULL + 담당자 이메일 보유, 마감 전 견적만(보낼 의미 있는 것). 견적 단위로 묶는다. + conds = [ + sessions.deleted == False, # noqa: E712 + sessions.email_sent_at.is_(None), + suppliers.manager_email.isnot(None), + suppliers.manager_email != "", + quotations.deleted == False, # noqa: E712 + quotations.status != QuotationStatus.CLOSED.value, + 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) + where = and_(*conds) + + def _joined(stmt): + return ( + stmt.select_from(sessions) + .join(suppliers, suppliers.supplier_id == sessions.supplier_id) + .join(quotations, quotations.qt_id == sessions.quotation_id) + .where(where) + ) + + c_err, c_rows = await DB_SESSION_MNG.execute(cdb, _joined(select(func.count()))) + if c_err != ErrorType.SUCCESS: + return c_err, [], 0 + total = int(c_rows[0] or 0) if c_rows else 0 + + cnt = func.count().label("cnt") + g_err, rows = await DB_SESSION_MNG.execute( + cdb, + _joined(select(quotations.qt_id, quotations.name, cnt)) + .group_by(quotations.qt_id, quotations.name) + .order_by(cnt.desc()) + .limit(limit), + ) + if g_err != ErrorType.SUCCESS: + return g_err, [], 0 + return ErrorType.SUCCESS, list(rows), total + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [], 0 diff --git a/negodata/backend/router/router.py b/negodata/backend/router/router.py index 1d5bafb..14f1762 100644 --- a/negodata/backend/router/router.py +++ b/negodata/backend/router/router.py @@ -17,6 +17,7 @@ import router.v1.supplier.supplier import router.v1.card.card import router.v1.quotation.quotation import router.v1.quotation_setting.quotation_setting +import router.v1.dashboard.dashboard API_SERVER_START_TIME = GTime.UTCStr() @@ -69,3 +70,4 @@ app.include_router(router.v1.supplier.supplier.router) 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) diff --git a/negodata/backend/router/v1/dashboard/dashboard.py b/negodata/backend/router/v1/dashboard/dashboard.py new file mode 100644 index 0000000..c257e3b --- /dev/null +++ b/negodata/backend/router/v1/dashboard/dashboard.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.dashboard_service import DashboardService +from .protocol import Res_DashboardSummary + +# 라우터(컨트롤러). 인증(Depends(IsValidAccessToken))의 UserInfo 로 company/user 스코프 집계를 한 번에 내린다. +router = APIRouter(prefix="/v1/dashboard", tags=["Dashboard"], responses={404: {"description": "Not found"}}) + + +@router.get(path="/summary", response_model=Res_DashboardSummary, summary="대시보드 요약(회사 전체 + 내 견적)") +async def get_dashboard_summary(service: DashboardService = 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/router/v1/dashboard/protocol.py b/negodata/backend/router/v1/dashboard/protocol.py new file mode 100644 index 0000000..574040f --- /dev/null +++ b/negodata/backend/router/v1/dashboard/protocol.py @@ -0,0 +1,47 @@ +import uuid +from datetime import datetime +from typing import Optional + +from pydantic import Field + +from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol + + +class DashboardProtocol(WebPacketProtocol): + pass + + +class DashboardQuotationRef(WebPacketProtocol): + qt_id: uuid.UUID + name: str = "" + end_time: Optional[datetime] = None # 마감 임박 위젯에서만 채움(나머지는 None) + + +class DashboardActionList(WebPacketProtocol): + total: int = 0 # 전수 COUNT(최신 N개 아님) + items: list[DashboardQuotationRef] = [] # 정렬해서 상위 N개만(클릭 → /quotation?detail=qt_id) + + +class DashboardEmailUnsentItem(WebPacketProtocol): + qt_id: uuid.UUID + name: str = "" + unsent_count: int = 0 # 해당 견적의 미발송 세션(협력사) 수 + + +class DashboardEmailUnsent(WebPacketProtocol): + total: int = 0 # 미발송 '협상(세션)' 전수 — 헤드라인 숫자 + quotations: list[DashboardEmailUnsentItem] = [] # 견적 단위로 묶은 상위 N개 + + +class DashboardScope(WebPacketProtocol): + in_progress: int = 0 # 진행중(마감 전) 견적 수 = status != 마감 + this_month: int = 0 # 이번 달 생성 견적 수(created_at 기준) + deadline_soon: DashboardActionList = Field(default_factory=DashboardActionList) + email_unsent: DashboardEmailUnsent = Field(default_factory=DashboardEmailUnsent) + equal_bid: DashboardActionList = Field(default_factory=DashboardActionList) # 동가(수동 결정 필요) + ruptured: DashboardActionList = Field(default_factory=DashboardActionList) # 결렬(낙찰자 없이 마감) + + +class Res_DashboardSummary(Res_WebPacketProtocol): + company: DashboardScope = Field(default_factory=DashboardScope) # 회사 전체(company_id 스코프) + mine: DashboardScope = Field(default_factory=DashboardScope) # 내가 만든 견적(user_id 추가 스코프) diff --git a/negodata/backend/services/dashboard_service.py b/negodata/backend/services/dashboard_service.py new file mode 100644 index 0000000..05def58 --- /dev/null +++ b/negodata/backend/services/dashboard_service.py @@ -0,0 +1,95 @@ +import uuid +from datetime import timedelta + +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 +from common.utils.gtime import GTime +from crud.dashboard_crud import DashboardCRUD, IDashboardCRUD +from router.v1.dashboard.protocol import ( + DashboardActionList, + DashboardEmailUnsent, + DashboardEmailUnsentItem, + DashboardQuotationRef, + DashboardScope, + Res_DashboardSummary, +) + + +class DashboardService: + """대시보드 요약 집계. 회사 전체(company)와 내가 만든 견적(mine) 두 스코프를 한 응답으로 내린다. + + KPI 숫자는 전수 COUNT, 액션 리스트는 정렬해서 상위 N개만. 읽기 전용(사이드이펙트 없음). + """ + + LIST_LIMIT = 5 # 액션 위젯 리스트당 표시 개수 + DEADLINE_HOURS = 72 # 마감 임박 기준(3일 이내) + + def __init__(self, dashboard_crud: IDashboardCRUD = Depends(DashboardCRUD)): + self.dashboard_crud = dashboard_crud + + async def get_summary(self, company_id: str, user_id: str) -> Res_DashboardSummary: + res = Res_DashboardSummary() + company_uuid = uuid.UUID(company_id) + user_uuid = uuid.UUID(user_id) + now = GTime.UTC() + horizon = now + timedelta(hours=self.DEADLINE_HOURS) + month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + res.company = await self._scope_summary(company_uuid, None, now, horizon, month_start) + res.mine = await self._scope_summary(company_uuid, user_uuid, now, horizon, month_start) + return res + + async def _scope_summary(self, company_uuid, owner_uuid, now, horizon, month_start) -> DashboardScope: + scope = DashboardScope() + scope.in_progress = await self._count( + lambda s: self.dashboard_crud.count_in_progress(s, company_uuid, owner_uuid) + ) + scope.this_month = await self._count( + lambda s: self.dashboard_crud.count_created_since(s, company_uuid, owner_uuid, month_start) + ) + scope.deadline_soon = await self._action( + lambda s: self.dashboard_crud.deadline_soon(s, company_uuid, owner_uuid, now, horizon, self.LIST_LIMIT), + with_end_time=True, + ) + scope.equal_bid = await self._action( + lambda s: self.dashboard_crud.equal_bid(s, company_uuid, owner_uuid, self.LIST_LIMIT) + ) + scope.ruptured = await self._action( + lambda s: self.dashboard_crud.ruptured(s, company_uuid, owner_uuid, self.LIST_LIMIT) + ) + scope.email_unsent = await self._email_unsent(company_uuid, owner_uuid) + return scope + + async def _count(self, fn) -> int: + err, n = await DB_SESSION_MNG.execute_lambda(quotations.DBType(), DBWRType.DB_READ.value, fn) + return n if err == ErrorType.SUCCESS else 0 + + async def _action(self, fn, with_end_time: bool = False) -> DashboardActionList: + out = DashboardActionList() + err, rows, total = await DB_SESSION_MNG.execute_lambda(quotations.DBType(), DBWRType.DB_READ.value, fn) + if err != ErrorType.SUCCESS: + return out + out.total = total + out.items = [ + DashboardQuotationRef(qt_id=r[0], name=r[1] or "", end_time=(r[2] if with_end_time else None)) + for r in rows + ] + return out + + async def _email_unsent(self, company_uuid, owner_uuid) -> DashboardEmailUnsent: + out = DashboardEmailUnsent() + err, rows, total = await DB_SESSION_MNG.execute_lambda( + quotations.DBType(), + DBWRType.DB_READ.value, + lambda s: self.dashboard_crud.email_unsent(s, company_uuid, owner_uuid, self.LIST_LIMIT), + ) + if err != ErrorType.SUCCESS: + return out + out.total = total + out.quotations = [ + DashboardEmailUnsentItem(qt_id=r[0], name=r[1] or "", unsent_count=int(r[2] or 0)) for r in rows + ] + return out diff --git a/negodata/front/src/api/generated/dashboard/dashboard.ts b/negodata/front/src/api/generated/dashboard/dashboard.ts new file mode 100644 index 0000000..66333ba --- /dev/null +++ b/negodata/front/src/api/generated/dashboard/dashboard.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 { + ResDashboardSummary +} from '.././model'; + +import { customFetch } from '../../mutator/custom-fetch'; + + +type SecondParameter unknown> = Parameters[1]; + + + +/** + * @summary 대시보드 요약(회사 전체 + 내 견적) + */ +export const getDashboardSummary = ( + + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/dashboard/summary`, method: 'GET', signal + }, + options); + } + + + + +export const getGetDashboardSummaryQueryKey = () => { + return [ + `/v1/dashboard/summary` + ] as const; + } + + +export const getGetDashboardSummaryQueryOptions = >, TError = void>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetDashboardSummaryQueryKey(); + + + + const queryFn: QueryFunction>> = ({ signal }) => getDashboardSummary(requestOptions, signal); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type GetDashboardSummaryQueryResult = NonNullable>> +export type GetDashboardSummaryQueryError = void + + +export function useGetDashboardSummary>, TError = void>( + options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useGetDashboardSummary>, TError = void>( + options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useGetDashboardSummary>, TError = void>( + options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary 대시보드 요약(회사 전체 + 내 견적) + */ + +export function useGetDashboardSummary>, TError = void>( + options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getGetDashboardSummaryQueryOptions(options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + + diff --git a/negodata/front/src/api/generated/model/dashboardActionList.ts b/negodata/front/src/api/generated/model/dashboardActionList.ts new file mode 100644 index 0000000..f6de712 --- /dev/null +++ b/negodata/front/src/api/generated/model/dashboardActionList.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { DashboardQuotationRef } from './dashboardQuotationRef'; + +export interface DashboardActionList { + total?: number; + items?: DashboardQuotationRef[]; +} diff --git a/negodata/front/src/api/generated/model/dashboardEmailUnsent.ts b/negodata/front/src/api/generated/model/dashboardEmailUnsent.ts new file mode 100644 index 0000000..68f3075 --- /dev/null +++ b/negodata/front/src/api/generated/model/dashboardEmailUnsent.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { DashboardEmailUnsentItem } from './dashboardEmailUnsentItem'; + +export interface DashboardEmailUnsent { + total?: number; + quotations?: DashboardEmailUnsentItem[]; +} diff --git a/negodata/front/src/api/generated/model/dashboardEmailUnsentItem.ts b/negodata/front/src/api/generated/model/dashboardEmailUnsentItem.ts new file mode 100644 index 0000000..c80738a --- /dev/null +++ b/negodata/front/src/api/generated/model/dashboardEmailUnsentItem.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 DashboardEmailUnsentItem { + qt_id: string; + name?: string; + unsent_count?: number; +} diff --git a/negodata/front/src/api/generated/model/dashboardQuotationRef.ts b/negodata/front/src/api/generated/model/dashboardQuotationRef.ts new file mode 100644 index 0000000..6ca7858 --- /dev/null +++ b/negodata/front/src/api/generated/model/dashboardQuotationRef.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { DashboardQuotationRefEndTime } from './dashboardQuotationRefEndTime'; + +export interface DashboardQuotationRef { + qt_id: string; + name?: string; + end_time?: DashboardQuotationRefEndTime; +} diff --git a/negodata/front/src/api/generated/model/dashboardQuotationRefEndTime.ts b/negodata/front/src/api/generated/model/dashboardQuotationRefEndTime.ts new file mode 100644 index 0000000..b1f20fc --- /dev/null +++ b/negodata/front/src/api/generated/model/dashboardQuotationRefEndTime.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 DashboardQuotationRefEndTime = string | null; diff --git a/negodata/front/src/api/generated/model/dashboardScope.ts b/negodata/front/src/api/generated/model/dashboardScope.ts new file mode 100644 index 0000000..0e8fce2 --- /dev/null +++ b/negodata/front/src/api/generated/model/dashboardScope.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { DashboardActionList } from './dashboardActionList'; +import type { DashboardEmailUnsent } from './dashboardEmailUnsent'; + +export interface DashboardScope { + in_progress?: number; + this_month?: number; + deadline_soon?: DashboardActionList; + email_unsent?: DashboardEmailUnsent; + equal_bid?: DashboardActionList; + ruptured?: DashboardActionList; +} diff --git a/negodata/front/src/api/generated/model/index.ts b/negodata/front/src/api/generated/model/index.ts index 75dc364..8f8ca20 100644 --- a/negodata/front/src/api/generated/model/index.ts +++ b/negodata/front/src/api/generated/model/index.ts @@ -35,6 +35,12 @@ export * from './companyUserDataEmail'; export * from './companyUserDataLastAccessedAt'; export * from './companyUserDataName'; export * from './companyUserDataUpdatedAt'; +export * from './dashboardActionList'; +export * from './dashboardEmailUnsent'; +export * from './dashboardEmailUnsentItem'; +export * from './dashboardQuotationRef'; +export * from './dashboardQuotationRefEndTime'; +export * from './dashboardScope'; export * from './deliveryType'; export * from './errorInfo'; export * from './errorInfoCode'; @@ -80,6 +86,7 @@ export * from './quotationCardDataType'; export * from './quotationCardDataWildCardId'; export * from './quotationData'; export * from './quotationDataCreatedAt'; +export * from './quotationDataCreatorName'; export * from './quotationDataEqualBidData'; export * from './quotationDataEqualBidYn'; export * from './quotationDataItemId'; @@ -212,6 +219,8 @@ export * from './resCompanyUserUser'; export * from './resCreateQuotation'; export * from './resCreateQuotationMsg'; export * from './resCreateQuotationQtId'; +export * from './resDashboardSummary'; +export * from './resDashboardSummaryMsg'; export * from './resDeleteCard'; export * from './resDeleteCardMsg'; export * from './resDeleteCompanyUser'; diff --git a/negodata/front/src/api/generated/model/quotationData.ts b/negodata/front/src/api/generated/model/quotationData.ts index 23fa400..e8f0e8b 100644 --- a/negodata/front/src/api/generated/model/quotationData.ts +++ b/negodata/front/src/api/generated/model/quotationData.ts @@ -19,6 +19,7 @@ import type { QuotationDataEqualBidYn } from './quotationDataEqualBidYn'; import type { QuotationDataEqualBidData } from './quotationDataEqualBidData'; import type { QuotationDataItemId } from './quotationDataItemId'; import type { QuotationDataItemName } from './quotationDataItemName'; +import type { QuotationDataCreatorName } from './quotationDataCreatorName'; import type { QuotationDataCreatedAt } from './quotationDataCreatedAt'; import type { QuotationDataUpdatedAt } from './quotationDataUpdatedAt'; @@ -49,7 +50,7 @@ export interface QuotationData { participation_count?: number; item_id?: QuotationDataItemId; item_name?: QuotationDataItemName; - creator_name?: string | null; + creator_name?: QuotationDataCreatorName; created_at?: QuotationDataCreatedAt; updated_at?: QuotationDataUpdatedAt; } diff --git a/negodata/front/src/api/generated/model/quotationDataCreatorName.ts b/negodata/front/src/api/generated/model/quotationDataCreatorName.ts new file mode 100644 index 0000000..401912b --- /dev/null +++ b/negodata/front/src/api/generated/model/quotationDataCreatorName.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 QuotationDataCreatorName = string | null; diff --git a/negodata/front/src/api/generated/model/resDashboardSummary.ts b/negodata/front/src/api/generated/model/resDashboardSummary.ts new file mode 100644 index 0000000..8e8d74b --- /dev/null +++ b/negodata/front/src/api/generated/model/resDashboardSummary.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 { ResDashboardSummaryMsg } from './resDashboardSummaryMsg'; +import type { DashboardScope } from './dashboardScope'; + +export interface ResDashboardSummary { + result?: ErrorInfo; + msg?: ResDashboardSummaryMsg; + company?: DashboardScope; + mine?: DashboardScope; +} diff --git a/negodata/front/src/api/generated/model/resDashboardSummaryMsg.ts b/negodata/front/src/api/generated/model/resDashboardSummaryMsg.ts new file mode 100644 index 0000000..5b07b96 --- /dev/null +++ b/negodata/front/src/api/generated/model/resDashboardSummaryMsg.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 ResDashboardSummaryMsg = string | null; diff --git a/negodata/front/src/app/router.tsx b/negodata/front/src/app/router.tsx index 0809f23..f0f3400 100644 --- a/negodata/front/src/app/router.tsx +++ b/negodata/front/src/app/router.tsx @@ -3,6 +3,7 @@ import {initAuth} from '../features/auth/service'; import {isLoggedIn, hasRole} from '../stores/auth'; import AuthenticatedLayout from '@/components/layout/AuthenticatedLayout'; import LoginPage from '../pages/login'; +import DashboardPage from '../pages/dashboard'; import ForbiddenPage from '../pages/forbidden'; import NotFoundPage from '../pages/not-found'; import ProductsPage from '../pages/products'; @@ -26,13 +27,13 @@ export const router = createBrowserRouter([ : []), { index: true, - loader: () => redirect('/products'), + loader: () => redirect('/dashboard'), }, { path: 'login', loader: async () => { await initAuth(); - if (isLoggedIn()) return redirect('/products'); + if (isLoggedIn()) return redirect('/dashboard'); return null; }, Component: LoginPage, @@ -53,6 +54,7 @@ export const router = createBrowserRouter([ }, Component: AuthenticatedLayout, children: [ + {path: 'dashboard', Component: DashboardPage}, {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 b47f3c0..6e0d701 100644 --- a/negodata/front/src/components/layout/AuthenticatedLayout.tsx +++ b/negodata/front/src/components/layout/AuthenticatedLayout.tsx @@ -5,6 +5,7 @@ import {useAuth} from '@/features/auth/useAuth'; import {showToast} from '@/lib/notify'; const PAGE_TO_PATH: Record = { + DASHBOARD: '/dashboard', 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 414b0f7..3fd4f0d 100644 --- a/negodata/front/src/components/layout/Layout.tsx +++ b/negodata/front/src/components/layout/Layout.tsx @@ -7,6 +7,7 @@ import { Badge } from '@/components/ui/badge'; import { Typography } from '@/components/ui/typography'; import { cn } from '@/lib/utils'; import { + LayoutDashboard, Briefcase, Users, UserCog, @@ -33,6 +34,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: 'PRODUCTS', label: '상품관리', icon: Briefcase, id: 'sidebar-products' }, { type: 'PARTNERS', label: '협력사관리', icon: Users, id: 'sidebar-partners' }, { type: 'QUOTATION', label: '견적관리', icon: FileSpreadsheet, id: 'sidebar-quotation' }, @@ -41,6 +43,7 @@ const menuItems: { type: PageType; label: string; icon: ElementType; id: string; ]; const pageLabelMap: Record = { + DASHBOARD: '대시보드', PRODUCTS: '상품관리', PARTNERS: '협력사관리', QUOTATION: '견적관리', diff --git a/negodata/front/src/pages/dashboard.tsx b/negodata/front/src/pages/dashboard.tsx new file mode 100644 index 0000000..45d1e43 --- /dev/null +++ b/negodata/front/src/pages/dashboard.tsx @@ -0,0 +1,199 @@ +import type { ReactNode } from 'react'; +import { useNavigate } from 'react-router'; +import { PageContainer } from '@/components/layout/PageContainer'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Typography } from '@/components/ui/typography'; +import { useGetDashboardSummary } from '@/api/generated/dashboard/dashboard'; +import type { DashboardScope } from '@/api/generated/model/dashboardScope'; +import type { DashboardActionList } from '@/api/generated/model/dashboardActionList'; +import type { DashboardEmailUnsent } from '@/api/generated/model/dashboardEmailUnsent'; + +// 견적 생성~마감~선정을 한눈에 다루는 대시보드. 회사 전체 + 내 견적 두 스코프를 따로 보여주고, +// 모든 액션 행은 클릭 시 해당 견적 상세로 딥링크(/quotation?detail=qt_id)된다. 읽기 전용. +export default function DashboardPage() { + const navigate = useNavigate(); + const { data, isLoading, isError } = useGetDashboardSummary(); + + const openQuotation = (qtId: string) => navigate(`/quotation?detail=${qtId}`); + + if (isLoading) { + return ( + + 대시보드를 불러오는 중… + + ); + } + if (isError || !data) { + return ( + + 대시보드를 불러오지 못했습니다. + + ); + } + + return ( + + + + + ); +} + +// ----- 스코프 섹션(회사 전체 / 내 견적 공용) ----- +function ScopeSection({ + title, + scope, + onOpen, +}: { + title: string; + scope?: DashboardScope; + onOpen: (qtId: string) => void; +}) { + const s = scope ?? {}; + return ( +
+ {title} + +
+ + + + + + +
+ +
+ + + + +
+
+ ); +} + +// ----- KPI 숫자 카드 ----- +function StatCard({ label, value, warn }: { label: string; value: number; warn?: boolean }) { + const danger = !!warn && value > 0; + return ( + + + {label} + + {value} + + + + ); +} + +// ----- 액션 위젯(공용 셸) ----- +function WidgetCard({ + title, + total, + empty, + children, +}: { + title: string; + total: number; + empty: boolean; + children: ReactNode; +}) { + return ( + + +
+ {title} + {total} +
+ {empty ? ( + 처리할 항목 없음 + ) : ( +
{children}
+ )} +
+
+ ); +} + +function ActionRow({ name, right, onClick }: { name?: string; right?: ReactNode; onClick: () => void }) { + return ( + + ); +} + +// ----- 마감 임박: 견적 + 마감 D-n ----- +function DeadlineWidget({ data, onOpen }: { data?: DashboardActionList; onOpen: (qtId: string) => void }) { + const items = data?.items ?? []; + return ( + + {items.map((it) => ( + onOpen(it.qt_id)} + right={{fmtDeadline(it.end_time)}} + /> + ))} + + ); +} + +// ----- 메일 미발송: 견적 단위로 묶고 미발송 협력사 수 ----- +function EmailUnsentWidget({ data, onOpen }: { data?: DashboardEmailUnsent; onOpen: (qtId: string) => void }) { + const items = data?.quotations ?? []; + return ( + + {items.map((it) => ( + onOpen(it.qt_id)} + right={미발송 {it.unsent_count ?? 0}곳} + /> + ))} + + ); +} + +// ----- 동가 / 결렬: 견적명만 ----- +function RefWidget({ + title, + data, + onOpen, +}: { + title: string; + data?: DashboardActionList; + onOpen: (qtId: string) => void; +}) { + const items = data?.items ?? []; + return ( + + {items.map((it) => ( + onOpen(it.qt_id)} /> + ))} + + ); +} + +// 백엔드 end_time 은 naive UTC ISO(타임존 표기 없음) → 'Z' 를 붙여 UTC 로 파싱하고 남은 일수로 D-n 표기. +function fmtDeadline(s?: string | null): string { + if (!s) return ''; + const due = new Date(s.endsWith('Z') ? s : `${s}Z`); + const days = Math.ceil((due.getTime() - Date.now()) / 86_400_000); + if (Number.isNaN(days)) return ''; + if (days < 0) return '지남'; + if (days === 0) return 'D-DAY'; + return `D-${days}`; +} diff --git a/negodata/front/src/types.ts b/negodata/front/src/types.ts index 66dc7ec..0d46dbd 100644 --- a/negodata/front/src/types.ts +++ b/negodata/front/src/types.ts @@ -33,4 +33,4 @@ export interface NegotiationCard { memo?: string; } -export type PageType = 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS' | 'MEMBERS'; +export type PageType = 'DASHBOARD' | 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS' | 'MEMBERS';