diff --git a/negodata/front/src/features/onboarding/OnboardingGuideModal.tsx b/negodata/front/src/features/onboarding/OnboardingGuideModal.tsx index 21ae9bd..baeec92 100644 --- a/negodata/front/src/features/onboarding/OnboardingGuideModal.tsx +++ b/negodata/front/src/features/onboarding/OnboardingGuideModal.tsx @@ -1,5 +1,5 @@ import { useState, type ElementType } from 'react'; -import { LayoutDashboard, FileText, Mail, Users, Clock, Award, RefreshCw, XCircle } from 'lucide-react'; +import { LayoutDashboard, FileText, Mail, Users, Clock, Award } from 'lucide-react'; import { Dialog, DialogContent, @@ -13,7 +13,7 @@ import { Badge } from '@/components/ui/badge'; import { Typography } from '@/components/ui/typography'; import { cn } from '@/lib/utils'; -// 신규 유저용 프로세스 안내. '흐름 설명'(6단계)과 '마감 결과'(낙찰/재생성/결렬 + 사유) 두 뷰. +// 신규 유저용 프로세스 안내. '흐름 설명'(6단계)·'견적 유형'(4종)·'마감 판정'(가격게이트+재생성 한도 결정표) 세 뷰. // 읽기 전용 정적 안내(테이블/서버 없음). 첫 방문 자동 노출 + 대시보드 버튼으로 재오픈은 호출부(대시보드)에서 제어. type Actor = 'user' | 'partner' | 'system'; @@ -67,9 +67,14 @@ const STEPS: Step[] = [ actor: '협력사', actorType: 'partner', icon: Users, - short: '협력사가 가격을 투찰', + short: 'AI 봇이 앵커링·와일드카드로 가격 유도(재협상)', detail: - '초청메일을 받은 협력사가 전용 채팅방에 입장해 AI 협상카드의 앵커링 응대에 따라 가격을 투찰합니다. 진행 현황은 견적 상세에서 실시간으로 볼 수 있습니다.', + '초청메일을 받은 협력사가 전용 채팅방에 입장해 가격을 제시하면, AI 협상봇이 회사가 정한 앵커링가를 기준으로 응대합니다. ' + + '단, 앵커링·협상카드·와일드카드까지 쓰는 이 밀당 흐름은 재협상(1:1) 유형에서만 돌고, 나머지 유형(재견적·신규협상·신규견적)은 카드 없이 배송형태·추가할인 요청 중심의 정형 흐름을 탑니다. ' + + '재협상 기준으로, 제시가가 앵커링가 이하로 내려오면 협상완료(투찰 확정)로 넘어갑니다. ' + + '앵커링가를 살짝(5% 이내) 초과하면 막판 인하 압박용 "와일드카드"가 한 번 발동해 앵커 이하로 유도합니다 — 앵커에 아주 근접(2% 이내)하면 1% 인하를 요청하고, 좀 더 높으면 목표 매입가에 맞춰달라고 제안합니다. ' + + '그보다 높거나 아직 여지가 있으면 일반 협상카드로 재제안을 반복하고, 카드가 소진되거나 카운터제안이 3회를 넘으면 협상이 실패로 종료됩니다. ' + + '진행 현황은 견적 상세에서 실시간으로 볼 수 있습니다.', }, { num: 5, @@ -87,9 +92,9 @@ const STEPS: Step[] = [ actor: '시스템', actorType: 'system', icon: Award, - short: '단독최저=낙찰 / 동가·미참여=재생성 / 결렬', + short: '단독최저+정책통과=낙찰 / 목표초과·동가·미참여=재생성 / 결렬', detail: - '단독 최저가면 그 협력사로 낙찰됩니다. 최저가가 둘 이상 같은 동가이거나 전원 미참여면 다음 차수가 자동 재생성되고, 거절·한도 등으로 낙찰자가 없으면 결렬 처리됩니다. 결과는 알림함으로 통지되고 대시보드에서 현황을 확인합니다.', + '낙찰 후보는 협상완료한 협력사 중 최저가입니다. 다만 최저가라고 바로 낙찰이 아니라, 그 최저가가 단독(동점 아님)이고 회사 가격정책의 가격게이트를 낙찰로 통과해야 낙찰됩니다. 최저가가 목표가 구간을 넘어 정책이 재협상이면 다음 차수로 더 깎고, 동가·전원 미참여도 다음 차수를 재생성합니다(모두 체인 전체 재생성 한도 안에서, 사유 무관 합산·기본 1회). 정책이 결렬이거나 한도를 소진하면, 또는 완료 투찰 없이 거부만 있으면 결렬됩니다. 결과는 알림함으로 통지되고 대시보드에서 현황을 확인합니다.', }, ]; @@ -99,68 +104,67 @@ const ACTOR_CLASS: Record = { system: 'text-muted-foreground', }; -// ----- 견적 마감 결과(close_and_decide 기준): 낙찰 / 재생성(동가·미참여) / 결렬(거부·한도). 모두 알림함 통지 ----- +// ----- 견적 마감 판정(close_and_decide 기준): 낙찰 후보=협상완료 최저가 → (1)단독/동가/미완료 (2)가격게이트(mid/over_action) (3)재생성 한도(체인 전체 총·사유무관, regen_limit)로 낙찰/재생성/결렬. 아래 결정표 8행 = close_reason 8종 ----- type Tone = 'win' | 'tie' | 'fail'; -interface ResultCase { - key: string; - label: string; +// (1) 가격게이트: 투찰가가 앵커링가/목표가 대비 어느 구간이냐로 회사 정책(mid/over_action) 적용. +const GATE_ZONES: { range: string; action: string; note: string }[] = [ + { range: '투찰가 ≤ 앵커링가', action: '무조건 낙찰', note: '고정 · 설정 불가' }, + { range: '앵커링가 < 투찰가 ≤ 목표가', action: '견적세팅 mid_action 대로', note: '기본값 = 낙찰' }, + { range: '목표가 < 투찰가', action: '견적세팅 over_action 대로', note: '기본값 = 낙찰' }, +]; + +// (2) 완료 양상별 결정. rows 는 위에서부터 순서대로 판정(close_and_decide 분기 순서와 동일). +interface DecisionRow { + cond: string; + result: string; tone: Tone; - icon: ElementType; - causes: { tag: string; desc: string }[]; - outcome: string; +} +interface DecisionGroup { + group: string; + desc: string; + rows: DecisionRow[]; } -const RESULT_CASES: ResultCase[] = [ +const DECISION_TREE: DecisionGroup[] = [ { - key: 'win', - label: '낙찰', - tone: 'win', - icon: Award, - causes: [{ tag: '단독 최저가', desc: '협상완료 중 최저가를 낸 협력사가 한 곳뿐' }], - outcome: '그 협력사로 낙찰 확정.', + group: '단독 최저가', + desc: '협상완료 협력사 중 최저가가 한 곳', + rows: [ + { cond: '가격게이트 = 낙찰', result: '낙찰', tone: 'win' }, + { cond: '가격게이트 = 재협상 · 재생성 한도 남음', result: '재협상 · 더 깎기', tone: 'tie' }, + { cond: '가격게이트 = 결렬, 또는 재협상인데 한도 소진', result: '결렬', tone: 'fail' }, + ], }, { - key: 'regen', - label: '재생성 · 다음 차수', - tone: 'tie', - icon: RefreshCw, - causes: [ - { tag: '동가', desc: '최저가가 2개사 이상 동일 → 동가 업체끼리 다음 차수' }, - { tag: '전원 미참여', desc: '아무도 투찰 안 함 → 원 공급사 전체로 다음 차수' }, + group: '동가', + desc: '최저가가 2곳 이상 동일 → 단독 낙찰 불가', + rows: [ + { cond: '가격게이트 ≠ 결렬 · 한도 남음', result: '재입찰 · 동가끼리', tone: 'tie' }, + { cond: '가격게이트 = 결렬, 또는 한도 소진', result: '결렬', tone: 'fail' }, ], - outcome: '해당 차수는 낙찰자 없이 마감되고 다음 차수가 자동 생성됩니다. (사유별 1회씩, 최대 2회까지)', }, { - key: 'fail', - label: '결렬 · 그냥 마감', - tone: 'fail', - icon: XCircle, - causes: [ - { tag: '협상거부', desc: '거부한 협력사가 하나라도 있음 → 재생성 없이 마감' }, - { tag: '재생성 한도', desc: '동가·미참여 재생성을 이미 써서 더 못 돌림' }, + group: '완료한 투찰 없음', + desc: '아무도 협상을 완료(투찰 확정)하지 않음', + rows: [ + { cond: '거부한 협력사가 있음', result: '결렬', tone: 'fail' }, + { cond: '전원 미참여 · 한도 남음', result: '재소집 · 공급사 전체', tone: 'tie' }, + { cond: '전원 미참여 · 한도 소진', result: '결렬', tone: 'fail' }, ], - outcome: '낙찰자 없이 협상이 종료됩니다.', }, ]; -const TONE_BORDER: Record = { - win: 'border-emerald-500/40', - tie: 'border-amber-500/40', - fail: 'border-destructive/40', -}; -const TONE_TEXT: Record = { - win: 'text-emerald-600 dark:text-emerald-400', - tie: 'text-amber-600 dark:text-amber-400', - fail: 'text-destructive', -}; const TONE_CHIP: Record = { win: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', tie: 'bg-amber-500/10 text-amber-600 dark:text-amber-400', fail: 'bg-destructive/10 text-destructive', }; -// ----- 견적 유형(4종): 두 축으로 갈림 — 신규/재(목표가 산정 후보) × 협상 1:1 / 견적 1:N(부르는 협력사 수) ----- +// ----- 견적 유형(4종): 두 축으로 갈림 — 신규/재(목표가 산정 후보) × 협상 1:1 / 견적 1:N(부르는 협력사 수). +// 추가로 '협상카드 발동'은 이 두 축과 별개다 — 라이브 봇(agent chat_engine)은 rq_type 을 재협상/재견적 둘로만 접고 +// (backend chat_service `qt_type==1`만 재협상), 협상카드·와일드카드는 재협상 스크립트에만 존재한다. +// 그래서 실제로 카드가 도는 건 재협상(1) 뿐이고, 나머지 3종(재견적·신규협상·신규견적)은 카드 없는 재견적 흐름을 탄다. ----- interface QtType { key: string; label: string; @@ -168,16 +172,27 @@ interface QtType { scope: string; // 1:1 / 1:N suppliers: string; // 부르는 협력사 수 basis: string; // 목표가 산정 기준 + cards: string; // 실제 협상카드/와일드카드 발동 여부(런타임 기준) } const QUOTATION_TYPES: QtType[] = [ - { key: 'new_quote', label: '신규견적', isNew: true, scope: '1:N', suppliers: '여러 협력사', basis: '인터넷최저가 기준' }, - { key: 'new_nego', label: '신규협상', isNew: true, scope: '1:1', suppliers: '단일 협력사', basis: '인터넷최저가 기준' }, - { key: 'requote', label: '재견적', isNew: false, scope: '1:N', suppliers: '여러 협력사', basis: '인터넷최저·매입·판매 중 최소' }, - { key: 'renego', label: '재협상', isNew: false, scope: '1:1', suppliers: '단일 협력사', basis: '인터넷최저·매입·판매 중 최소' }, + { key: 'renego', label: '재협상', isNew: false, scope: '1:1', suppliers: '단일 협력사', basis: '인터넷최저·매입·판매 중 최소', cards: '협상카드 · 와일드카드 발동' }, + { key: 'requote', label: '재견적', isNew: false, scope: '1:N', suppliers: '여러 협력사', basis: '인터넷최저·매입·판매 중 최소', cards: '카드 없음 · 배송형태·추가할인 흐름' }, + { key: 'new_nego', label: '신규협상', isNew: true, scope: '1:1', suppliers: '단일 협력사', basis: '인터넷최저가 기준', cards: '카드 없음 · 현재 재견적 흐름으로 진행' }, + { key: 'new_quote', label: '신규견적', isNew: true, scope: '1:N', suppliers: '여러 협력사', basis: '인터넷최저가 기준', cards: '카드 없음 · 현재 재견적 흐름으로 진행' }, ]; -const TAB_LABEL = { flow: '흐름 설명', type: '견적 유형', example: '마감 결과' } as const; +// ----- 협상 로직(agent 협상봇 chat_engine): 협력사 제시가를 회사 앵커링가 기준으로 구간별 응대. 와일드카드는 앵커 살짝 초과 시 세션당 1회 발동. +// ★ 이 카드/앵커링 흐름은 '재협상(1:1)' 스크립트(scripts_renegotiation)에만 있다. 재견적은 카드·와일드카드가 없는 별도 스크립트. ----- +const NEGO_ZONES: { range: string; action: string; note: string }[] = [ + { range: '제시가 ≤ 앵커링가', action: '협상완료 · 투찰 확정', note: '앵커 이하 = 우선협상' }, + { range: '앵커링가 < 제시가 ≤ 앵커×1.02', action: '와일드카드 · 1% 인하 요청', note: '앵커에 근접 → 딱 앵커 밑으로 유도' }, + { range: '앵커×1.02 < 제시가 ≤ 앵커×1.05', action: '와일드카드 · 목표 매입가 제시', note: '막판 인하 압박' }, + { range: '제시가 > 앵커×1.05', action: '일반 협상카드로 재제안', note: '아직 여지 있음 → 계속 협상' }, + { range: '카드 소진 · 카운터제안 3회 초과', action: '협상 실패', note: '라운드 상한(MAX_ROUNDS=3)·무한 협상 방지' }, +]; + +const TAB_LABEL = { flow: '흐름 설명', type: '견적 유형', nego: '협상 로직', example: '마감 판정' } as const; export function OnboardingGuideModal({ open, @@ -186,7 +201,7 @@ export function OnboardingGuideModal({ open: boolean; onOpenChange: (open: boolean) => void; }) { - const [tab, setTab] = useState<'flow' | 'type' | 'example'>('flow'); + const [tab, setTab] = useState<'flow' | 'type' | 'nego' | 'example'>('flow'); const [activeNum, setActiveNum] = useState(1); const active = STEPS.find((s) => s.num === activeNum) ?? STEPS[0]; @@ -201,14 +216,14 @@ export function OnboardingGuideModal({ - 상품·협력사 등록부터 자동 협상 낙찰까지의 흐름과, 마감 결과 케이스를 확인하세요. + 상품·협력사 등록부터 자동 협상 낙찰까지의 흐름과, 견적세팅 정책에 따른 마감 판정을 확인하세요. {/* 뷰 전환 탭 */}
- {(['flow', 'type', 'example'] as const).map((t) => ( + {(['flow', 'type', 'nego', 'example'] as const).map((t) => (
+
+ 협상카드 + {t.cards} +
))} @@ -330,53 +349,174 @@ export function OnboardingGuideModal({ 협상은 한 곳과 1:1, 견적은 여러 곳과 1:N. +
+ 협상카드는 재협상에서만 + + 앵커링·협상카드·와일드카드를 쓰는 밀당 협상은 지금 재협상(1:1)에서만 돕니다. 재견적은 카드 없이 배송형태·추가할인 요청 흐름이고, 신규협상·신규견적도 현재 라이브 봇에선 카드 없는 재견적 흐름으로 진행됩니다. (자세한 카드 동작은 「협상 로직」 탭) + +
+ + + )} + + {tab === 'nego' && ( +
+
+ 협상 중엔 이렇게 카드가 발동합니다 + AI 봇 +
+ + {/* 범위 경고: 이 카드 로직은 재협상 전용 — 나머지 유형엔 카드가 없다 */} +
+ + 이 카드 로직은 재협상(1:1)에서만 돕니다 + + + 아래 앵커링 구간·협상카드·와일드카드는 재협상 유형의 협상봇 흐름입니다. 재견적(1:N)은 카드·와일드카드 없이 배송형태 선택·추가할인 요청으로 이어지는 별도 흐름이고, 신규협상·신규견적도 현재 라이브 봇에선 카드 없는 재견적 흐름으로 진행됩니다. + +
+ + + 재협상 기준으로, 협력사가 제시한 가격을 회사가 정한 앵커링가(목표가보다 낮은 압박 기준선)와 비교해 AI 협상봇이 구간별로 다르게 응대합니다. + + + {/* 제시가 구간별 봇 행동 */} +
+ + 협력사 제시가 구간 → 봇 행동 + +
+ {NEGO_ZONES.map((z) => ( +
+ + {z.range} + +
+ + {z.action} + + + + {z.note} + + +
+
+ ))} +
+
+ + {/* 협상카드 vs 와일드카드 */} +
+
+ 협상카드 (일반) + + 제시가가 아직 앵커에서 여유가 있으면(앵커×1.05 초과) 매 라운드 협상카드를 한 장씩 꺼내 인하를 유도하고 재제안을 받습니다. 견적세팅의 카드 사용 횟수만큼 쓰며, 다 소진되면 협상이 종료됩니다. + +
+
+ 와일드카드 (막판) + + 제시가가 앵커를 살짝(5% 이내) 넘은 아까운 구간에서 앵커 밑으로 끌어내리려 발동하는 마지막 압박 카드입니다. 한 협상에서 딱 한 번만 나갑니다. + +
)} {tab === 'example' && ( -
+
- 견적 마감 결과는 이렇게 나뉩니다 - 예시 + 마감되면 이 순서로 판정합니다 + 자동
- 협력사 투찰 양상에 따라 마감 결과는 낙찰 / 재생성 / 결렬 셋으로 갈리며, 모든 결과는 알림함으로 통지됩니다. + 낙찰 후보는 협상완료한 협력사 중 최저가입니다. 최저가라고 곧 낙찰이 아니라, 그 가격을 견적세팅의 가격정책(가격게이트)에 통과시켜 낙찰 · 재협상 · 결렬이 갈립니다. -
- {RESULT_CASES.map((c) => { - const Icon = c.icon; - return ( -
+ {/* 핵심: 처리 방식은 견적세팅이 정하고, 기본은 낙찰 */} +
+ + 낙찰 · 재협상 · 결렬은 견적세팅에서 정합니다 + + + 각 가격 구간을 어떻게 처리할지는 견적세팅의 가격정책(mid_action · over_action)에서 지정합니다. 기본값은 전 구간 낙찰이라, 따로 바꾸지 않으면 목표가를 초과해도 낙찰됩니다. + +
+ + {/* 1) 가격게이트 구간 */} +
+ + 1) 가격게이트 — 투찰가 구간별 처리 + +
+ {GATE_ZONES.map((z) => ( +
+ + {z.range} +
- - - - - {c.label} + + {z.action} + + + {z.note} + +
+
+ ))} +
+
-
- {c.causes.map((cause) => ( -
- - {cause.tag} - - - {cause.desc} - -
- ))} -
- - - → {c.outcome} + {/* 2) 완료 양상 + 게이트 + 한도 → 결과 (8행 = close_reason 8종) */} +
+ + 2) 완료 양상 + 게이트 + 한도 → 결과 + + {DECISION_TREE.map((g) => ( +
+
+ + {g.group} + + + {g.desc}
- ); - })} +
+ {g.rows.map((r) => ( +
+ + {r.cond} + + + + {r.result} + + +
+ ))} +
+
+ ))} +
+ + {/* 3) 재생성 한도 */} +
+ + 3) 재생성 한도 + + + 재협상 · 재입찰 · 재소집은 모두 다음 차수를 자동 생성합니다. 한도(regen_limit)는 체인(같은 견적번호) 전체에서 사유 무관 합산한 총 횟수이며 기본 1회이고, 소진하면 결렬됩니다. 모든 결과는 알림함으로 통지됩니다. +
)} diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/DrawerHeaderCards.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/DrawerHeaderCards.tsx index 94af6da..2a64ae0 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/DrawerHeaderCards.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/DrawerHeaderCards.tsx @@ -36,6 +36,8 @@ type DrawerHeaderCardsProps = { sessionViews: SessionView[]; /** 현재 선택 세션의 상품(없으면 상품 카드는 빈 상태). */ currentProduct: Product | undefined; + /** 목표가 클릭 → 산정내역 모달(대표 세션 기준). 목표가/앵커링가는 견적 단위(1견적=1상품)라 세션 공통값. */ + onShowTarget: (sessionId: string) => void; }; export function DrawerHeaderCards({ @@ -43,6 +45,7 @@ export function DrawerHeaderCards({ quotationSettings, sessionViews, currentProduct, + onShowTarget, }: DrawerHeaderCardsProps) { // Quotations DDL 표시값 const q_name = quotation.name || '-'; @@ -58,6 +61,10 @@ export function DrawerHeaderCards({ const selectedSettingObj = quotationSettings.find((qs) => qs.qt_setting_id === quotation.qt_setting_id); + // 목표가·앵커링가는 견적 단위값(1견적=1상품 → 모든 세션 공통). 대표 세션 하나에서 읽어 견적 정보에 표기한다. + const repSession = sessionViews[0]; + const repSessionId = repSession?.session_id; + // 상품 카드는 '가격 관련' 값만 노출(모델명·규격·제조사 등 스펙은 상품 상세에서 확인). const won = (n?: number | null) => (n != null ? `₩${Number(n).toLocaleString()}` : '-'); const productPriceRows = currentProduct @@ -82,6 +89,21 @@ export function DrawerHeaderCards({ + + {repSessionId ? ( + + ) : ( + {won(repSession?.target_price)} + )} + + diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx index dbf68a2..5a60b69 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx @@ -3,8 +3,6 @@ import { MessageSquare, Copy, Mail, MailCheck, Send } from 'lucide-react'; import { showToast } from '@/lib/notify'; import { confirm } from '@/lib/confirm'; import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table'; -import { typographyVariants } from '@/components/ui/typography'; -import { cn } from '@/lib/utils'; import { StatusPill, sessionStatusTone } from './StatusPill'; import { mapServerSessionView, sessionStatusLabel } from '../../types'; @@ -14,7 +12,6 @@ export function SessionsStatusTab({ sessionViews, canNotify, onOpenChat, - onShowTarget, onNotifyAll, onNotifyOne, }: { @@ -22,8 +19,6 @@ export function SessionsStatusTab({ /** 초청 메일 발송 권한(견적 소유자만). false 면 발송 버튼 비활성. */ canNotify: boolean; onOpenChat: (sessionId: string) => void; - /** 목표가 셀 클릭 → 산정내역 모달. */ - onShowTarget: (sessionId: string) => void; /** 미발송 세션 전체에 초청 메일 발송. */ onNotifyAll: () => Promise; /** 한 세션(공급사)에 초청 메일 발송/재발송. */ @@ -94,7 +89,7 @@ export function SessionsStatusTab({
- +
협력사 @@ -102,7 +97,7 @@ export function SessionsStatusTab({ 초청메일 상품 협상상태 - 목표가 + 앵커링가 투찰가 투찰시각 마감시각 @@ -185,15 +180,19 @@ export function SessionsStatusTab({ {sessionStatusLabel(sess.status)} - - + + {sess.target_anchoring_price > 0 ? ( + <> + ₩{sess.target_anchoring_price.toLocaleString()} + {sess.target_price > 0 && ( + + ({((1 - sess.target_anchoring_price / sess.target_price) * 100).toFixed(1)}%) + + )} + + ) : ( + '-' + )} {sess.bid_price ? `₩${sess.bid_price.toLocaleString()}` : '-'} diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/index.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/index.tsx index 5c8d42b..2979168 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/index.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/index.tsx @@ -205,6 +205,7 @@ export function QuotationDetailSheet({ quotationSettings={quotationSettings} sessionViews={sessionViews} currentProduct={currentProduct} + onShowTarget={setTargetSessionId} /> )} @@ -248,7 +249,6 @@ export function QuotationDetailSheet({ sessionViews={sessionViews} canNotify={canNotify} onOpenChat={goToChat} - onShowTarget={setTargetSessionId} onNotifyAll={() => onNotify(qtId)} onNotifyOne={(sessionId) => onNotifySession(sessionId, qtId)} /> diff --git a/negodata/front/src/features/quotations/components/QuotationTable.tsx b/negodata/front/src/features/quotations/components/QuotationTable.tsx index e96909f..69fc3b4 100644 --- a/negodata/front/src/features/quotations/components/QuotationTable.tsx +++ b/negodata/front/src/features/quotations/components/QuotationTable.tsx @@ -11,7 +11,6 @@ import { quotationStatusLabel, quotationTypeLabel, chainRoundState, - quotationOutcomeLabel, CHAIN_ROUND_STATE_LABEL, } from '../types'; import { QuotationType, QuotationStatus } from '@/api/generated/model'; @@ -38,7 +37,7 @@ const statusBadgeClass = (status?: number | null) => { } }; -// 마감결과 배지 색. 낙찰=초록/재생성=주황/유찰=로즈/진행중=회색(아직 마감 전). +// 마감결과 배지 색. 낙찰=초록/재생성=주황/결렬=로즈/진행중=회색(아직 마감 전). const outcomeBadgeClass = (state: ChainRoundState) => { switch (state) { case 'awarded': @@ -144,15 +143,22 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, fo { header: '마감결과', align: 'center', + // 상세 마감사유는 상세 드로어에서만. 목록은 거친 결과(낙찰/재생성/결렬/진행중)만, + // 낙찰이면 배지에 '낙찰 - 낙찰사명' 한 줄로 붙인다. cell: (est) => { - const state = chainRoundState(est); // 배지 색 그룹 + const state = chainRoundState(est); + const winner = state === 'awarded' ? est.preferred_sp_name : null; return ( - {quotationOutcomeLabel(est)} + + {CHAIN_ROUND_STATE_LABEL[state]} + {winner ? ` - ${winner}` : ''} + ); }, diff --git a/negodata/front/src/features/quotations/types.ts b/negodata/front/src/features/quotations/types.ts index b9ff90b..b7be156 100644 --- a/negodata/front/src/features/quotations/types.ts +++ b/negodata/front/src/features/quotations/types.ts @@ -45,7 +45,7 @@ export interface QuotationSetting { export const PRICE_GATE_ACTION_LABEL: Record = { [PriceGateAction.AWARD]: '낙찰', [PriceGateAction.RENEGO]: '재협상', - [PriceGateAction.FAIL]: '유찰', + [PriceGateAction.FAIL]: '결렬', }; export const priceGateActionLabel = (a?: number | null): string => a != null ? PRICE_GATE_ACTION_LABEL[a as PriceGateAction] ?? String(a) : ''; @@ -183,7 +183,7 @@ export type ChainRoundState = 'awarded' | 'regenerated' | 'failed' | 'active'; export const CHAIN_ROUND_STATE_LABEL: Record = { awarded: '낙찰', regenerated: '재생성', - failed: '유찰', + failed: '결렬', active: '진행중', }; @@ -296,10 +296,10 @@ export const CLOSE_REASON_LABEL: Record = { [CloseReason.REGEN_PRICE]: '목표초과 — 재협상 진행', [CloseReason.REGEN_EQUAL]: '동가 — 재입찰 진행', [CloseReason.REGEN_NOSHOW]: '전원 미참여 — 재소집 진행', - [CloseReason.FAIL_PRICE]: '목표초과로 유찰', - [CloseReason.FAIL_EQUAL]: '동가로 유찰', - [CloseReason.FAIL_NOSHOW]: '미참여로 유찰', - [CloseReason.FAIL_REJECT]: '협상 거부로 유찰', + [CloseReason.FAIL_PRICE]: '목표초과로 결렬', + [CloseReason.FAIL_EQUAL]: '동가로 결렬', + [CloseReason.FAIL_NOSHOW]: '미참여로 결렬', + [CloseReason.FAIL_REJECT]: '협상 거부로 결렬', }; // 마감결과 텍스트(정밀). 마감결과 배지·라벨은 이 값을 쓴다 — close_reason(CloseReason) 을 그대로 표기.