[chore] negodata: enum 주석 영문값 보강 + 견적상태 3종 정리 + 대시보드 정비

- enum DDL 주석에 영문값 보강(status/role/delivery/usage_type/qt_type/supplier_type)
- 견적상태 3종(생성/진행중/마감)으로 정리(ON_HOLD 제거), 배송 PARTNER→SUPPLIER
- 대시보드 손질, 관련 테스트·negosium 견적유형 주석 동반

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-06-30 17:25:38 +09:00
parent 615167ad41
commit f19c6f57ea
36 changed files with 758 additions and 247 deletions

View File

@ -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) # 견적 시작 시각

View File

@ -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 = ""

View File

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

View File

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

View File

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

View File

@ -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 # 픽업배송

View File

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

View File

@ -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) # 결렬(낙찰자 없이 마감)

View File

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

View File

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

View File

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

View File

@ -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 위임 결과 스모크(잡①을 통해) -----

View File

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

View File

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

View File

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

View File

@ -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 (
<Card className="gap-3 py-4">
<CardContent className="space-y-2.5 px-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={cn('flex size-7 items-center justify-center rounded-lg', TONE_CHIP[tone])}>
<Icon size={15} />
</span>
<Typography variant="h4" className="text-base">
{title}
</Typography>
</div>
<Badge variant="secondary">{total}</Badge>
</div>
{empty ? (
<Typography variant="muted">{emptyText}</Typography>
) : (
<div className="-mx-1.5 space-y-0.5">{children}</div>
)}
</CardContent>
</Card>
);
}
// 위젯 안의 클릭 가능한 행(견적 1건 → 상세 딥링크). 우측 슬롯에 D-n·미발송 배지 등을 건다.
export function ActionRow({
name,
right,
onClick,
}: {
name?: string;
right?: ReactNode;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className="flex w-full cursor-pointer items-center justify-between gap-2 rounded-md px-1.5 py-1.5 text-left transition-colors hover:bg-muted/60"
>
<Typography variant="small" className="truncate">
{name || '(이름 없음)'}
</Typography>
{right}
</button>
);
}

View File

@ -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 (
<div className="flex flex-col gap-4 rounded-xl border border-primary/20 bg-primary/5 p-6 md:flex-row md:items-center md:justify-between">
<div className="space-y-2">
{company && <Badge>{company}</Badge>}
<Typography variant="h2">{name ? `${name}님, 환영합니다` : '환영합니다'}</Typography>
<Typography variant="muted" className="max-w-2xl">
견적 생성부터 초청메일·협상·마감·낙찰까지, 지금 처리할 일을 아래에서 한눈에 봅니다.
</Typography>
</div>
<Button variant="outline" onClick={onOpenGuide} className="shrink-0">
<HelpCircle />
이용안내
</Button>
</div>
);
}

View File

@ -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 (
<ActionWidget title="마감 임박" icon={Clock} tone="amber" total={data?.total ?? 0} empty={items.length === 0}>
{items.map((it) => (
<ActionRow
key={it.qt_id}
name={it.name}
onClick={() => onOpen(it.qt_id)}
right={<Badge variant="outline">{fmtDeadline(it.end_time)}</Badge>}
/>
))}
</ActionWidget>
);
}

View File

@ -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 (
<ActionWidget title="메일 미발송" icon={Mail} tone="rose" total={data?.total ?? 0} empty={items.length === 0}>
{items.map((it) => (
<ActionRow
key={it.qt_id}
name={it.name}
onClick={() => onOpen(it.qt_id)}
right={<Badge variant="destructive">미발송 {it.unsent_count ?? 0}곳</Badge>}
/>
))}
</ActionWidget>
);
}

View File

@ -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 (
<Card className="gap-0 py-0">
<CardContent className="flex items-center gap-3 px-4 py-3.5">
<div className={cn('flex size-9 shrink-0 items-center justify-center rounded-lg', TONE_CHIP[tone])}>
<Icon size={18} />
</div>
<div className="min-w-0 space-y-0.5">
<Typography variant="h3" className={cn('leading-none', alert && 'text-destructive')}>
{value}
</Typography>
<Typography variant="caption" className="block truncate">
{label}
</Typography>
</div>
</CardContent>
</Card>
);
}

View File

@ -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 (
<Card>
<CardContent className="space-y-3 px-6">
<div className="space-y-1">
<Typography variant="h3">협상은 이렇게 진행됩니다</Typography>
<Typography variant="muted">
견적 생성부터 낙찰까지 6단계로 흐릅니다. 3단계 초청메일을 직접 보내야 협상이 시작됩니다.
</Typography>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{STEPS.map((step) => {
const Icon = step.icon;
return (
<div key={step.num} className="relative">
<div
className={cn(
'flex h-full flex-col gap-2 rounded-lg border p-3',
step.highlight ? 'border-destructive/50 bg-destructive/5' : 'border-border bg-card',
)}
>
<div className="flex items-center justify-between">
<Badge variant={step.highlight ? 'destructive' : 'secondary'}>{`0${step.num}`}</Badge>
<Typography as="span" variant="caption" className={ACTOR_CLASS[step.actorType]}>
[{step.actor}]
</Typography>
</div>
<Icon
size={18}
className={step.highlight ? 'text-destructive' : 'text-muted-foreground'}
/>
<Typography variant="small" className={cn('font-semibold', step.highlight && 'text-destructive')}>
{step.title}
</Typography>
<Typography variant="caption" className="leading-snug">
{step.short}
</Typography>
</div>
{/* 큰 화면에서 단계 사이 화살표 */}
{step.num < STEPS.length && (
<ChevronRight
size={14}
className="absolute top-1/2 -right-2.5 hidden -translate-y-1/2 text-muted-foreground/50 lg:block"
/>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
);
}

View File

@ -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 (
<ActionWidget
title={title}
icon={icon}
tone={tone}
total={data?.total ?? 0}
empty={items.length === 0}
emptyText={emptyText}
>
{items.map((it) => (
<ActionRow key={it.qt_id} name={it.name} onClick={() => onOpen(it.qt_id)} />
))}
</ActionWidget>
);
}

View File

@ -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 (
<Card>
<CardContent className="space-y-3 px-6">
<div className="flex items-center gap-2">
<Typography variant="h3">낙찰 결과는 이렇게 정리됩니다</Typography>
<Badge variant="outline">예시</Badge>
</div>
<div className="grid gap-3 md:grid-cols-2">
{CASES.map((c) => (
<ResultCaseCard key={c.qtNo} data={c} />
))}
</div>
</CardContent>
</Card>
);
}
function ResultCaseCard({ data }: { data: ResultCase }) {
const Icon = data.tone === 'emerald' ? Award : RefreshCw;
return (
<div className="space-y-3 rounded-xl border border-border p-4">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className={cn('flex size-7 items-center justify-center rounded-lg', TONE_CHIP[data.tone])}>
<Icon size={15} />
</span>
<Typography as="span" variant="small" className="font-semibold">
{data.tag}
</Typography>
</div>
<Typography as="span" variant="mono" className="normal-case">
{data.qtNo}
</Typography>
</div>
<Typography variant="small" className="font-semibold">
{data.title}
</Typography>
<div className="space-y-1 rounded-lg bg-muted/40 p-3">
{data.rows.map((r) => (
<div key={r.label} className="flex items-center justify-between gap-2">
<Typography as="span" variant="caption">
{r.label}
</Typography>
<Typography as="span" variant="small" className="font-semibold">
{r.value}
</Typography>
</div>
))}
</div>
<Typography variant="caption" className="block leading-relaxed">
{data.outcome}
</Typography>
</div>
);
}

View File

@ -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 (
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="h-4 w-1 rounded bg-primary" />
<Typography variant="h3">{label}</Typography>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<KpiTile label="진행중" value={s.in_progress ?? 0} icon={Activity} tone="blue" />
<KpiTile label="이번 달 생성" value={s.this_month ?? 0} icon={CalendarPlus} tone="purple" />
<KpiTile label="누적 낙찰" value={s.awarded?.total ?? 0} icon={Award} tone="emerald" />
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<DeadlineWidget data={s.deadline_soon} onOpen={onOpen} />
<EmailUnsentWidget data={s.email_unsent} onOpen={onOpen} />
<RefWidget title="결렬 (선정자 없이 마감)" icon={Ban} tone="rose" data={s.ruptured} onOpen={onOpen} />
</div>
</div>
);
}

View File

@ -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 (
<Card>
<CardContent className="space-y-4 px-6">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Typography variant="h3">시작하기 체크리스트</Typography>
<Badge variant="outline">예시</Badge>
</div>
<Typography variant="muted">필수 준비를 끝내야 첫 자동협상을 시작할 수 있습니다.</Typography>
</div>
<Typography as="span" variant="small" className="shrink-0 font-semibold">
{DONE} / {ITEMS.length} 완료
</Typography>
</div>
{/* 진행률 바 */}
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${(DONE / ITEMS.length) * 100}%` }}
/>
</div>
<div className="space-y-2.5">
{ITEMS.map((item) => (
<ChecklistRow key={item.title} item={item} onAction={() => item.to && navigate(item.to)} />
))}
</div>
</CardContent>
</Card>
);
}
function ChecklistRow({ item, onAction }: { item: ChecklistItem; onAction: () => void }) {
const warn = item.status === 'warn';
return (
<div
className={cn(
'flex flex-col gap-2 rounded-lg border p-3.5 sm:flex-row sm:items-center sm:justify-between',
warn ? 'border-destructive/40 bg-destructive/5' : 'border-border bg-muted/30',
)}
>
<div className="flex items-start gap-3">
<StatusIcon status={item.status} />
<div className="space-y-0.5">
<Typography
variant="small"
className={cn('font-semibold', item.status === 'done' && 'line-through decoration-muted-foreground/50')}
>
{item.title}
</Typography>
<Typography variant="caption" className="block">
{item.desc}
</Typography>
</div>
</div>
{item.meta ? (
<Badge variant="secondary" className="self-start sm:self-center">
{item.meta}
</Badge>
) : item.actionLabel ? (
<Button
size="sm"
variant={warn ? 'destructive' : 'default'}
onClick={onAction}
className="self-start sm:self-center"
>
{item.actionLabel}
</Button>
) : null}
</div>
);
}
function StatusIcon({ status }: { status: ItemStatus }) {
if (status === 'done') return <CheckCircle2 size={18} className="mt-0.5 shrink-0 text-emerald-600 dark:text-emerald-400" />;
if (status === 'warn') return <AlertTriangle size={18} className="mt-0.5 shrink-0 text-destructive" />;
return <Circle size={18} className="mt-0.5 shrink-0 text-muted-foreground" />;
}

View File

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

View File

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

View File

@ -0,0 +1,12 @@
// 아이콘 칩 색(배경+글자). StatusPill 의 PILL_TONE 과 같은 계열 — 코드베이스 전반에서 쓰는 공용 톤이라
// 디자인토큰 범위 안에서 색만 입히는 용도. 생짜 그라데이션 대신 이 맵으로 통일한다.
export type Tone = 'blue' | 'emerald' | 'amber' | 'rose' | 'purple' | 'zinc';
export const TONE_CHIP: Record<Tone, string> = {
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',
};

View File

@ -97,7 +97,6 @@ export function SessionsStatusTab({
<Table className="w-full text-left text-xs border-collapse font-mono min-w-[1350px]">
<TableHeader className="bg-muted text-muted-foreground text-[10px] border-b border-border">
<TableRow>
<TableHead className="p-3 font-semibold">세션 ID</TableHead>
<TableHead className="p-3 font-semibold font-sans">협력사</TableHead>
<TableHead className="p-3 font-semibold font-sans">협상 URL</TableHead>
<TableHead className="p-3 font-semibold text-center font-sans">초청메일</TableHead>
@ -115,14 +114,13 @@ export function SessionsStatusTab({
<TableBody className="divide-y divide-border">
{sessionViews.length === 0 && (
<TableRow>
<TableCell colSpan={13} className="p-12 text-center text-muted-foreground">
<TableCell colSpan={12} className="p-12 text-center text-muted-foreground">
참여 중인 협상 세션이 없습니다. (리스트가 비어 있습니다)
</TableCell>
</TableRow>
)}
{sessionViews.map((sess) => (
<TableRow key={sess.session_id} className="hover:bg-muted/30 transition-colors text-[11px]">
<TableCell className="p-3 text-muted-foreground font-mono">{sess.session_id}</TableCell>
<TableCell className="p-3 font-bold text-foreground font-sans">
<div className="flex items-center gap-2">
<span>{sess.supplier_name}</span>

View File

@ -51,7 +51,7 @@ const QSTATUS_TONE: Record<QuotationStatus, { box: string; dot: string }> = {
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<QuotationStatus, { box: string; dot: string }> = {
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' };

View File

@ -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 (
<div>
<Typography as="span" variant="link" className="block text-sm font-bold">
<Typography as="span" variant="small" className="block font-bold">
{est.title}
</Typography>
<Typography as="span" variant="small" className="mt-0.5 flex items-center gap-1.5 text-[10px] font-mono text-muted-foreground">

View File

@ -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, QtStatusKey> = {
[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) : '';

View File

@ -1,7 +1,7 @@
import { DeliveryType, UserRole, SupplierType, CardUsageType, UserStatus } from '@/api/generated/model';
export const DELIVERY_TYPE_LABEL: Record<DeliveryType, string> = {
[DeliveryType.PARTNER]: '협력사배송',
[DeliveryType.SUPPLIER]: '협력사배송',
[DeliveryType.COURIER]: '지정택배배송',
[DeliveryType.PICKUP]: '픽업배송',
};

View File

@ -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 (
<PageContainer>
<Typography variant="muted">대시보드를 불러오는 중…</Typography>
</PageContainer>
);
}
if (isError || !data) {
return (
<PageContainer>
<Typography variant="muted">대시보드를 불러오지 못했습니다.</Typography>
</PageContainer>
);
}
return (
<PageContainer>
<ScopeSection title="회사 전체" scope={data.company} onOpen={openQuotation} />
<ScopeSection title="내 견적" scope={data.mine} onOpen={openQuotation} />
<DashboardHero onOpenGuide={() => setGuideOpen(true)} />
{isLoading ? (
<Typography variant="muted">대시보드를 불러오는 중…</Typography>
) : isError || !data ? (
<Typography variant="muted">대시보드를 불러오지 못했습니다.</Typography>
) : (
<>
{isOwner && <ScopeSection label="회사 전체 견적" scope={data.company} onOpen={openQuotation} />}
<ScopeSection label="내 견적" scope={data.mine} onOpen={openQuotation} />
</>
)}
<OnboardingGuideModal open={guideOpen} onOpenChange={setGuideOpen} />
</PageContainer>
);
}
// ----- 스코프 섹션(회사 전체 / 내 견적 공용) -----
function ScopeSection({
title,
scope,
onOpen,
}: {
title: string;
scope?: DashboardScope;
onOpen: (qtId: string) => void;
}) {
const s = scope ?? {};
return (
<section className="space-y-3">
<Typography variant="h3">{title}</Typography>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<StatCard label="진행중 견적" value={s.in_progress ?? 0} />
<StatCard label="이번 달 생성" value={s.this_month ?? 0} />
<StatCard label="마감 임박" value={s.deadline_soon?.total ?? 0} warn />
<StatCard label="메일 미발송" value={s.email_unsent?.total ?? 0} warn />
<StatCard label="동가" value={s.equal_bid?.total ?? 0} warn />
<StatCard label="결렬" value={s.ruptured?.total ?? 0} warn />
</div>
<div className="grid gap-3 lg:grid-cols-2">
<DeadlineWidget data={s.deadline_soon} onOpen={onOpen} />
<EmailUnsentWidget data={s.email_unsent} onOpen={onOpen} />
<RefWidget title="동가 (수동 결정 필요)" data={s.equal_bid} onOpen={onOpen} />
<RefWidget title="결렬 (선정자 없이 마감)" data={s.ruptured} onOpen={onOpen} />
</div>
</section>
);
}
// ----- KPI 숫자 카드 -----
function StatCard({ label, value, warn }: { label: string; value: number; warn?: boolean }) {
const danger = !!warn && value > 0;
return (
<Card className="gap-1 py-4">
<CardContent className="space-y-1 px-4">
<Typography variant="caption">{label}</Typography>
<Typography variant="h2" className={danger ? 'text-destructive' : undefined}>
{value}
</Typography>
</CardContent>
</Card>
);
}
// ----- 액션 위젯(공용 셸) -----
function WidgetCard({
title,
total,
empty,
children,
}: {
title: string;
total: number;
empty: boolean;
children: ReactNode;
}) {
return (
<Card className="gap-3 py-4">
<CardContent className="space-y-2 px-4">
<div className="flex items-center justify-between">
<Typography variant="h4">{title}</Typography>
<Badge variant="secondary">{total}</Badge>
</div>
{empty ? (
<Typography variant="muted">처리할 항목 없음</Typography>
) : (
<div className="space-y-1">{children}</div>
)}
</CardContent>
</Card>
);
}
function ActionRow({ name, right, onClick }: { name?: string; right?: ReactNode; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/50"
>
<Typography variant="small" className="truncate">
{name || '(이름 없음)'}
</Typography>
{right}
</button>
);
}
// ----- 마감 임박: 견적 + 마감 D-n -----
function DeadlineWidget({ data, onOpen }: { data?: DashboardActionList; onOpen: (qtId: string) => void }) {
const items = data?.items ?? [];
return (
<WidgetCard title="마감 임박" total={data?.total ?? 0} empty={items.length === 0}>
{items.map((it) => (
<ActionRow
key={it.qt_id}
name={it.name}
onClick={() => onOpen(it.qt_id)}
right={<Badge variant="outline">{fmtDeadline(it.end_time)}</Badge>}
/>
))}
</WidgetCard>
);
}
// ----- 메일 미발송: 견적 단위로 묶고 미발송 협력사 수 -----
function EmailUnsentWidget({ data, onOpen }: { data?: DashboardEmailUnsent; onOpen: (qtId: string) => void }) {
const items = data?.quotations ?? [];
return (
<WidgetCard title="메일 미발송" total={data?.total ?? 0} empty={items.length === 0}>
{items.map((it) => (
<ActionRow
key={it.qt_id}
name={it.name}
onClick={() => onOpen(it.qt_id)}
right={<Badge variant="destructive">미발송 {it.unsent_count ?? 0}곳</Badge>}
/>
))}
</WidgetCard>
);
}
// ----- 동가 / 결렬: 견적명만 -----
function RefWidget({
title,
data,
onOpen,
}: {
title: string;
data?: DashboardActionList;
onOpen: (qtId: string) => void;
}) {
const items = data?.items ?? [];
return (
<WidgetCard title={title} total={data?.total ?? 0} empty={items.length === 0}>
{items.map((it) => (
<ActionRow key={it.qt_id} name={it.name} onClick={() => onOpen(it.qt_id)} />
))}
</WidgetCard>
);
}
// 백엔드 end_time 은 naive UTC ISO(타임존 표기 없음) → 'Z' 를 붙여 UTC 로 파싱하고 남은 일수로 D-n 표기.
function fmtDeadline(s?: string | null): string {
if (!s) return '';
const due = new Date(s.endsWith('Z') ? s : `${s}Z`);
const days = Math.ceil((due.getTime() - Date.now()) / 86_400_000);
if (Number.isNaN(days)) return '';
if (days < 0) return '지남';
if (days === 0) return 'D-DAY';
return `D-${days}`;
}

View File

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

View File

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