[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.card.card
|
||||||
import router.v1.quotation.quotation
|
import router.v1.quotation.quotation
|
||||||
import router.v1.quotation_setting.quotation_setting
|
import router.v1.quotation_setting.quotation_setting
|
||||||
|
import router.v1.dashboard.dashboard
|
||||||
|
|
||||||
API_SERVER_START_TIME = GTime.UTCStr()
|
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.card.card.router)
|
||||||
app.include_router(router.v1.quotation.quotation.router)
|
app.include_router(router.v1.quotation.quotation.router)
|
||||||
app.include_router(router.v1.quotation_setting.quotation_setting.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 './companyUserDataLastAccessedAt';
|
||||||
export * from './companyUserDataName';
|
export * from './companyUserDataName';
|
||||||
export * from './companyUserDataUpdatedAt';
|
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 './deliveryType';
|
||||||
export * from './errorInfo';
|
export * from './errorInfo';
|
||||||
export * from './errorInfoCode';
|
export * from './errorInfoCode';
|
||||||
@ -80,6 +86,7 @@ export * from './quotationCardDataType';
|
|||||||
export * from './quotationCardDataWildCardId';
|
export * from './quotationCardDataWildCardId';
|
||||||
export * from './quotationData';
|
export * from './quotationData';
|
||||||
export * from './quotationDataCreatedAt';
|
export * from './quotationDataCreatedAt';
|
||||||
|
export * from './quotationDataCreatorName';
|
||||||
export * from './quotationDataEqualBidData';
|
export * from './quotationDataEqualBidData';
|
||||||
export * from './quotationDataEqualBidYn';
|
export * from './quotationDataEqualBidYn';
|
||||||
export * from './quotationDataItemId';
|
export * from './quotationDataItemId';
|
||||||
@ -212,6 +219,8 @@ export * from './resCompanyUserUser';
|
|||||||
export * from './resCreateQuotation';
|
export * from './resCreateQuotation';
|
||||||
export * from './resCreateQuotationMsg';
|
export * from './resCreateQuotationMsg';
|
||||||
export * from './resCreateQuotationQtId';
|
export * from './resCreateQuotationQtId';
|
||||||
|
export * from './resDashboardSummary';
|
||||||
|
export * from './resDashboardSummaryMsg';
|
||||||
export * from './resDeleteCard';
|
export * from './resDeleteCard';
|
||||||
export * from './resDeleteCardMsg';
|
export * from './resDeleteCardMsg';
|
||||||
export * from './resDeleteCompanyUser';
|
export * from './resDeleteCompanyUser';
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import type { QuotationDataEqualBidYn } from './quotationDataEqualBidYn';
|
|||||||
import type { QuotationDataEqualBidData } from './quotationDataEqualBidData';
|
import type { QuotationDataEqualBidData } from './quotationDataEqualBidData';
|
||||||
import type { QuotationDataItemId } from './quotationDataItemId';
|
import type { QuotationDataItemId } from './quotationDataItemId';
|
||||||
import type { QuotationDataItemName } from './quotationDataItemName';
|
import type { QuotationDataItemName } from './quotationDataItemName';
|
||||||
|
import type { QuotationDataCreatorName } from './quotationDataCreatorName';
|
||||||
import type { QuotationDataCreatedAt } from './quotationDataCreatedAt';
|
import type { QuotationDataCreatedAt } from './quotationDataCreatedAt';
|
||||||
import type { QuotationDataUpdatedAt } from './quotationDataUpdatedAt';
|
import type { QuotationDataUpdatedAt } from './quotationDataUpdatedAt';
|
||||||
|
|
||||||
@ -49,7 +50,7 @@ export interface QuotationData {
|
|||||||
participation_count?: number;
|
participation_count?: number;
|
||||||
item_id?: QuotationDataItemId;
|
item_id?: QuotationDataItemId;
|
||||||
item_name?: QuotationDataItemName;
|
item_name?: QuotationDataItemName;
|
||||||
creator_name?: string | null;
|
creator_name?: QuotationDataCreatorName;
|
||||||
created_at?: QuotationDataCreatedAt;
|
created_at?: QuotationDataCreatedAt;
|
||||||
updated_at?: QuotationDataUpdatedAt;
|
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 {isLoggedIn, hasRole} from '../stores/auth';
|
||||||
import AuthenticatedLayout from '@/components/layout/AuthenticatedLayout';
|
import AuthenticatedLayout from '@/components/layout/AuthenticatedLayout';
|
||||||
import LoginPage from '../pages/login';
|
import LoginPage from '../pages/login';
|
||||||
|
import DashboardPage from '../pages/dashboard';
|
||||||
import ForbiddenPage from '../pages/forbidden';
|
import ForbiddenPage from '../pages/forbidden';
|
||||||
import NotFoundPage from '../pages/not-found';
|
import NotFoundPage from '../pages/not-found';
|
||||||
import ProductsPage from '../pages/products';
|
import ProductsPage from '../pages/products';
|
||||||
@ -26,13 +27,13 @@ export const router = createBrowserRouter([
|
|||||||
: []),
|
: []),
|
||||||
{
|
{
|
||||||
index: true,
|
index: true,
|
||||||
loader: () => redirect('/products'),
|
loader: () => redirect('/dashboard'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'login',
|
path: 'login',
|
||||||
loader: async () => {
|
loader: async () => {
|
||||||
await initAuth();
|
await initAuth();
|
||||||
if (isLoggedIn()) return redirect('/products');
|
if (isLoggedIn()) return redirect('/dashboard');
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
Component: LoginPage,
|
Component: LoginPage,
|
||||||
@ -53,6 +54,7 @@ export const router = createBrowserRouter([
|
|||||||
},
|
},
|
||||||
Component: AuthenticatedLayout,
|
Component: AuthenticatedLayout,
|
||||||
children: [
|
children: [
|
||||||
|
{path: 'dashboard', Component: DashboardPage},
|
||||||
{path: 'products', Component: ProductsPage},
|
{path: 'products', Component: ProductsPage},
|
||||||
{path: 'partners', Component: PartnersPage},
|
{path: 'partners', Component: PartnersPage},
|
||||||
{path: 'quotation', Component: QuotationPage},
|
{path: 'quotation', Component: QuotationPage},
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {useAuth} from '@/features/auth/useAuth';
|
|||||||
import {showToast} from '@/lib/notify';
|
import {showToast} from '@/lib/notify';
|
||||||
|
|
||||||
const PAGE_TO_PATH: Record<PageType, string> = {
|
const PAGE_TO_PATH: Record<PageType, string> = {
|
||||||
|
DASHBOARD: '/dashboard',
|
||||||
PRODUCTS: '/products',
|
PRODUCTS: '/products',
|
||||||
PARTNERS: '/partners',
|
PARTNERS: '/partners',
|
||||||
QUOTATION: '/quotation',
|
QUOTATION: '/quotation',
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Typography } from '@/components/ui/typography';
|
import { Typography } from '@/components/ui/typography';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
Users,
|
Users,
|
||||||
UserCog,
|
UserCog,
|
||||||
@ -33,6 +34,7 @@ type SidebarUser = ReturnType<typeof useAuth>['user'];
|
|||||||
|
|
||||||
// ownerOnly 항목은 최고관리자에게만 노출된다(렌더 시 user.role 로 필터).
|
// ownerOnly 항목은 최고관리자에게만 노출된다(렌더 시 user.role 로 필터).
|
||||||
const menuItems: { type: PageType; label: string; icon: ElementType; id: string; ownerOnly?: boolean }[] = [
|
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: 'PRODUCTS', label: '상품관리', icon: Briefcase, id: 'sidebar-products' },
|
||||||
{ type: 'PARTNERS', label: '협력사관리', icon: Users, id: 'sidebar-partners' },
|
{ type: 'PARTNERS', label: '협력사관리', icon: Users, id: 'sidebar-partners' },
|
||||||
{ type: 'QUOTATION', label: '견적관리', icon: FileSpreadsheet, id: 'sidebar-quotation' },
|
{ 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> = {
|
const pageLabelMap: Record<PageType, string> = {
|
||||||
|
DASHBOARD: '대시보드',
|
||||||
PRODUCTS: '상품관리',
|
PRODUCTS: '상품관리',
|
||||||
PARTNERS: '협력사관리',
|
PARTNERS: '협력사관리',
|
||||||
QUOTATION: '견적관리',
|
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;
|
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