diff --git a/backend/common/database/model/models.py b/backend/common/database/model/models.py
index 70ec1a3..a331aa7 100644
--- a/backend/common/database/model/models.py
+++ b/backend/common/database/model/models.py
@@ -109,7 +109,7 @@ class sessions(MAIN_BASE):
supplier_id = Column(UUID(as_uuid=True), nullable=False) # 대상 공급사(partner.suppliers.supplier_id)
qt_number = Column(String(30), nullable=False) # 견적번호(스냅샷)
qt_round = Column(Integer, nullable=False) # 견적 라운드(스냅샷)
- qt_type = Column(SmallInteger, nullable=False) # 견적 유형: 1=재협상, 2=재견적 (QtType)
+ qt_type = Column(SmallInteger, nullable=False) # 견적 유형: 1=재협상, 2=재견적, 3=신규협상, 4=신규견적 (QtType)
target_price = Column(BigInteger, nullable=False) # 목표가(원)
target_anchoring_price = Column(BigInteger, nullable=True)
status = Column(SmallInteger, nullable=False) # 진행 상태 (SessionStatus 코드)
@@ -139,7 +139,7 @@ class quotations(MAIN_BASE):
version_id = Column(UUID(as_uuid=True), nullable=False) # 버전(card.versions.version_id)
name = Column(String(50), nullable=False) # 견적명
number = Column(String(30), nullable=False) # 견적번호
- type = Column(SmallInteger, nullable=False) # 견적 유형: 1=재협상, 2=재견적 (QtType)
+ type = Column(SmallInteger, nullable=False) # 견적 유형: 1=재협상, 2=재견적, 3=신규협상, 4=신규견적 (QtType)
round = Column(Integer, nullable=False, server_default=text("1")) # 재견적 회차
status = Column(SmallInteger, nullable=False) # 진행 상태 (QuotationStatus 코드)
start_time = Column(DateTime(timezone=True), nullable=False) # 견적 시작 시각
diff --git a/backend/router/v1/negotiation/protocol.py b/backend/router/v1/negotiation/protocol.py
index 1101e0e..4687b46 100644
--- a/backend/router/v1/negotiation/protocol.py
+++ b/backend/router/v1/negotiation/protocol.py
@@ -5,7 +5,7 @@ from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
class ListItem(WebPacketProtocol):
session_id: str = ""
session_status: int = 0 # SessionStatus 코드
- qt_type: int = 0 # QtType 코드 (1=재협상, 2=재견적)
+ qt_type: int = 0 # QtType 코드 (1=재협상, 2=재견적, 3=신규협상, 4=신규견적)
qt_number: str = ""
qt_end_time: str = "" # ISO 8601 (마감 시각)
item_code: str = ""
diff --git a/backend/router/v1/negotiation/session.py b/backend/router/v1/negotiation/session.py
index 324a634..e1f3dca 100644
--- a/backend/router/v1/negotiation/session.py
+++ b/backend/router/v1/negotiation/session.py
@@ -22,7 +22,7 @@ async def list_sessions(
credentials: HTTPAuthorizationCredentials = Depends(security),
service: NegotiationService = Depends(),
status: Optional[int] = Query(None, description="세션 상태 코드 (SessionStatus)"),
- qt_type: Optional[int] = Query(None, description="견적 유형 코드 (QtType: 1=재협상, 2=재견적)"),
+ qt_type: Optional[int] = Query(None, description="견적 유형 코드 (QtType: 1=재협상, 2=재견적, 3=신규협상, 4=신규견적)"),
order: str = Query("asc", description="마감일 정렬: asc(임박순)/desc"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
diff --git a/backend/scripts/dev_seed.sql b/backend/scripts/dev_seed.sql
index 039efe5..9d513f3 100644
--- a/backend/scripts/dev_seed.sql
+++ b/backend/scripts/dev_seed.sql
@@ -60,7 +60,7 @@ VALUES
('b0000000-0000-0000-0000-000000000006','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','회의실 대형 디스플레이 65인치','IMK-10236','QM65R','삼성전자', 2890000);
-- 5) 견적 6개 (end_time = 마감 진실값)
--- type: 1=재협상(RENEGO) 2=재견적(REQUOTE) / status: 1=생성 2=진행중 3=마감
+-- type: 1=재협상(RENEGO) 2=재견적(REQUOTE) 3=신규협상(NEW_NEGO) 4=신규견적(NEW_QUOTE) / status: 1=생성 2=진행중 3=마감
INSERT INTO quotation.quotations
(qt_id, user_id, qt_setting_id, version_id, name, number, type, round, status, start_time, end_time)
VALUES
diff --git a/negodata/backend/common/database/model/models.py b/negodata/backend/common/database/model/models.py
index ff1edbe..ce9c570 100644
--- a/negodata/backend/common/database/model/models.py
+++ b/negodata/backend/common/database/model/models.py
@@ -206,7 +206,7 @@ class quotations(MainTableMixin, MAIN_BASE):
name = Column(String(50), nullable=False)
number = Column(String(30), nullable=False)
- type = Column(SmallInteger, nullable=False) # QuotationType: 1=renego(1:1) / 2=requote(1:N)
+ type = Column(SmallInteger, nullable=False) # QuotationType: 1=renego(1:1) / 2=requote(1:N) / 3=new_nego(1:1) / 4=new_quote(1:N)
round = Column(Integer, nullable=False, default=1) # 재견적 진행 시 증가
status = Column(SmallInteger, nullable=False) # 진행 상태 코드
start_time = Column(DateTime(timezone=True), nullable=False)
diff --git a/negodata/backend/common/enums.py b/negodata/backend/common/enums.py
index 5dcde46..64c8e70 100644
--- a/negodata/backend/common/enums.py
+++ b/negodata/backend/common/enums.py
@@ -148,9 +148,8 @@ class QuotationStatus(CodeEnum):
"""quotations.status 코드값(SMALLINT). 프론트 견적상태 뱃지와 매핑된다."""
CREATED = 1
- ACTIVE = 2
+ IN_PROGRESS = 2
CLOSED = 3
- ON_HOLD = 4
class SessionStatus(CodeEnum):
@@ -191,7 +190,7 @@ class ChatSender(CodeEnum):
class DeliveryType(CodeEnum):
"""items.delivery_type 코드값. 협상 채팅의 배송형태 선택지와 동일 집합."""
- PARTNER = 1 # 협력사배송
+ SUPPLIER = 1 # 협력사배송
COURIER = 2 # 지정택배배송
PICKUP = 3 # 픽업배송
diff --git a/negodata/backend/crud/dashboard_crud.py b/negodata/backend/crud/dashboard_crud.py
index 9a9897c..965eb5c 100644
--- a/negodata/backend/crud/dashboard_crud.py
+++ b/negodata/backend/crud/dashboard_crud.py
@@ -31,10 +31,18 @@ class IDashboardCRUD(ABC):
async def count_created_since(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, int]:
pass
+ @abstractmethod
+ async def count_awarded_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 awarded(self, cdb: AsyncSession, company_id, owner, limit) -> Tuple[ErrorType, list, int]:
+ pass
+
@abstractmethod
async def equal_bid(self, cdb: AsyncSession, company_id, owner, limit) -> Tuple[ErrorType, list, int]:
pass
@@ -80,6 +88,20 @@ class DashboardCRUD(IDashboardCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0
+ async def count_awarded_since(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, int]:
+ try:
+ # 이번 달 낙찰 = 마감 + 단독 최저 선정(preferred_sp_yn=True) + 낙찰(마감) 시각이 기준일 이후.
+ where = and_(
+ *_company_scope(company_id, owner),
+ quotations.status == QuotationStatus.CLOSED.value,
+ quotations.preferred_sp_yn.is_(True),
+ quotations.updated_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_(
@@ -94,6 +116,20 @@ class DashboardCRUD(IDashboardCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, [], 0
+ async def awarded(self, cdb: AsyncSession, company_id, owner, limit) -> Tuple[ErrorType, list, int]:
+ try:
+ # 낙찰 = 마감 + 단독 최저가 선정(preferred_sp_yn=True). 동가/결렬과 동형(최신 마감순).
+ where = and_(
+ *_company_scope(company_id, owner),
+ quotations.status == QuotationStatus.CLOSED.value,
+ quotations.preferred_sp_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 equal_bid(self, cdb: AsyncSession, company_id, owner, limit) -> Tuple[ErrorType, list, int]:
try:
where = and_(
@@ -124,7 +160,8 @@ class DashboardCRUD(IDashboardCRUD):
async def email_unsent(self, cdb: AsyncSession, company_id, owner, limit) -> Tuple[ErrorType, list, int]:
try:
- # 미발송 세션 = email_sent_at IS NULL + 담당자 이메일 보유, 마감 전 견적만(보낼 의미 있는 것). 견적 단위로 묶는다.
+ # 미발송 세션 = email_sent_at IS NULL + 담당자 이메일 보유, 마감 전 견적만. 견적 단위로 묶는다.
+ # total = 미발송 '견적' 수(distinct qt_id) — 리스트와 일치. 견적별 미발송 협력사 수는 행의 unsent_count.
conds = [
sessions.deleted == False, # noqa: E712
sessions.email_sent_at.is_(None),
@@ -146,7 +183,7 @@ class DashboardCRUD(IDashboardCRUD):
.where(where)
)
- c_err, c_rows = await DB_SESSION_MNG.execute(cdb, _joined(select(func.count())))
+ c_err, c_rows = await DB_SESSION_MNG.execute(cdb, _joined(select(func.count(func.distinct(quotations.qt_id)))))
if c_err != ErrorType.SUCCESS:
return c_err, [], 0
total = int(c_rows[0] or 0) if c_rows else 0
diff --git a/negodata/backend/router/v1/dashboard/protocol.py b/negodata/backend/router/v1/dashboard/protocol.py
index 574040f..52492ed 100644
--- a/negodata/backend/router/v1/dashboard/protocol.py
+++ b/negodata/backend/router/v1/dashboard/protocol.py
@@ -29,16 +29,18 @@ class DashboardEmailUnsentItem(WebPacketProtocol):
class DashboardEmailUnsent(WebPacketProtocol):
- total: int = 0 # 미발송 '협상(세션)' 전수 — 헤드라인 숫자
+ total: int = 0 # 미발송 견적 수(distinct qt_id) — 리스트와 일치하는 헤드라인 숫자
quotations: list[DashboardEmailUnsentItem] = [] # 견적 단위로 묶은 상위 N개
class DashboardScope(WebPacketProtocol):
in_progress: int = 0 # 진행중(마감 전) 견적 수 = status != 마감
this_month: int = 0 # 이번 달 생성 견적 수(created_at 기준)
+ awarded_this_month: int = 0 # 이번 달 낙찰 견적 수(CLOSED·preferred_sp_yn=True, 마감 시각 기준)
deadline_soon: DashboardActionList = Field(default_factory=DashboardActionList)
email_unsent: DashboardEmailUnsent = Field(default_factory=DashboardEmailUnsent)
- equal_bid: DashboardActionList = Field(default_factory=DashboardActionList) # 동가(수동 결정 필요)
+ awarded: DashboardActionList = Field(default_factory=DashboardActionList) # 낙찰(단독 최저가 선정, preferred_sp_yn=True)
+ equal_bid: DashboardActionList = Field(default_factory=DashboardActionList) # 동가 마감(자동 다음 차수 생성, equal_bid_yn=True)
ruptured: DashboardActionList = Field(default_factory=DashboardActionList) # 결렬(낙찰자 없이 마감)
diff --git a/negodata/backend/services/dashboard_service.py b/negodata/backend/services/dashboard_service.py
index 05def58..2335f04 100644
--- a/negodata/backend/services/dashboard_service.py
+++ b/negodata/backend/services/dashboard_service.py
@@ -50,10 +50,16 @@ class DashboardService:
scope.this_month = await self._count(
lambda s: self.dashboard_crud.count_created_since(s, company_uuid, owner_uuid, month_start)
)
+ scope.awarded_this_month = await self._count(
+ lambda s: self.dashboard_crud.count_awarded_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.awarded = await self._action(
+ lambda s: self.dashboard_crud.awarded(s, company_uuid, owner_uuid, self.LIST_LIMIT)
+ )
scope.equal_bid = await self._action(
lambda s: self.dashboard_crud.equal_bid(s, company_uuid, owner_uuid, self.LIST_LIMIT)
)
diff --git a/negodata/backend/tests/test_close_and_decide_fixes.py b/negodata/backend/tests/test_close_and_decide_fixes.py
index d341c42..08b19b6 100644
--- a/negodata/backend/tests/test_close_and_decide_fixes.py
+++ b/negodata/backend/tests/test_close_and_decide_fixes.py
@@ -88,7 +88,7 @@ async def test_concurrent_close_creates_only_one_next_round(clean):
"""같은 견적을 5번 동시에 close_and_decide 해도 다음 라운드는 정확히 1개만 생성된다."""
engine = clean
number = "C-CONCURRENT"
- qt = await _seed_quotation(engine, number=number, round_=1, status=QuotationStatus.ACTIVE.value)
+ qt = await _seed_quotation(engine, number=number, round_=1, status=QuotationStatus.IN_PROGRESS.value)
# 전원 미참여(미시작 세션만) → close_and_decide 가 '다음 라운드 재생성' 경로를 탄다
await _add_session(engine, qt, status=SessionStatus.CREATED.value)
await _add_session(engine, qt, status=SessionStatus.CREATED.value)
@@ -111,7 +111,7 @@ async def test_next_round_numbering_and_min_duration(clean):
number = "C-DURATION"
# start==end (협상기간 0) → 하한이 적용되지 않으면 새 라운드도 0 길이가 된다
qt = await _seed_quotation(
- engine, number=number, round_=1, status=QuotationStatus.ACTIVE.value,
+ engine, number=number, round_=1, status=QuotationStatus.IN_PROGRESS.value,
start_time=PAST, end_time=PAST,
)
await _add_session(engine, qt, status=SessionStatus.CREATED.value)
@@ -141,7 +141,7 @@ async def test_awarded_prior_round_not_counted_as_no_show(clean):
preferred_sp_yn=True, equal_bid_yn=False,
)
# round 2: 전원 미참여 → 미참여 재생성이 일어나야 한다(round 1 은 미참여로 세면 안 됨)
- qt2 = await _seed_quotation(engine, number=number, round_=2, status=QuotationStatus.ACTIVE.value)
+ qt2 = await _seed_quotation(engine, number=number, round_=2, status=QuotationStatus.IN_PROGRESS.value)
await _add_session(engine, qt2, status=SessionStatus.CREATED.value)
service = QuotationService(QuotationCRUD())
@@ -167,7 +167,7 @@ async def test_no_show_prior_round_consumes_budget(clean):
preferred_sp_yn=False, equal_bid_yn=False,
)
# round 2: 또 전원 미참여 → 한도 도달이라 재생성 없이 그냥 마감
- qt2 = await _seed_quotation(engine, number=number, round_=2, status=QuotationStatus.ACTIVE.value)
+ qt2 = await _seed_quotation(engine, number=number, round_=2, status=QuotationStatus.IN_PROGRESS.value)
await _add_session(engine, qt2, status=SessionStatus.CREATED.value)
service = QuotationService(QuotationCRUD())
diff --git a/negodata/backend/tests/test_features.py b/negodata/backend/tests/test_features.py
index 75792ca..99e599b 100644
--- a/negodata/backend/tests/test_features.py
+++ b/negodata/backend/tests/test_features.py
@@ -55,7 +55,7 @@ async def test_quotation_create(client, company_id):
"version_id": str(uuid.uuid4()),
"name": "견적A",
"type": QuotationType.REQUOTE.value,
- "status": QuotationStatus.ACTIVE.value,
+ "status": QuotationStatus.IN_PROGRESS.value,
"start_time": "2026-06-16T00:00:00",
"end_time": "2026-06-17T00:00:00",
}
diff --git a/negodata/backend/tests/test_scheduler.py b/negodata/backend/tests/test_scheduler.py
index 73abe7d..2d26d49 100644
--- a/negodata/backend/tests/test_scheduler.py
+++ b/negodata/backend/tests/test_scheduler.py
@@ -30,7 +30,7 @@ async def clean(db_engine):
# ----- 시드 헬퍼 (FK 미설정이라 user/item/supplier 없이 임의 uuid 로 충분) -----
-async def _add_quotation(engine, *, status=QuotationStatus.ACTIVE.value, end_time=PAST, deleted=False):
+async def _add_quotation(engine, *, status=QuotationStatus.IN_PROGRESS.value, end_time=PAST, deleted=False):
qt_id = uuid.uuid4()
async with engine.begin() as conn:
await conn.execute(
@@ -81,38 +81,38 @@ async def _quotation_row(engine, qt_id):
# ----- 잡① close_expired_quotations : 대상 선정(마감시각 지난 미마감만) -----
async def test_close_expired_picks_only_due_and_open(clean):
engine = clean
- due = await _add_quotation(engine, status=QuotationStatus.ACTIVE.value, end_time=PAST)
- future = await _add_quotation(engine, status=QuotationStatus.ACTIVE.value, end_time=FUTURE)
+ due = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=PAST)
+ future = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=FUTURE)
already = await _add_quotation(engine, status=QuotationStatus.CLOSED.value, end_time=PAST)
- deleted = await _add_quotation(engine, status=QuotationStatus.ACTIVE.value, end_time=PAST, deleted=True)
+ deleted = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=PAST, deleted=True)
n = await jobs.close_expired_quotations()
assert n == 1 # 마감 대상은 due 1건뿐
assert (await _quotation_row(engine, due)).status == QuotationStatus.CLOSED.value
- assert (await _quotation_row(engine, future)).status == QuotationStatus.ACTIVE.value # 미래 → 안 건드림
+ assert (await _quotation_row(engine, future)).status == QuotationStatus.IN_PROGRESS.value # 미래 → 안 건드림
assert (await _quotation_row(engine, already)).status == QuotationStatus.CLOSED.value # 원래부터 CLOSED
- assert (await _quotation_row(engine, deleted)).status == QuotationStatus.ACTIVE.value # 삭제분 → 제외
+ assert (await _quotation_row(engine, deleted)).status == QuotationStatus.IN_PROGRESS.value # 삭제분 → 제외
# ----- 잡② close_negotiated_quotations : 대상 선정(전 세션 종결 + 세션 1개+) -----
async def test_close_negotiated_picks_when_all_sessions_ended(clean):
engine = clean
# 전 세션 종결(거부) → 대상
- ended = await _add_quotation(engine, status=QuotationStatus.ACTIVE.value, end_time=FUTURE)
+ ended = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=FUTURE)
await _add_session(engine, ended, status=SessionStatus.REJECTED.value)
# 진행중 세션 하나라도 있으면 → 제외
- pending = await _add_quotation(engine, status=QuotationStatus.ACTIVE.value, end_time=FUTURE)
+ pending = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=FUTURE)
await _add_session(engine, pending, status=SessionStatus.DONE.value, bid_price=100)
await _add_session(engine, pending, status=SessionStatus.IN_PROGRESS.value)
# 세션 0개 → 제외
- no_session = await _add_quotation(engine, status=QuotationStatus.ACTIVE.value, end_time=FUTURE)
+ no_session = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=FUTURE)
await jobs.close_negotiated_quotations()
assert (await _quotation_row(engine, ended)).status == QuotationStatus.CLOSED.value
- assert (await _quotation_row(engine, pending)).status == QuotationStatus.ACTIVE.value
- assert (await _quotation_row(engine, no_session)).status == QuotationStatus.ACTIVE.value
+ assert (await _quotation_row(engine, pending)).status == QuotationStatus.IN_PROGRESS.value
+ assert (await _quotation_row(engine, no_session)).status == QuotationStatus.IN_PROGRESS.value
# ----- close_and_decide 위임 결과 스모크(잡①을 통해) -----
diff --git a/negodata/front/src/api/generated/model/dashboardScope.ts b/negodata/front/src/api/generated/model/dashboardScope.ts
index 0e8fce2..a6d1137 100644
--- a/negodata/front/src/api/generated/model/dashboardScope.ts
+++ b/negodata/front/src/api/generated/model/dashboardScope.ts
@@ -10,8 +10,10 @@ import type { DashboardEmailUnsent } from './dashboardEmailUnsent';
export interface DashboardScope {
in_progress?: number;
this_month?: number;
+ awarded_this_month?: number;
deadline_soon?: DashboardActionList;
email_unsent?: DashboardEmailUnsent;
+ awarded?: DashboardActionList;
equal_bid?: DashboardActionList;
ruptured?: DashboardActionList;
}
diff --git a/negodata/front/src/api/generated/model/deliveryType.ts b/negodata/front/src/api/generated/model/deliveryType.ts
index 2f51973..b1bd428 100644
--- a/negodata/front/src/api/generated/model/deliveryType.ts
+++ b/negodata/front/src/api/generated/model/deliveryType.ts
@@ -13,7 +13,7 @@ export type DeliveryType = typeof DeliveryType[keyof typeof DeliveryType];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const DeliveryType = {
- PARTNER: 1,
+ SUPPLIER: 1,
COURIER: 2,
PICKUP: 3,
} as const;
diff --git a/negodata/front/src/api/generated/model/quotationStatus.ts b/negodata/front/src/api/generated/model/quotationStatus.ts
index 1758d54..7d526fd 100644
--- a/negodata/front/src/api/generated/model/quotationStatus.ts
+++ b/negodata/front/src/api/generated/model/quotationStatus.ts
@@ -14,7 +14,6 @@ export type QuotationStatus = typeof QuotationStatus[keyof typeof QuotationStatu
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const QuotationStatus = {
CREATED: 1,
- ACTIVE: 2,
+ IN_PROGRESS: 2,
CLOSED: 3,
- ON_HOLD: 4,
} as const;
diff --git a/negodata/front/src/features/dashboard/components/ActionWidget.tsx b/negodata/front/src/features/dashboard/components/ActionWidget.tsx
new file mode 100644
index 0000000..34a8828
--- /dev/null
+++ b/negodata/front/src/features/dashboard/components/ActionWidget.tsx
@@ -0,0 +1,72 @@
+import type { ElementType, ReactNode } from 'react';
+import { Card, CardContent } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { Typography } from '@/components/ui/typography';
+import { cn } from '@/lib/utils';
+import { TONE_CHIP, type Tone } from '../tones';
+
+// 대시보드 액션 위젯 공용 셸: 컬러 아이콘칩·제목·total 배지·빈상태. 본문(행 목록)은 children 으로 받는다.
+export function ActionWidget({
+ title,
+ icon: Icon,
+ tone,
+ total,
+ empty,
+ emptyText = '처리할 항목 없음',
+ children,
+}: {
+ title: string;
+ icon: ElementType;
+ tone: Tone;
+ total: number;
+ empty: boolean;
+ emptyText?: string;
+ children: ReactNode;
+}) {
+ return (
+
+
+
+
+
+
+
+
+ {title}
+
+
+
{total}
+
+ {empty ? (
+ {emptyText}
+ ) : (
+ {children}
+ )}
+
+
+ );
+}
+
+// 위젯 안의 클릭 가능한 행(견적 1건 → 상세 딥링크). 우측 슬롯에 D-n·미발송 배지 등을 건다.
+export function ActionRow({
+ name,
+ right,
+ onClick,
+}: {
+ name?: string;
+ right?: ReactNode;
+ onClick: () => void;
+}) {
+ return (
+
+ );
+}
diff --git a/negodata/front/src/features/dashboard/components/DashboardHero.tsx b/negodata/front/src/features/dashboard/components/DashboardHero.tsx
new file mode 100644
index 0000000..57e23a6
--- /dev/null
+++ b/negodata/front/src/features/dashboard/components/DashboardHero.tsx
@@ -0,0 +1,29 @@
+import { HelpCircle } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { Typography } from '@/components/ui/typography';
+import { useAuth } from '@/features/auth/useAuth';
+
+// 대시보드 상단 환영 배너. 제품 소개는 온보딩(이용안내 모달)이 맡고, 여기선 누구의/어느 회사 대시보드인지만 보여준다.
+// 배지=현재 로그인 회사, 제목=사용자 이름 인사. 목업의 그라데이션 대신 primary 틴트 패널로 절제.
+export function DashboardHero({ onOpenGuide }: { onOpenGuide: () => void }) {
+ const { user } = useAuth();
+ const name = user?.name?.trim();
+ const company = user?.company?.trim();
+
+ return (
+
+
+ {company && {company}}
+ {name ? `${name}님, 환영합니다` : '환영합니다'}
+
+ 견적 생성부터 초청메일·협상·마감·낙찰까지, 지금 처리할 일을 아래에서 한눈에 봅니다.
+
+
+
+
+ );
+}
diff --git a/negodata/front/src/features/dashboard/components/DeadlineWidget.tsx b/negodata/front/src/features/dashboard/components/DeadlineWidget.tsx
new file mode 100644
index 0000000..cca2478
--- /dev/null
+++ b/negodata/front/src/features/dashboard/components/DeadlineWidget.tsx
@@ -0,0 +1,28 @@
+import { Clock } from 'lucide-react';
+import { Badge } from '@/components/ui/badge';
+import type { DashboardActionList } from '@/api/generated/model/dashboardActionList';
+import { ActionWidget, ActionRow } from './ActionWidget';
+import { fmtDeadline } from '../fmt';
+
+// 마감 임박: 견적명 + 우측에 마감 D-n.
+export function DeadlineWidget({
+ data,
+ onOpen,
+}: {
+ data?: DashboardActionList;
+ onOpen: (qtId: string) => void;
+}) {
+ const items = data?.items ?? [];
+ return (
+
+ {items.map((it) => (
+ onOpen(it.qt_id)}
+ right={{fmtDeadline(it.end_time)}}
+ />
+ ))}
+
+ );
+}
diff --git a/negodata/front/src/features/dashboard/components/EmailUnsentWidget.tsx b/negodata/front/src/features/dashboard/components/EmailUnsentWidget.tsx
new file mode 100644
index 0000000..4995b0f
--- /dev/null
+++ b/negodata/front/src/features/dashboard/components/EmailUnsentWidget.tsx
@@ -0,0 +1,27 @@
+import { Mail } from 'lucide-react';
+import { Badge } from '@/components/ui/badge';
+import type { DashboardEmailUnsent } from '@/api/generated/model/dashboardEmailUnsent';
+import { ActionWidget, ActionRow } from './ActionWidget';
+
+// 메일 미발송: 견적 단위로 묶고 우측에 미발송 협력사 수. 발송 전엔 협상이 시작 안 되므로 미발송 수는 destructive 배지.
+export function EmailUnsentWidget({
+ data,
+ onOpen,
+}: {
+ data?: DashboardEmailUnsent;
+ onOpen: (qtId: string) => void;
+}) {
+ const items = data?.quotations ?? [];
+ return (
+
+ {items.map((it) => (
+ onOpen(it.qt_id)}
+ right={미발송 {it.unsent_count ?? 0}곳}
+ />
+ ))}
+
+ );
+}
diff --git a/negodata/front/src/features/dashboard/components/KpiTile.tsx b/negodata/front/src/features/dashboard/components/KpiTile.tsx
new file mode 100644
index 0000000..ac9a86d
--- /dev/null
+++ b/negodata/front/src/features/dashboard/components/KpiTile.tsx
@@ -0,0 +1,39 @@
+import type { ElementType } from 'react';
+import { Card, CardContent } from '@/components/ui/card';
+import { Typography } from '@/components/ui/typography';
+import { cn } from '@/lib/utils';
+import { TONE_CHIP, type Tone } from '../tones';
+
+// 대시보드 KPI 숫자 타일. 컬러 아이콘칩 + 값 + 라벨. warn 지표는 값이 0보다 클 때만 숫자를 destructive 로 강조한다.
+export function KpiTile({
+ label,
+ value,
+ icon: Icon,
+ tone,
+ alertWhenPositive,
+}: {
+ label: string;
+ value: number;
+ icon: ElementType;
+ tone: Tone;
+ alertWhenPositive?: boolean;
+}) {
+ const alert = !!alertWhenPositive && value > 0;
+ return (
+
+
+
+
+
+
+
+ {value}
+
+
+ {label}
+
+
+
+
+ );
+}
diff --git a/negodata/front/src/features/dashboard/components/LifecycleStepper.tsx b/negodata/front/src/features/dashboard/components/LifecycleStepper.tsx
new file mode 100644
index 0000000..abbdeab
--- /dev/null
+++ b/negodata/front/src/features/dashboard/components/LifecycleStepper.tsx
@@ -0,0 +1,64 @@
+import { ChevronRight } from 'lucide-react';
+import { Card, CardContent } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { Typography } from '@/components/ui/typography';
+import { cn } from '@/lib/utils';
+import { STEPS, ACTOR_CLASS } from '@/features/onboarding/steps';
+
+// 견적 생성~낙찰 6단계 흐름을 가로 스텝퍼로 한눈에. 단계 정의는 온보딩 모달과 steps.ts 에서 공유한다.
+// 3단계(초청메일)는 수동 발송이 필수라 destructive 톤으로 강조.
+export function LifecycleStepper() {
+ return (
+
+
+
+ 협상은 이렇게 진행됩니다
+
+ 견적 생성부터 낙찰까지 6단계로 흐릅니다. 3단계 초청메일을 직접 보내야 협상이 시작됩니다.
+
+
+
+
+ {STEPS.map((step) => {
+ const Icon = step.icon;
+ return (
+
+
+
+ {`0${step.num}`}
+
+ [{step.actor}]
+
+
+
+
+ {step.title}
+
+
+ {step.short}
+
+
+
+ {/* 큰 화면에서 단계 사이 화살표 */}
+ {step.num < STEPS.length && (
+
+ )}
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/negodata/front/src/features/dashboard/components/RefWidget.tsx b/negodata/front/src/features/dashboard/components/RefWidget.tsx
new file mode 100644
index 0000000..f93370b
--- /dev/null
+++ b/negodata/front/src/features/dashboard/components/RefWidget.tsx
@@ -0,0 +1,37 @@
+import type { ElementType } from 'react';
+import type { DashboardActionList } from '@/api/generated/model/dashboardActionList';
+import { ActionWidget, ActionRow } from './ActionWidget';
+import type { Tone } from '../tones';
+
+// 동가 / 결렬: 견적명만 나열(수동 확인 대상). 제목·아이콘·톤은 호출부에서 지정.
+export function RefWidget({
+ title,
+ icon,
+ tone,
+ data,
+ onOpen,
+ emptyText,
+}: {
+ title: string;
+ icon: ElementType;
+ tone: Tone;
+ data?: DashboardActionList;
+ onOpen: (qtId: string) => void;
+ emptyText?: string;
+}) {
+ const items = data?.items ?? [];
+ return (
+
+ {items.map((it) => (
+ onOpen(it.qt_id)} />
+ ))}
+
+ );
+}
diff --git a/negodata/front/src/features/dashboard/components/ResultExamples.tsx b/negodata/front/src/features/dashboard/components/ResultExamples.tsx
new file mode 100644
index 0000000..49c2a98
--- /dev/null
+++ b/negodata/front/src/features/dashboard/components/ResultExamples.tsx
@@ -0,0 +1,109 @@
+import { Award, RefreshCw } from 'lucide-react';
+import { Card, CardContent } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { Typography } from '@/components/ui/typography';
+import { cn } from '@/lib/utils';
+import { TONE_CHIP, type Tone } from '../tones';
+
+// 낙찰 판정 결과가 어떻게 보이는지 보여주는 예시(정적). 실제 동가·결렬 현황은 위 스코프 위젯에서 다룬다.
+// 데모 수치(금액·절감률)는 설명용이며 실데이터가 아니다.
+
+interface ResultRow {
+ label: string;
+ value: string;
+}
+
+interface ResultCase {
+ tone: Tone;
+ tag: string;
+ qtNo: string;
+ title: string;
+ rows: ResultRow[];
+ outcome: string;
+}
+
+const CASES: ResultCase[] = [
+ {
+ tone: 'amber',
+ tag: '동가 → 차수 재생성',
+ qtNo: 'QT-20260611-A',
+ title: 'MRO 안전화 일괄 조달 (1차수 마감)',
+ rows: [
+ { label: '최저 투찰가', value: '42,000원' },
+ { label: '동가 투찰사', value: '2개사' },
+ ],
+ outcome: '최저가가 같은 2개사가 나와 2차수 협상이 자동 재생성·발송되었습니다.',
+ },
+ {
+ tone: 'emerald',
+ tag: '단독 최저 낙찰',
+ qtNo: 'QT-20260608-F',
+ title: '오피스 소모품 조달',
+ rows: [
+ { label: '목표가', value: '500,000원' },
+ { label: '최종 낙찰가', value: '415,000원' },
+ { label: '낙찰자', value: '단독 최저 투찰사' },
+ ],
+ outcome: '단독 최저가 투찰사로 낙찰되어 결과가 알림함으로 통지되었습니다.',
+ },
+];
+
+export function ResultExamples() {
+ return (
+
+
+
+ 낙찰 결과는 이렇게 정리됩니다
+ 예시
+
+
+ {CASES.map((c) => (
+
+ ))}
+
+
+
+ );
+}
+
+function ResultCaseCard({ data }: { data: ResultCase }) {
+ const Icon = data.tone === 'emerald' ? Award : RefreshCw;
+ return (
+
+
+
+
+
+
+
+ {data.tag}
+
+
+
+ {data.qtNo}
+
+
+
+
+ {data.title}
+
+
+
+ {data.rows.map((r) => (
+
+
+ {r.label}
+
+
+ {r.value}
+
+
+ ))}
+
+
+
+ {data.outcome}
+
+
+ );
+}
diff --git a/negodata/front/src/features/dashboard/components/ScopeSection.tsx b/negodata/front/src/features/dashboard/components/ScopeSection.tsx
new file mode 100644
index 0000000..f6666c0
--- /dev/null
+++ b/negodata/front/src/features/dashboard/components/ScopeSection.tsx
@@ -0,0 +1,42 @@
+import { Activity, CalendarPlus, Award, Ban } from 'lucide-react';
+import { Typography } from '@/components/ui/typography';
+import type { DashboardScope } from '@/api/generated/model/dashboardScope';
+import { KpiTile } from './KpiTile';
+import { DeadlineWidget } from './DeadlineWidget';
+import { EmailUnsentWidget } from './EmailUnsentWidget';
+import { RefWidget } from './RefWidget';
+
+// 스코프 1개(회사 전체 / 내 견적) 블록: 라벨 + 요약 KPI + 액션 위젯.
+// KPI = 리스트 없는 순수 지표만.
+// 위젯 = 관리자가 직접 처리해야 하는 것만.
+export function ScopeSection({
+ label,
+ scope,
+ onOpen,
+}: {
+ label: string;
+ scope?: DashboardScope;
+ onOpen: (qtId: string) => void;
+}) {
+ const s = scope ?? {};
+ return (
+
+
+
+ {label}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/negodata/front/src/features/dashboard/components/StartChecklist.tsx b/negodata/front/src/features/dashboard/components/StartChecklist.tsx
new file mode 100644
index 0000000..531eb33
--- /dev/null
+++ b/negodata/front/src/features/dashboard/components/StartChecklist.tsx
@@ -0,0 +1,136 @@
+import { useNavigate } from 'react-router';
+import { CheckCircle2, Circle, AlertTriangle } from 'lucide-react';
+import { Card, CardContent } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Typography } from '@/components/ui/typography';
+import { cn } from '@/lib/utils';
+
+// 시작하기 체크리스트 — 신규 유저 온보딩 예시(정적). 항목 진행 수치는 데모용 고정값이고,
+// 버튼만 실제 등록 페이지로 이동시킨다. 실데이터 연동(상품/협력사/카드 카운트) 전까지 예시로 둔다.
+
+type ItemStatus = 'done' | 'warn' | 'todo';
+
+interface ChecklistItem {
+ status: ItemStatus;
+ title: string;
+ desc: string;
+ meta?: string; // 우측 완료 배지 텍스트
+ actionLabel?: string;
+ to?: string;
+}
+
+const ITEMS: ChecklistItem[] = [
+ {
+ status: 'done',
+ title: '상품 등록 완료',
+ desc: '자동 가격협상에 부칠 품목을 등록했습니다.',
+ meta: '12개',
+ },
+ {
+ status: 'done',
+ title: '협력사 등록 완료',
+ desc: '투찰에 참여할 공급 협력사를 등록했습니다.',
+ meta: '5개사',
+ },
+ {
+ status: 'warn',
+ title: '협력사 담당자 이메일 누락',
+ desc: '이메일이 없으면 초청메일을 보낼 수 없어 협상이 시작되지 않습니다.',
+ actionLabel: '이메일 채우기',
+ to: '/partners',
+ },
+ {
+ status: 'todo',
+ title: '첫 견적 만들기',
+ desc: '상품·협력사를 묶어 목표가를 정하고 자동협상을 시작합니다.',
+ actionLabel: '견적 생성',
+ to: '/quotation',
+ },
+];
+
+const DONE = ITEMS.filter((i) => i.status === 'done').length;
+
+export function StartChecklist() {
+ const navigate = useNavigate();
+ return (
+
+
+
+
+
+ 시작하기 체크리스트
+ 예시
+
+
필수 준비를 끝내야 첫 자동협상을 시작할 수 있습니다.
+
+
+ {DONE} / {ITEMS.length} 완료
+
+
+
+ {/* 진행률 바 */}
+
+
+
+ {ITEMS.map((item) => (
+ item.to && navigate(item.to)} />
+ ))}
+
+
+
+ );
+}
+
+function ChecklistRow({ item, onAction }: { item: ChecklistItem; onAction: () => void }) {
+ const warn = item.status === 'warn';
+ return (
+
+
+
+
+
+ {item.title}
+
+
+ {item.desc}
+
+
+
+
+ {item.meta ? (
+
+ {item.meta}
+
+ ) : item.actionLabel ? (
+
+ ) : null}
+
+ );
+}
+
+function StatusIcon({ status }: { status: ItemStatus }) {
+ if (status === 'done') return ;
+ if (status === 'warn') return ;
+ return ;
+}
diff --git a/negodata/front/src/features/dashboard/fmt.ts b/negodata/front/src/features/dashboard/fmt.ts
new file mode 100644
index 0000000..8cf5339
--- /dev/null
+++ b/negodata/front/src/features/dashboard/fmt.ts
@@ -0,0 +1,21 @@
+// 백엔드 end_time 은 naive UTC ISO(타임존 표기 없음) → 'Z' 를 붙여 UTC 로 파싱한다.
+const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
+
+// UTC 절대시각(ms) → 한국시간(KST) 기준 '그 날짜의 자정'을 UTC ms 앵커로 반환.
+// +9h 시프트 후 UTC 게터로 읽으면 KST 벽시계 날짜가 된다(브라우저 타임존과 무관하게 항상 KST).
+function kstDayStart(ms: number): number {
+ const shifted = new Date(ms + KST_OFFSET_MS);
+ return Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate());
+}
+
+// 마감까지 남은 '한국시간 캘린더 일수'로 D-n 표기. 경과시간이 아니라 날짜 차이라
+// 오늘 마감(시각 무관)은 항상 D-DAY, 내일이면 D-1.
+export function fmtDeadline(s?: string | null): string {
+ if (!s) return '';
+ const due = new Date(s.endsWith('Z') ? s : `${s}Z`);
+ if (Number.isNaN(due.getTime())) return '';
+ const days = Math.round((kstDayStart(due.getTime()) - kstDayStart(Date.now())) / 86_400_000);
+ if (days < 0) return '지남';
+ if (days === 0) return 'D-DAY';
+ return `D-${days}`;
+}
diff --git a/negodata/front/src/features/dashboard/index.ts b/negodata/front/src/features/dashboard/index.ts
new file mode 100644
index 0000000..8c503d2
--- /dev/null
+++ b/negodata/front/src/features/dashboard/index.ts
@@ -0,0 +1,5 @@
+export { DashboardHero } from './components/DashboardHero';
+export { LifecycleStepper } from './components/LifecycleStepper';
+export { ScopeSection } from './components/ScopeSection';
+export { StartChecklist } from './components/StartChecklist';
+export { ResultExamples } from './components/ResultExamples';
diff --git a/negodata/front/src/features/dashboard/tones.ts b/negodata/front/src/features/dashboard/tones.ts
new file mode 100644
index 0000000..fdbb304
--- /dev/null
+++ b/negodata/front/src/features/dashboard/tones.ts
@@ -0,0 +1,12 @@
+// 아이콘 칩 색(배경+글자). StatusPill 의 PILL_TONE 과 같은 계열 — 코드베이스 전반에서 쓰는 공용 톤이라
+// 디자인토큰 범위 안에서 색만 입히는 용도. 생짜 그라데이션 대신 이 맵으로 통일한다.
+export type Tone = 'blue' | 'emerald' | 'amber' | 'rose' | 'purple' | 'zinc';
+
+export const TONE_CHIP: Record = {
+ blue: 'bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-300',
+ emerald: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300',
+ amber: 'bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300',
+ rose: 'bg-rose-100 text-rose-700 dark:bg-rose-950/40 dark:text-rose-300',
+ purple: 'bg-purple-100 text-purple-700 dark:bg-purple-950/40 dark:text-purple-300',
+ zinc: 'bg-muted text-muted-foreground',
+};
diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx
index 50c335e..32e7e5d 100644
--- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx
+++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx
@@ -97,7 +97,6 @@ export function SessionsStatusTab({
- 세션 ID
협력사
협상 URL
초청메일
@@ -115,14 +114,13 @@ export function SessionsStatusTab({
{sessionViews.length === 0 && (
-
+
참여 중인 협상 세션이 없습니다. (리스트가 비어 있습니다)
)}
{sessionViews.map((sess) => (
- {sess.session_id}
{sess.supplier_name}
diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/StatusPill.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/StatusPill.tsx
index 39c9a7c..42bf4f7 100644
--- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/StatusPill.tsx
+++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/StatusPill.tsx
@@ -51,7 +51,7 @@ const QSTATUS_TONE: Record
= {
box: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-700/50',
dot: 'bg-amber-500',
},
- [QuotationStatus.ACTIVE]: {
+ [QuotationStatus.IN_PROGRESS]: {
box: 'bg-emerald-100 text-emerald-800 border-emerald-300 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-700/50',
dot: 'bg-emerald-500',
},
@@ -59,10 +59,6 @@ const QSTATUS_TONE: Record = {
box: 'bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-950/40 dark:text-blue-300 dark:border-blue-700/50',
dot: 'bg-blue-500',
},
- [QuotationStatus.ON_HOLD]: {
- box: 'bg-rose-100 text-rose-800 border-rose-300 dark:bg-rose-950/40 dark:text-rose-300 dark:border-rose-700/50',
- dot: 'bg-rose-500',
- },
};
const QSTATUS_FALLBACK = { box: 'bg-zinc-100 text-zinc-800 border-zinc-300', dot: 'bg-zinc-500' };
diff --git a/negodata/front/src/features/quotations/components/QuotationTable.tsx b/negodata/front/src/features/quotations/components/QuotationTable.tsx
index 5e65321..c9006c0 100644
--- a/negodata/front/src/features/quotations/components/QuotationTable.tsx
+++ b/negodata/front/src/features/quotations/components/QuotationTable.tsx
@@ -26,12 +26,10 @@ const statusBadgeClass = (status?: number | null) => {
switch (status) {
case QuotationStatus.CREATED:
return 'bg-yellow-50 text-yellow-700 border-yellow-300';
- case QuotationStatus.ACTIVE:
+ case QuotationStatus.IN_PROGRESS:
return 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/25 dark:text-emerald-400 border-emerald-300/40';
case QuotationStatus.CLOSED:
return 'bg-blue-50 text-blue-700 border-blue-300';
- case QuotationStatus.ON_HOLD:
- return 'bg-red-50 text-red-700 border-red-300';
default:
return 'bg-zinc-100 text-zinc-600';
}
@@ -53,7 +51,7 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, fo
const productName = product?.name ?? est.productName; // 목록에 없으면 서버 조인 상품명으로 폴백
return (
-
+
{est.title}
diff --git a/negodata/front/src/features/quotations/types.ts b/negodata/front/src/features/quotations/types.ts
index 49c00d7..c4cbe80 100644
--- a/negodata/front/src/features/quotations/types.ts
+++ b/negodata/front/src/features/quotations/types.ts
@@ -118,13 +118,12 @@ function formatDueDate(end?: string | null): string {
return toKstDateTime(end) ?? end;
}
-export type QtStatusKey = '견적생성' | '견적진행중' | '견적마감' | '협상보류';
+export type QtStatusKey = '견적생성' | '견적진행중' | '견적마감';
export const QUOTATION_STATUS_LABEL: Record = {
[QuotationStatus.CREATED]: '견적생성',
- [QuotationStatus.ACTIVE]: '견적진행중',
+ [QuotationStatus.IN_PROGRESS]: '견적진행중',
[QuotationStatus.CLOSED]: '견적마감',
- [QuotationStatus.ON_HOLD]: '협상보류',
};
export const quotationStatusLabel = (s?: number | null): string =>
s != null ? QUOTATION_STATUS_LABEL[s as QuotationStatus] ?? String(s) : '';
diff --git a/negodata/front/src/lib/enumLabels.ts b/negodata/front/src/lib/enumLabels.ts
index 0c2efdd..7ae00b7 100644
--- a/negodata/front/src/lib/enumLabels.ts
+++ b/negodata/front/src/lib/enumLabels.ts
@@ -1,7 +1,7 @@
import { DeliveryType, UserRole, SupplierType, CardUsageType, UserStatus } from '@/api/generated/model';
export const DELIVERY_TYPE_LABEL: Record = {
- [DeliveryType.PARTNER]: '협력사배송',
+ [DeliveryType.SUPPLIER]: '협력사배송',
[DeliveryType.COURIER]: '지정택배배송',
[DeliveryType.PICKUP]: '픽업배송',
};
diff --git a/negodata/front/src/pages/dashboard.tsx b/negodata/front/src/pages/dashboard.tsx
index 45d1e43..6f9c979 100644
--- a/negodata/front/src/pages/dashboard.tsx
+++ b/negodata/front/src/pages/dashboard.tsx
@@ -1,199 +1,49 @@
-import type { ReactNode } from 'react';
+import { useEffect, useState } 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 { DashboardHero, ScopeSection } from '@/features/dashboard';
+import { OnboardingGuideModal } from '@/features/onboarding/OnboardingGuideModal';
+import { useAuth } from '@/features/auth/useAuth';
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)된다. 읽기 전용.
+// 견적 생성~마감~선정을 한눈에 보는 대시보드.
+// 회사 전체 스코프는 최고관리자만, 일반 사용자는 '내 견적'만 본다. 위젯 행은 클릭 시 견적 상세로 딥링크. 읽기 전용.
+const ONBOARDING_SEEN_KEY = 'negodata_onboarding_seen';
+
export default function DashboardPage() {
const navigate = useNavigate();
+ const { user } = useAuth();
+ const isOwner = user?.role === '최고관리자';
const { data, isLoading, isError } = useGetDashboardSummary();
+ const [guideOpen, setGuideOpen] = useState(false);
+
+ // 첫 방문 시 1회 자동 노출(localStorage). 이후엔 상단 "이용안내" 버튼으로만 연다.
+ useEffect(() => {
+ if (!localStorage.getItem(ONBOARDING_SEEN_KEY)) {
+ setGuideOpen(true);
+ localStorage.setItem(ONBOARDING_SEEN_KEY, '1');
+ }
+ }, []);
const openQuotation = (qtId: string) => navigate(`/quotation?detail=${qtId}`);
- if (isLoading) {
- return (
-
- 대시보드를 불러오는 중…
-
- );
- }
- if (isError || !data) {
- return (
-
- 대시보드를 불러오지 못했습니다.
-
- );
- }
-
return (
-
-
+ setGuideOpen(true)} />
+
+ {isLoading ? (
+ 대시보드를 불러오는 중…
+ ) : isError || !data ? (
+ 대시보드를 불러오지 못했습니다.
+ ) : (
+ <>
+ {isOwner && }
+
+ >
+ )}
+
+
);
}
-
-// ----- 스코프 섹션(회사 전체 / 내 견적 공용) -----
-function ScopeSection({
- title,
- scope,
- onOpen,
-}: {
- title: string;
- scope?: DashboardScope;
- onOpen: (qtId: string) => void;
-}) {
- const s = scope ?? {};
- return (
-
- {title}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-// ----- KPI 숫자 카드 -----
-function StatCard({ label, value, warn }: { label: string; value: number; warn?: boolean }) {
- const danger = !!warn && value > 0;
- return (
-
-
- {label}
-
- {value}
-
-
-
- );
-}
-
-// ----- 액션 위젯(공용 셸) -----
-function WidgetCard({
- title,
- total,
- empty,
- children,
-}: {
- title: string;
- total: number;
- empty: boolean;
- children: ReactNode;
-}) {
- return (
-
-
-
- {title}
- {total}
-
- {empty ? (
- 처리할 항목 없음
- ) : (
- {children}
- )}
-
-
- );
-}
-
-function ActionRow({ name, right, onClick }: { name?: string; right?: ReactNode; onClick: () => void }) {
- return (
-
- );
-}
-
-// ----- 마감 임박: 견적 + 마감 D-n -----
-function DeadlineWidget({ data, onOpen }: { data?: DashboardActionList; onOpen: (qtId: string) => void }) {
- const items = data?.items ?? [];
- return (
-
- {items.map((it) => (
- onOpen(it.qt_id)}
- right={{fmtDeadline(it.end_time)}}
- />
- ))}
-
- );
-}
-
-// ----- 메일 미발송: 견적 단위로 묶고 미발송 협력사 수 -----
-function EmailUnsentWidget({ data, onOpen }: { data?: DashboardEmailUnsent; onOpen: (qtId: string) => void }) {
- const items = data?.quotations ?? [];
- return (
-
- {items.map((it) => (
- onOpen(it.qt_id)}
- right={미발송 {it.unsent_count ?? 0}곳}
- />
- ))}
-
- );
-}
-
-// ----- 동가 / 결렬: 견적명만 -----
-function RefWidget({
- title,
- data,
- onOpen,
-}: {
- title: string;
- data?: DashboardActionList;
- onOpen: (qtId: string) => void;
-}) {
- const items = data?.items ?? [];
- return (
-
- {items.map((it) => (
- onOpen(it.qt_id)} />
- ))}
-
- );
-}
-
-// 백엔드 end_time 은 naive UTC ISO(타임존 표기 없음) → 'Z' 를 붙여 UTC 로 파싱하고 남은 일수로 D-n 표기.
-function fmtDeadline(s?: string | null): string {
- if (!s) return '';
- const due = new Date(s.endsWith('Z') ? s : `${s}Z`);
- const days = Math.ceil((due.getTime() - Date.now()) / 86_400_000);
- if (Number.isNaN(days)) return '';
- if (days < 0) return '지남';
- if (days === 0) return 'D-DAY';
- return `D-${days}`;
-}
diff --git a/negodata/front/src/tokens.css b/negodata/front/src/tokens.css
index f06a9ef..9d7016f 100644
--- a/negodata/front/src/tokens.css
+++ b/negodata/front/src/tokens.css
@@ -28,6 +28,7 @@
--color-secondary-foreground: var(--secondary-foreground);
--color-accent: var(--accent);
--color-destructive: var(--destructive);
+ --color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success);
--color-warning: var(--warning);
--color-ring: var(--ring);
@@ -51,6 +52,7 @@
--secondary-foreground: #171717;
--accent: #f5f5f5;
--destructive: #e7000b;
+ --destructive-foreground: #fafafa;
--success: #10b981;
--warning: #f59e0b;
--ring: #a1a1a1;
@@ -89,6 +91,7 @@
--secondary-foreground: #fafafa;
--accent: #262626;
--destructive: #ff6467;
+ --destructive-foreground: #fafafa;
--success: #10b981;
--warning: #f59e0b;
--ring: #737373;
@@ -131,6 +134,7 @@
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
+ --color-destructive-foreground: var(--destructive-foreground);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
diff --git a/postgres-init/01-schema.sql b/postgres-init/01-schema.sql
index db6afd9..6ab5c50 100644
--- a/postgres-init/01-schema.sql
+++ b/postgres-init/01-schema.sql
@@ -53,7 +53,7 @@ CREATE TABLE IF NOT EXISTS company.companies (
contact_number VARCHAR(20) NULL, -- 대표 연락처
website_url VARCHAR(255) NULL, -- 홈페이지 URL
industry SMALLINT NULL, -- 업종 ( 필요한 만큼 숫자에 매핑하여 사용 )
- status SMALLINT NOT NULL DEFAULT 1, -- 상태: 1=active, 2=inactive
+ status SMALLINT NOT NULL DEFAULT 1, -- 상태: 1=active(활성), 2=inactive(비활성)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
@@ -68,8 +68,8 @@ CREATE TABLE IF NOT EXISTS company.users (
email VARCHAR(255) NULL, -- 이메일
contact_number VARCHAR(20) NULL, -- 연락처
last_accessed_at TIMESTAMPTZ NOT NULL, -- 마지막 접속 시각
- status SMALLINT NOT NULL DEFAULT 1, -- 상태: 1=active, 2=inactive
- role SMALLINT NOT NULL DEFAULT 1, -- 권한: 1=일반, 2=최고관리자(owner)
+ status SMALLINT NOT NULL DEFAULT 1, -- 상태: 1=active(활성), 2=inactive(비활성)
+ role SMALLINT NOT NULL DEFAULT 1, -- 권한(UserRole): 1=user(일반), 2=owner(최고관리자)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
@@ -99,8 +99,8 @@ CREATE TABLE IF NOT EXISTS supplier.supplier_users (
email VARCHAR(255) NULL, -- 이메일
contact_number VARCHAR(20) NULL, -- 연락처
last_accessed_at TIMESTAMPTZ NOT NULL, -- 마지막 접속 시각
- status SMALLINT NOT NULL DEFAULT 1, -- 상태: 1=active, 2=inactive
- role SMALLINT NOT NULL DEFAULT 1, -- 권한: 1=user, 2=manager
+ status SMALLINT NOT NULL DEFAULT 1, -- 상태: 1=active(활성), 2=inactive(비활성)
+ role SMALLINT NOT NULL DEFAULT 1, -- 권한: 1=user(유저), 2=manager(매니저)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
@@ -152,7 +152,7 @@ CREATE TABLE IF NOT EXISTS partner.items (
manufacturer VARCHAR(50) NULL, -- 제조사
made_in VARCHAR(100) NULL, -- 원산지
quantity_unit VARCHAR(50) NULL, -- 상품 취급 단위 라벨(자유입력): EA/BOX/SET/ROLL ... (ORM String 기준)
- delivery_type SMALLINT NULL, -- 배송 유형 (코드, 앱 enum 매핑)
+ delivery_type SMALLINT NULL, -- 배송 유형(DeliveryType): 1=supplier(협력사배송), 2=courier(지정택배배송), 3=pickup(픽업배송)
vat_yn BOOLEAN NULL, -- 부가세 포함 여부
delivery_fee_yn BOOLEAN NULL, -- 배송비 포함 여부
internet_lowest_price_yn BOOLEAN NOT NULL DEFAULT FALSE, -- 최저가 솔루션의 원자성을 보존하기 위한 보조 컬럼
@@ -200,7 +200,7 @@ CREATE TABLE IF NOT EXISTS card.nego_cards (
number VARCHAR(10) NULL, -- 식별번호
script VARCHAR(255) NULL, -- 협상 스크립트
edit_script JSONB NULL, -- 편집된 스크립트(JSON)
- usage_type SMALLINT NOT NULL DEFAULT 1, -- 카드 적용 견적 구분(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용
+ usage_type SMALLINT NOT NULL DEFAULT 1, -- 카드 적용 견적 구분(CardUsageType): 1=common(공통), 2=new(신규견적전용), 3=reuse(재견적전용)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
@@ -213,7 +213,7 @@ CREATE TABLE IF NOT EXISTS card.wild_cards (
number VARCHAR(10) NULL, -- 식별번호
script VARCHAR(255) NULL, -- 협상 스크립트
edit_script JSONB NULL, -- 편집된 스크립트(JSON)
- usage_type SMALLINT NOT NULL DEFAULT 1, -- 카드 적용 견적 구분(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용
+ usage_type SMALLINT NOT NULL DEFAULT 1, -- 카드 적용 견적 구분(CardUsageType): 1=common(공통), 2=new(신규견적전용), 3=reuse(재견적전용)
condition VARCHAR(255) NULL, -- 커스터마이징 협상 카드이기 때문에 상세 조건을 기재해야 함
available BOOLEAN NOT NULL DEFAULT FALSE, -- 와일드 카드는 수동으로 코드에 추가해야 하기 때문에 컬럼 추가
memo VARCHAR(255) NULL, -- 사용 조건 이외에 자유롭게 적을 수 있는 메모
@@ -261,9 +261,9 @@ CREATE TABLE IF NOT EXISTS quotation.quotations (
version_id uuid NOT NULL, -- 버전(card.versions.version_id)
name VARCHAR(50) NOT NULL, -- 견적명
number VARCHAR(30) NOT NULL, -- 견적번호
- type SMALLINT NOT NULL, -- 견적 유형: 1=renego(재협상 1:1), 2=requote(재견적 1:N)
+ type SMALLINT NOT NULL, -- 견적 유형(QuotationType): 1=renego(재협상 1:1), 2=requote(재견적 1:N), 3=new_nego(신규협상 1:1), 4=new_quote(신규견적 1:N)
round INTEGER NOT NULL DEFAULT 1, -- 같은 견적 번호로 재견적 진행 시, 해당 숫자가 증가
- status SMALLINT NOT NULL, -- 진행 상태 (코드, 앱 enum 매핑)
+ status SMALLINT NOT NULL, -- 진행 상태(QuotationStatus): 1=created(생성), 2=in_progress(진행중), 3=closed(마감)
start_time TIMESTAMPTZ NOT NULL, -- 견적 시작 시각
end_time TIMESTAMPTZ NOT NULL, -- 견적 종료 시각
manager_name VARCHAR(50) NULL, -- 담당자명
@@ -271,7 +271,7 @@ CREATE TABLE IF NOT EXISTS quotation.quotations (
manager_contact_number VARCHAR(20) NULL, -- 담당자 연락처
memo VARCHAR(100) NULL, -- 메모
md_price BIGINT NULL, -- MD 제시가(원). 목표가 산정 최우선값 (견적생성 모달 입력)
- supplier_type SMALLINT NULL, -- 협력사 유형(SupplierType). 재견적 1:1 → 견적에 기록 (견적생성 모달 입력)
+ supplier_type SMALLINT NULL, -- 협력사 유형(SupplierType): 0=none(없음), 1=distribution(유통), 2=manufacture(제조), 3=sole_agency(총판). 재견적 1:1 견적에 기록
iteration INTEGER NOT NULL DEFAULT 0, -- 반복 횟수
preferred_sp_yn BOOLEAN NULL, -- 선호 공급사 지정 여부
preferred_sp_id uuid NULL, -- 선호 공급사(partner.suppliers.supplier_id)
@@ -293,16 +293,16 @@ CREATE TABLE IF NOT EXISTS negotiation.sessions (
supplier_id uuid NOT NULL, -- 대상 공급사(partner.suppliers.supplier_id)
qt_number VARCHAR(30) NOT NULL, -- 견적번호(스냅샷)
qt_round INTEGER NOT NULL, -- 견적 라운드(스냅샷)
- qt_type SMALLINT NOT NULL, -- 견적 유형(스냅샷): 1=renego, 2=requote
+ qt_type SMALLINT NOT NULL, -- 견적 유형(스냅샷, QuotationType): 1=renego(재협상 1:1), 2=requote(재견적 1:N), 3=new_nego(신규협상 1:1), 4=new_quote(신규견적 1:N)
target_price BIGINT NOT NULL, -- 목표가(원)
target_anchoring_price BIGINT NULL, -- 앵커링가(원)
- status SMALLINT NOT NULL, -- 진행 상태 (코드, 앱 enum 매핑)
+ status SMALLINT NOT NULL, -- 진행 상태(SessionStatus): 1=created(생성), 2=in_progress(진행중), 3=done(완료), 4=not_participated(미참여), 5=rejected(거부)
bid_price BIGINT NULL, -- 입찰가(원)
bid_at TIMESTAMPTZ NULL, -- 입찰 시각
end_time TIMESTAMPTZ NOT NULL, -- 세션 종료 시각
reject_reason VARCHAR(255) NULL, -- 거절 사유
reject_price BIGINT NULL, -- 거절 시 제시가(원)
- reject_delivery_type SMALLINT NULL, -- 거절 시 배송 유형 (코드, 앱 enum 매핑)
+ reject_delivery_type SMALLINT NULL, -- 거절 시 배송 유형(DeliveryType): 1=supplier(협력사배송), 2=courier(지정택배배송), 3=pickup(픽업배송)
email_sent_at TIMESTAMPTZ NULL, -- 협상 초청 메일 발송 시각(NULL=미발송). 수동 발송 버튼이 채움
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
@@ -314,11 +314,11 @@ CREATE TABLE IF NOT EXISTS negotiation.chats (
session_id uuid NOT NULL, -- 소속 세션(negotiation.sessions.session_id), session 1 : N chats
card_id uuid NULL, -- 사용된 카드(card.nego_cards/card.wild_cards)
seq INTEGER NOT NULL DEFAULT 1, -- 세션 내 메시지 순번
- sender SMALLINT NOT NULL, -- 발신자 구분 (코드, 앱 enum 매핑)
+ sender SMALLINT NOT NULL, -- 발신자 구분(ChatSender): 1=bot(봇), 2=user(유저)
target_price BIGINT NOT NULL, -- 제시 목표가(원)
card_used_yn BOOLEAN NULL, -- 카드 사용 여부
indicator_value NUMERIC(8,6) NULL, -- 소수점 까지 반환할 수도 있음 (정수부 2자리 + 소수 6자리, -99.999999~99.999999)
- card_type SMALLINT NULL, -- 카드 유형: 1=nego_card, 2=wild_card
+ card_type SMALLINT NULL, -- 카드 유형(CardType): 1=nego(협상카드), 2=wild(와일드카드)
meta JSONB NULL, -- 말풍선 표현 데이터(script/step/client_step/input_mode/input_options/chat_end). 구조화 컬럼(price/card/indicator) 외 가변 UI 필드만 보관.
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)