[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:
Mina Choi 2026-06-30 11:01:38 +09:00
parent 5f648965e2
commit b42cc41b5e
22 changed files with 774 additions and 4 deletions

View 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

View File

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

View File

@ -0,0 +1,14 @@
from fastapi import APIRouter, Depends
from common.models.gmodel import UserInfo
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
from services.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))

View 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 추가 스코프)

View 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

View File

@ -0,0 +1,124 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import {
useQuery
} from '@tanstack/react-query';
import type {
DataTag,
DefinedInitialDataOptions,
DefinedUseQueryResult,
QueryClient,
QueryFunction,
QueryKey,
UndefinedInitialDataOptions,
UseQueryOptions,
UseQueryResult
} from '@tanstack/react-query';
import type {
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;
}

View File

@ -0,0 +1,12 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { DashboardQuotationRef } from './dashboardQuotationRef';
export interface DashboardActionList {
total?: number;
items?: DashboardQuotationRef[];
}

View File

@ -0,0 +1,12 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { DashboardEmailUnsentItem } from './dashboardEmailUnsentItem';
export interface DashboardEmailUnsent {
total?: number;
quotations?: DashboardEmailUnsentItem[];
}

View File

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

View File

@ -0,0 +1,13 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { DashboardQuotationRefEndTime } from './dashboardQuotationRefEndTime';
export interface DashboardQuotationRef {
qt_id: string;
name?: string;
end_time?: DashboardQuotationRefEndTime;
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type DashboardQuotationRefEndTime = string | null;

View File

@ -0,0 +1,17 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { 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;
}

View File

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

View File

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

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type QuotationDataCreatorName = string | null;

View File

@ -0,0 +1,16 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { ErrorInfo } from './errorInfo';
import type { ResDashboardSummaryMsg } from './resDashboardSummaryMsg';
import type { DashboardScope } from './dashboardScope';
export interface ResDashboardSummary {
result?: ErrorInfo;
msg?: ResDashboardSummaryMsg;
company?: DashboardScope;
mine?: DashboardScope;
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type ResDashboardSummaryMsg = string | null;

View File

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

View File

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

View File

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

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

View File

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