[feat] negodata/front: 이용안내 마감판정 재작성 + 견적상세 목표가·앵커링 표시
- 이용안내(OnboardingGuideModal) '마감 판정' 탭 재구성: 가격게이트(견적세팅 mid/over_action 정책·기본 낙찰) + 완료양상 결정표(낙찰/재생성/결렬, 8행=close_reason 8종) + 재생성 한도(체인 전체 총합). - 견적상세 헤더카드에 목표가(→산정내역 링크)·앵커링가 표기(대표 세션 공통값), 협상 리스트에 앵커링 컬럼 추가. onShowTarget 을 헤더카드로 이관. - 목록 마감결과 배지: 낙찰 시 낙찰 협력사명 병기. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bfdc0c3047
commit
c98e5fa7bb
@ -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<Actor, string> = {
|
||||
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<Tone, string> = {
|
||||
win: 'border-emerald-500/40',
|
||||
tie: 'border-amber-500/40',
|
||||
fail: 'border-destructive/40',
|
||||
};
|
||||
const TONE_TEXT: Record<Tone, string> = {
|
||||
win: 'text-emerald-600 dark:text-emerald-400',
|
||||
tie: 'text-amber-600 dark:text-amber-400',
|
||||
fail: 'text-destructive',
|
||||
};
|
||||
const TONE_CHIP: Record<Tone, string> = {
|
||||
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({
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Typography as="span" variant="muted">
|
||||
상품·협력사 등록부터 자동 협상 낙찰까지의 흐름과, 마감 결과 케이스를 확인하세요.
|
||||
상품·협력사 등록부터 자동 협상 낙찰까지의 흐름과, 견적세팅 정책에 따른 마감 판정을 확인하세요.
|
||||
</Typography>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 뷰 전환 탭 */}
|
||||
<div className="flex w-fit gap-1 rounded-lg bg-muted p-1">
|
||||
{(['flow', 'type', 'example'] as const).map((t) => (
|
||||
{(['flow', 'type', 'nego', 'example'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
@ -312,6 +327,10 @@ export function OnboardingGuideModal({
|
||||
<Typography as="span" variant="small" className="font-semibold">목표가 산정</Typography>
|
||||
<Typography variant="caption" className="mt-0.5 block leading-relaxed">{t.basis}</Typography>
|
||||
</div>
|
||||
<div className={cn('rounded-lg p-2.5', t.key === 'renego' ? 'bg-emerald-500/10' : 'bg-muted/40')}>
|
||||
<Typography as="span" variant="small" className="font-semibold">협상카드</Typography>
|
||||
<Typography variant="caption" className="mt-0.5 block leading-relaxed">{t.cards}</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@ -330,53 +349,174 @@ export function OnboardingGuideModal({
|
||||
협상은 한 곳과 1:1, 견적은 여러 곳과 1:N.
|
||||
</Typography>
|
||||
</div>
|
||||
<div>
|
||||
<Typography as="span" variant="small" className="font-semibold">협상카드는 재협상에서만</Typography>
|
||||
<Typography variant="caption" className="mt-0.5 block leading-relaxed">
|
||||
앵커링·협상카드·와일드카드를 쓰는 밀당 협상은 지금 재협상(1:1)에서만 돕니다. 재견적은 카드 없이 배송형태·추가할인 요청 흐름이고, 신규협상·신규견적도 현재 라이브 봇에선 카드 없는 재견적 흐름으로 진행됩니다. (자세한 카드 동작은 「협상 로직」 탭)
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'nego' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Typography variant="h4">협상 중엔 이렇게 카드가 발동합니다</Typography>
|
||||
<Badge variant="outline">AI 봇</Badge>
|
||||
</div>
|
||||
|
||||
{/* 범위 경고: 이 카드 로직은 재협상 전용 — 나머지 유형엔 카드가 없다 */}
|
||||
<div className="rounded-lg border border-amber-500/40 bg-amber-500/5 p-3">
|
||||
<Typography as="p" variant="small" className="font-semibold text-amber-700 dark:text-amber-400">
|
||||
이 카드 로직은 재협상(1:1)에서만 돕니다
|
||||
</Typography>
|
||||
<Typography variant="caption" className="mt-0.5 block leading-relaxed">
|
||||
아래 앵커링 구간·협상카드·와일드카드는 재협상 유형의 협상봇 흐름입니다. 재견적(1:N)은 카드·와일드카드 없이 배송형태 선택·추가할인 요청으로 이어지는 별도 흐름이고, 신규협상·신규견적도 현재 라이브 봇에선 카드 없는 재견적 흐름으로 진행됩니다.
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<Typography variant="muted">
|
||||
재협상 기준으로, 협력사가 제시한 가격을 회사가 정한 앵커링가(목표가보다 낮은 압박 기준선)와 비교해 AI 협상봇이 구간별로 다르게 응대합니다.
|
||||
</Typography>
|
||||
|
||||
{/* 제시가 구간별 봇 행동 */}
|
||||
<div className="rounded-xl border border-border p-3">
|
||||
<Typography as="p" variant="small" className="font-bold">
|
||||
협력사 제시가 구간 → 봇 행동
|
||||
</Typography>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{NEGO_ZONES.map((z) => (
|
||||
<div
|
||||
key={z.range}
|
||||
className="flex flex-col gap-1 rounded-lg bg-muted/40 p-2.5 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<Typography as="span" variant="small" className="font-mono text-[12px]">
|
||||
{z.range}
|
||||
</Typography>
|
||||
<div className="flex items-center gap-2">
|
||||
<Typography as="span" variant="caption" className="font-semibold">
|
||||
{z.action}
|
||||
</Typography>
|
||||
<span className="shrink-0 rounded bg-background px-1.5 py-0.5">
|
||||
<Typography as="span" variant="caption" className="text-muted-foreground">
|
||||
{z.note}
|
||||
</Typography>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 협상카드 vs 와일드카드 */}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-xl border border-border p-3">
|
||||
<Typography as="p" variant="small" className="font-bold">협상카드 (일반)</Typography>
|
||||
<Typography variant="caption" className="mt-0.5 block leading-relaxed">
|
||||
제시가가 아직 앵커에서 여유가 있으면(앵커×1.05 초과) 매 라운드 협상카드를 한 장씩 꺼내 인하를 유도하고 재제안을 받습니다. 견적세팅의 카드 사용 횟수만큼 쓰며, 다 소진되면 협상이 종료됩니다.
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="rounded-xl border border-amber-500/40 bg-amber-500/5 p-3">
|
||||
<Typography as="p" variant="small" className="font-bold text-amber-700 dark:text-amber-400">와일드카드 (막판)</Typography>
|
||||
<Typography variant="caption" className="mt-0.5 block leading-relaxed">
|
||||
제시가가 앵커를 살짝(5% 이내) 넘은 아까운 구간에서 앵커 밑으로 끌어내리려 발동하는 마지막 압박 카드입니다. 한 협상에서 딱 한 번만 나갑니다.
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'example' && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Typography variant="h4">견적 마감 결과는 이렇게 나뉩니다</Typography>
|
||||
<Badge variant="outline">예시</Badge>
|
||||
<Typography variant="h4">마감되면 이 순서로 판정합니다</Typography>
|
||||
<Badge variant="outline">자동</Badge>
|
||||
</div>
|
||||
<Typography variant="muted">
|
||||
협력사 투찰 양상에 따라 마감 결과는 낙찰 / 재생성 / 결렬 셋으로 갈리며, 모든 결과는 알림함으로 통지됩니다.
|
||||
낙찰 후보는 협상완료한 협력사 중 최저가입니다. 최저가라고 곧 낙찰이 아니라, 그 가격을 견적세팅의 가격정책(가격게이트)에 통과시켜 낙찰 · 재협상 · 결렬이 갈립니다.
|
||||
</Typography>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{RESULT_CASES.map((c) => {
|
||||
const Icon = c.icon;
|
||||
return (
|
||||
<div key={c.key} className={cn('flex flex-col gap-2 rounded-xl border p-3', TONE_BORDER[c.tone])}>
|
||||
{/* 핵심: 처리 방식은 견적세팅이 정하고, 기본은 낙찰 */}
|
||||
<div className="rounded-lg border border-primary/40 bg-primary/5 p-3">
|
||||
<Typography as="p" variant="small" className="font-semibold text-primary">
|
||||
낙찰 · 재협상 · 결렬은 견적세팅에서 정합니다
|
||||
</Typography>
|
||||
<Typography variant="caption" className="mt-0.5 block leading-relaxed">
|
||||
각 가격 구간을 어떻게 처리할지는 견적세팅의 가격정책(mid_action · over_action)에서 지정합니다. 기본값은 전 구간 낙찰이라, 따로 바꾸지 않으면 목표가를 초과해도 낙찰됩니다.
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
{/* 1) 가격게이트 구간 */}
|
||||
<div className="rounded-xl border border-border p-3">
|
||||
<Typography as="p" variant="small" className="font-bold">
|
||||
1) 가격게이트 — 투찰가 구간별 처리
|
||||
</Typography>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{GATE_ZONES.map((z) => (
|
||||
<div
|
||||
key={z.range}
|
||||
className="flex flex-col gap-1 rounded-lg bg-muted/40 p-2.5 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<Typography as="span" variant="small" className="font-mono text-[12px]">
|
||||
{z.range}
|
||||
</Typography>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('flex size-7 items-center justify-center rounded-lg', TONE_CHIP[c.tone])}>
|
||||
<Icon size={15} />
|
||||
</span>
|
||||
<Typography as="span" variant="small" className={cn('font-semibold', TONE_TEXT[c.tone])}>
|
||||
{c.label}
|
||||
<Typography as="span" variant="caption" className="font-semibold">
|
||||
{z.action}
|
||||
</Typography>
|
||||
<span className="shrink-0 rounded bg-background px-1.5 py-0.5">
|
||||
<Typography as="span" variant="caption" className="text-muted-foreground">
|
||||
{z.note}
|
||||
</Typography>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{c.causes.map((cause) => (
|
||||
<div key={cause.tag} className="rounded-lg bg-muted/40 p-2.5">
|
||||
<Typography as="span" variant="small" className="font-semibold">
|
||||
{cause.tag}
|
||||
</Typography>
|
||||
<Typography variant="caption" className="mt-0.5 block leading-relaxed">
|
||||
{cause.desc}
|
||||
</Typography>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Typography variant="caption" className="mt-auto block leading-relaxed">
|
||||
→ {c.outcome}
|
||||
{/* 2) 완료 양상 + 게이트 + 한도 → 결과 (8행 = close_reason 8종) */}
|
||||
<div className="space-y-2.5">
|
||||
<Typography as="p" variant="small" className="font-bold">
|
||||
2) 완료 양상 + 게이트 + 한도 → 결과
|
||||
</Typography>
|
||||
{DECISION_TREE.map((g) => (
|
||||
<div key={g.group} className="rounded-xl border border-border p-3">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<Typography as="span" variant="small" className="font-bold">
|
||||
{g.group}
|
||||
</Typography>
|
||||
<Typography as="span" variant="caption" className="leading-relaxed">
|
||||
{g.desc}
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="space-y-1.5">
|
||||
{g.rows.map((r) => (
|
||||
<div key={r.cond} className="flex items-center gap-2 rounded-lg bg-muted/30 p-2">
|
||||
<Typography as="span" variant="caption" className="flex-1 leading-relaxed">
|
||||
{r.cond}
|
||||
</Typography>
|
||||
<span className={cn('shrink-0 rounded-md px-2 py-0.5', TONE_CHIP[r.tone])}>
|
||||
<Typography as="span" variant="caption" className="font-semibold">
|
||||
{r.result}
|
||||
</Typography>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 3) 재생성 한도 */}
|
||||
<div className="rounded-lg border border-amber-500/40 bg-amber-500/5 p-3">
|
||||
<Typography as="p" variant="small" className="font-semibold text-amber-700 dark:text-amber-400">
|
||||
3) 재생성 한도
|
||||
</Typography>
|
||||
<Typography variant="caption" className="mt-0.5 block leading-relaxed">
|
||||
재협상 · 재입찰 · 재소집은 모두 다음 차수를 자동 생성합니다. 한도(regen_limit)는 체인(같은 견적번호) 전체에서 사유 무관 합산한 총 횟수이며 기본 1회이고, 소진하면 결렬됩니다. 모든 결과는 알림함으로 통지됩니다.
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -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({
|
||||
<InfoField label="견적번호" value={q_number} />
|
||||
<InfoField label="유형" value={quotationTypeLabel(quotation.type)} />
|
||||
<InfoField label="차수" value={`${q_round}차`} />
|
||||
<InfoField label="목표가">
|
||||
{repSessionId ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onShowTarget(repSessionId)}
|
||||
title="목표가 산정 내역 보기"
|
||||
className={cn(typographyVariants({ variant: 'link' }), 'font-bold font-sans')}
|
||||
>
|
||||
{won(repSession?.target_price)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="font-sans font-bold text-foreground">{won(repSession?.target_price)}</span>
|
||||
)}
|
||||
</InfoField>
|
||||
<InfoField label="앵커링가" value={won(repSession?.target_anchoring_price)} valueClassName="font-sans" />
|
||||
<InfoField label="견적상태" labelClassName="opacity-90 font-bold mb-1">
|
||||
<QuotationStatusBadge status={quotation.status} />
|
||||
</InfoField>
|
||||
|
||||
@ -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<void>;
|
||||
/** 한 세션(공급사)에 초청 메일 발송/재발송. */
|
||||
@ -94,7 +89,7 @@ export function SessionsStatusTab({
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg bg-card overflow-x-auto">
|
||||
<Table className="w-full text-left text-xs border-collapse font-mono min-w-[1350px]">
|
||||
<Table className="w-full text-left text-xs border-collapse font-mono min-w-[1250px]">
|
||||
<TableHeader className="bg-muted text-muted-foreground text-[10px] border-b border-border">
|
||||
<TableRow>
|
||||
<TableHead className="p-3 font-semibold font-sans">협력사</TableHead>
|
||||
@ -102,7 +97,7 @@ export function SessionsStatusTab({
|
||||
<TableHead className="p-3 font-semibold text-center font-sans">초청메일</TableHead>
|
||||
<TableHead className="p-3 font-semibold font-sans">상품</TableHead>
|
||||
<TableHead className="p-3 font-semibold text-center font-sans">협상상태</TableHead>
|
||||
<TableHead className="p-3 font-semibold text-right">목표가</TableHead>
|
||||
<TableHead className="p-3 font-semibold text-right">앵커링가</TableHead>
|
||||
<TableHead className="p-3 font-semibold text-right">투찰가</TableHead>
|
||||
<TableHead className="p-3 font-semibold">투찰시각</TableHead>
|
||||
<TableHead className="p-3 font-semibold">마감시각</TableHead>
|
||||
@ -185,15 +180,19 @@ export function SessionsStatusTab({
|
||||
<TableCell className="p-3 text-center">
|
||||
<StatusPill tone={sessionStatusTone(sess.status)}>{sessionStatusLabel(sess.status)}</StatusPill>
|
||||
</TableCell>
|
||||
<TableCell className="p-3 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onShowTarget(sess.session_id)}
|
||||
title="목표가 산정 내역 보기"
|
||||
className={cn(typographyVariants({ variant: 'link' }), 'font-bold')}
|
||||
>
|
||||
₩{sess.target_price?.toLocaleString() || '-'}
|
||||
</button>
|
||||
<TableCell className="p-3 text-right text-muted-foreground">
|
||||
{sess.target_anchoring_price > 0 ? (
|
||||
<>
|
||||
₩{sess.target_anchoring_price.toLocaleString()}
|
||||
{sess.target_price > 0 && (
|
||||
<span className="ml-0.5 text-[10px] opacity-70">
|
||||
({((1 - sess.target_anchoring_price / sess.target_price) * 100).toFixed(1)}%)
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="p-3 text-right font-bold text-foreground">
|
||||
{sess.bid_price ? `₩${sess.bid_price.toLocaleString()}` : '-'}
|
||||
|
||||
@ -205,6 +205,7 @@ export function QuotationDetailSheet({
|
||||
quotationSettings={quotationSettings}
|
||||
sessionViews={sessionViews}
|
||||
currentProduct={currentProduct}
|
||||
onShowTarget={setTargetSessionId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -248,7 +249,6 @@ export function QuotationDetailSheet({
|
||||
sessionViews={sessionViews}
|
||||
canNotify={canNotify}
|
||||
onOpenChat={goToChat}
|
||||
onShowTarget={setTargetSessionId}
|
||||
onNotifyAll={() => onNotify(qtId)}
|
||||
onNotifyOne={(sessionId) => onNotifySession(sessionId, qtId)}
|
||||
/>
|
||||
|
||||
@ -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 (
|
||||
<Typography
|
||||
as="span"
|
||||
variant="small"
|
||||
className={`inline-flex items-center gap-1 px-2.5 py-0.5 text-[10px] font-semibold rounded-full border ${outcomeBadgeClass(state)}`}
|
||||
className={`inline-flex max-w-[180px] items-center px-2.5 py-0.5 text-[10px] font-semibold rounded-full border ${outcomeBadgeClass(state)}`}
|
||||
title={winner ?? undefined}
|
||||
>
|
||||
{quotationOutcomeLabel(est)}
|
||||
<span className="truncate">
|
||||
{CHAIN_ROUND_STATE_LABEL[state]}
|
||||
{winner ? ` - ${winner}` : ''}
|
||||
</span>
|
||||
</Typography>
|
||||
);
|
||||
},
|
||||
|
||||
@ -45,7 +45,7 @@ export interface QuotationSetting {
|
||||
export const PRICE_GATE_ACTION_LABEL: Record<PriceGateAction, string> = {
|
||||
[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<ChainRoundState, string> = {
|
||||
awarded: '낙찰',
|
||||
regenerated: '재생성',
|
||||
failed: '유찰',
|
||||
failed: '결렬',
|
||||
active: '진행중',
|
||||
};
|
||||
|
||||
@ -296,10 +296,10 @@ export const CLOSE_REASON_LABEL: Record<CloseReason, string> = {
|
||||
[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) 을 그대로 표기.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user