[feat] negodata: 대시보드 — 회사/내 견적 요약 API + 프론트 페이지
백엔드: GET /v1/dashboard/summary — 회사 전체(company)+내 견적(mine) 스코프로 진행중·이번달생성·마감임박(72h)·메일미발송·동가·결렬 집계(users.company_id 조인, 읽기 전용). 프론트: /dashboard 페이지(KPI 카드 + 액션 위젯, 행 클릭→/quotation?detail= 딥링크), 사이드바·라우트 배선, 루트·로그인 리다이렉트를 /dashboard 로. orval 재생성(useGetDashboardSummary + Dashboard* 타입, QuotationData.creator_name 동기화 포함). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
5f648965e2
commit
b42cc41b5e
167
negodata/backend/crud/dashboard_crud.py
Normal file
167
negodata/backend/crud/dashboard_crud.py
Normal file
@ -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
|
||||
@ -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)
|
||||
|
||||
14
negodata/backend/router/v1/dashboard/dashboard.py
Normal file
14
negodata/backend/router/v1/dashboard/dashboard.py
Normal 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.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))
|
||||
47
negodata/backend/router/v1/dashboard/protocol.py
Normal file
47
negodata/backend/router/v1/dashboard/protocol.py
Normal file
@ -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 추가 스코프)
|
||||
95
negodata/backend/services/dashboard_service.py
Normal file
95
negodata/backend/services/dashboard_service.py
Normal file
@ -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
|
||||
124
negodata/front/src/api/generated/dashboard/dashboard.ts
Normal file
124
negodata/front/src/api/generated/dashboard/dashboard.ts
Normal 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 {
|
||||
ResDashboardSummary
|
||||
} from '.././model';
|
||||
|
||||
import { customFetch } from '../../mutator/custom-fetch';
|
||||
|
||||
|
||||
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @summary 대시보드 요약(회사 전체 + 내 견적)
|
||||
*/
|
||||
export const getDashboardSummary = (
|
||||
|
||||
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
|
||||
) => {
|
||||
|
||||
|
||||
return customFetch<ResDashboardSummary>(
|
||||
{url: `/v1/dashboard/summary`, method: 'GET', signal
|
||||
},
|
||||
options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export const getGetDashboardSummaryQueryKey = () => {
|
||||
return [
|
||||
`/v1/dashboard/summary`
|
||||
] as const;
|
||||
}
|
||||
|
||||
|
||||
export const getGetDashboardSummaryQueryOptions = <TData = Awaited<ReturnType<typeof getDashboardSummary>>, TError = void>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDashboardSummary>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||
) => {
|
||||
|
||||
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetDashboardSummaryQueryKey();
|
||||
|
||||
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getDashboardSummary>>> = ({ signal }) => getDashboardSummary(requestOptions, signal);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getDashboardSummary>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
}
|
||||
|
||||
export type GetDashboardSummaryQueryResult = NonNullable<Awaited<ReturnType<typeof getDashboardSummary>>>
|
||||
export type GetDashboardSummaryQueryError = void
|
||||
|
||||
|
||||
export function useGetDashboardSummary<TData = Awaited<ReturnType<typeof getDashboardSummary>>, TError = void>(
|
||||
options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDashboardSummary>>, TError, TData>> & Pick<
|
||||
DefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof getDashboardSummary>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof getDashboardSummary>>
|
||||
> , 'initialData'
|
||||
>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
export function useGetDashboardSummary<TData = Awaited<ReturnType<typeof getDashboardSummary>>, TError = void>(
|
||||
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDashboardSummary>>, TError, TData>> & Pick<
|
||||
UndefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof getDashboardSummary>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof getDashboardSummary>>
|
||||
> , 'initialData'
|
||||
>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
export function useGetDashboardSummary<TData = Awaited<ReturnType<typeof getDashboardSummary>>, TError = void>(
|
||||
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDashboardSummary>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
/**
|
||||
* @summary 대시보드 요약(회사 전체 + 내 견적)
|
||||
*/
|
||||
|
||||
export function useGetDashboardSummary<TData = Awaited<ReturnType<typeof getDashboardSummary>>, TError = void>(
|
||||
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDashboardSummary>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
|
||||
|
||||
const queryOptions = getGetDashboardSummaryQueryOptions(options)
|
||||
|
||||
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||
|
||||
query.queryKey = queryOptions.queryKey ;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
@ -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[];
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
17
negodata/front/src/api/generated/model/dashboardScope.ts
Normal file
17
negodata/front/src/api/generated/model/dashboardScope.ts
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { 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;
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
@ -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},
|
||||
|
||||
@ -5,6 +5,7 @@ import {useAuth} from '@/features/auth/useAuth';
|
||||
import {showToast} from '@/lib/notify';
|
||||
|
||||
const PAGE_TO_PATH: Record<PageType, string> = {
|
||||
DASHBOARD: '/dashboard',
|
||||
PRODUCTS: '/products',
|
||||
PARTNERS: '/partners',
|
||||
QUOTATION: '/quotation',
|
||||
|
||||
@ -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<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: '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<PageType, string> = {
|
||||
DASHBOARD: '대시보드',
|
||||
PRODUCTS: '상품관리',
|
||||
PARTNERS: '협력사관리',
|
||||
QUOTATION: '견적관리',
|
||||
|
||||
199
negodata/front/src/pages/dashboard.tsx
Normal file
199
negodata/front/src/pages/dashboard.tsx
Normal file
@ -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 (
|
||||
<PageContainer>
|
||||
<Typography variant="muted">대시보드를 불러오는 중…</Typography>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Typography variant="muted">대시보드를 불러오지 못했습니다.</Typography>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<ScopeSection title="회사 전체" scope={data.company} onOpen={openQuotation} />
|
||||
<ScopeSection title="내 견적" scope={data.mine} onOpen={openQuotation} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// ----- 스코프 섹션(회사 전체 / 내 견적 공용) -----
|
||||
function ScopeSection({
|
||||
title,
|
||||
scope,
|
||||
onOpen,
|
||||
}: {
|
||||
title: string;
|
||||
scope?: DashboardScope;
|
||||
onOpen: (qtId: string) => void;
|
||||
}) {
|
||||
const s = scope ?? {};
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<Typography variant="h3">{title}</Typography>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<StatCard label="진행중 견적" value={s.in_progress ?? 0} />
|
||||
<StatCard label="이번 달 생성" value={s.this_month ?? 0} />
|
||||
<StatCard label="마감 임박" value={s.deadline_soon?.total ?? 0} warn />
|
||||
<StatCard label="메일 미발송" value={s.email_unsent?.total ?? 0} warn />
|
||||
<StatCard label="동가" value={s.equal_bid?.total ?? 0} warn />
|
||||
<StatCard label="결렬" value={s.ruptured?.total ?? 0} warn />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<DeadlineWidget data={s.deadline_soon} onOpen={onOpen} />
|
||||
<EmailUnsentWidget data={s.email_unsent} onOpen={onOpen} />
|
||||
<RefWidget title="동가 (수동 결정 필요)" data={s.equal_bid} onOpen={onOpen} />
|
||||
<RefWidget title="결렬 (선정자 없이 마감)" data={s.ruptured} onOpen={onOpen} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ----- KPI 숫자 카드 -----
|
||||
function StatCard({ label, value, warn }: { label: string; value: number; warn?: boolean }) {
|
||||
const danger = !!warn && value > 0;
|
||||
return (
|
||||
<Card className="gap-1 py-4">
|
||||
<CardContent className="space-y-1 px-4">
|
||||
<Typography variant="caption">{label}</Typography>
|
||||
<Typography variant="h2" className={danger ? 'text-destructive' : undefined}>
|
||||
{value}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ----- 액션 위젯(공용 셸) -----
|
||||
function WidgetCard({
|
||||
title,
|
||||
total,
|
||||
empty,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
total: number;
|
||||
empty: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card className="gap-3 py-4">
|
||||
<CardContent className="space-y-2 px-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Typography variant="h4">{title}</Typography>
|
||||
<Badge variant="secondary">{total}</Badge>
|
||||
</div>
|
||||
{empty ? (
|
||||
<Typography variant="muted">처리할 항목 없음</Typography>
|
||||
) : (
|
||||
<div className="space-y-1">{children}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionRow({ name, right, onClick }: { name?: string; right?: ReactNode; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<Typography variant="small" className="truncate">
|
||||
{name || '(이름 없음)'}
|
||||
</Typography>
|
||||
{right}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ----- 마감 임박: 견적 + 마감 D-n -----
|
||||
function DeadlineWidget({ data, onOpen }: { data?: DashboardActionList; onOpen: (qtId: string) => void }) {
|
||||
const items = data?.items ?? [];
|
||||
return (
|
||||
<WidgetCard title="마감 임박" total={data?.total ?? 0} empty={items.length === 0}>
|
||||
{items.map((it) => (
|
||||
<ActionRow
|
||||
key={it.qt_id}
|
||||
name={it.name}
|
||||
onClick={() => onOpen(it.qt_id)}
|
||||
right={<Badge variant="outline">{fmtDeadline(it.end_time)}</Badge>}
|
||||
/>
|
||||
))}
|
||||
</WidgetCard>
|
||||
);
|
||||
}
|
||||
|
||||
// ----- 메일 미발송: 견적 단위로 묶고 미발송 협력사 수 -----
|
||||
function EmailUnsentWidget({ data, onOpen }: { data?: DashboardEmailUnsent; onOpen: (qtId: string) => void }) {
|
||||
const items = data?.quotations ?? [];
|
||||
return (
|
||||
<WidgetCard title="메일 미발송" total={data?.total ?? 0} empty={items.length === 0}>
|
||||
{items.map((it) => (
|
||||
<ActionRow
|
||||
key={it.qt_id}
|
||||
name={it.name}
|
||||
onClick={() => onOpen(it.qt_id)}
|
||||
right={<Badge variant="destructive">미발송 {it.unsent_count ?? 0}곳</Badge>}
|
||||
/>
|
||||
))}
|
||||
</WidgetCard>
|
||||
);
|
||||
}
|
||||
|
||||
// ----- 동가 / 결렬: 견적명만 -----
|
||||
function RefWidget({
|
||||
title,
|
||||
data,
|
||||
onOpen,
|
||||
}: {
|
||||
title: string;
|
||||
data?: DashboardActionList;
|
||||
onOpen: (qtId: string) => void;
|
||||
}) {
|
||||
const items = data?.items ?? [];
|
||||
return (
|
||||
<WidgetCard title={title} total={data?.total ?? 0} empty={items.length === 0}>
|
||||
{items.map((it) => (
|
||||
<ActionRow key={it.qt_id} name={it.name} onClick={() => onOpen(it.qt_id)} />
|
||||
))}
|
||||
</WidgetCard>
|
||||
);
|
||||
}
|
||||
|
||||
// 백엔드 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}`;
|
||||
}
|
||||
@ -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';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user