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, 앱에서 갱신)