[feat] negodata/front: 견적 생성 UX 개선 + 온보딩 견적유형 안내

- 견적 생성: 협력사 유형 전 유형 입력(항상 노출), 명칭 협상견적→견적, 단계 인디케이터 확대·클릭 이동, 라벨 통일
- 온보딩 가이드: 견적 유형 차이 안내 탭(목표가 산정·협력사 수, MD 제시가 우선 강조)

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

View File

@ -0,0 +1,390 @@
import { useState, type ElementType } from 'react';
import { LayoutDashboard, FileText, Mail, Users, Clock, Award, RefreshCw, XCircle } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Typography } from '@/components/ui/typography';
import { cn } from '@/lib/utils';
// 신규 유저용 프로세스 안내. '흐름 설명'(6단계)과 '마감 결과'(낙찰/재생성/결렬 + 사유) 두 뷰.
// 읽기 전용 정적 안내(테이블/서버 없음). 첫 방문 자동 노출 + 대시보드 버튼으로 재오픈은 호출부(대시보드)에서 제어.
type Actor = 'user' | 'partner' | 'system';
interface Step {
num: number;
title: string;
actor: string;
actorType: Actor;
short: string;
detail: string;
icon: ElementType;
highlight?: boolean; // 초청메일: 수동 발송 안 하면 협상이 시작 안 됨 → 강조
}
const STEPS: Step[] = [
{
num: 1,
title: '준비',
actor: '나',
actorType: 'user',
icon: LayoutDashboard,
short: '상품·협력사·협상카드 등록',
detail:
'협상에 부칠 상품(가격 기준: 인터넷최저가·매입가·판매가 중 최소 하나)과 견적을 보낼 협력사(담당자 이메일 필수)를 먼저 등록합니다. 협상 멘트 전략인 협상카드는 기본 제공 카드는 제외되고, 직접 만든 카드만 목록에 떠 견적에 사용할 수 있습니다.',
},
{
num: 2,
title: '견적 생성',
actor: '나',
actorType: 'user',
icon: FileText,
short: '유형 선택·목표가 자동 산출',
detail:
'상품과 협력사를 묶어 견적을 만들면 상품×협력사 조합마다 협상 세션이 생기고, 상품 가격 기준으로 목표가가 자동 산정됩니다. 이 단계까지는 협력사에게 아무것도 공개되지 않습니다.',
},
{
num: 3,
title: '초청메일',
actor: '나',
actorType: 'user',
icon: Mail,
highlight: true,
short: '메일을 보내야 협상이 시작됨',
detail:
'견적 생성 후 직접 초청메일을 발송해야 협력사에게 투찰 링크가 전달되고 협상이 실제로 시작됩니다. 메일을 보내지 않으면 협력사는 견적이 생긴 줄도 모르고 아무 일도 일어나지 않습니다. 누락 건은 대시보드의 "메일 미발송" 위젯에서 바로 잡을 수 있습니다.',
},
{
num: 4,
title: '협상',
actor: '협력사',
actorType: 'partner',
icon: Users,
short: '협력사가 가격을 투찰',
detail:
'초청메일을 받은 협력사가 전용 채팅방에 입장해 AI 협상카드의 앵커링 응대에 따라 가격을 투찰합니다. 진행 현황은 견적 상세에서 실시간으로 볼 수 있습니다.',
},
{
num: 5,
title: '마감',
actor: '나/자동',
actorType: 'system',
icon: Clock,
short: '기한 도달 또는 전원 완료 시',
detail:
'설정한 마감 시각에 도달하거나 초청한 협력사가 전원 투찰을 마치면 자동으로 마감되어 판정에 들어갑니다. 필요하면 견적 상세에서 직접 수동 마감할 수도 있습니다.',
},
{
num: 6,
title: '낙찰 판정',
actor: '시스템',
actorType: 'system',
icon: Award,
short: '단독최저=낙찰 / 동가·미참여=재생성 / 결렬',
detail:
'단독 최저가면 그 협력사로 낙찰됩니다. 최저가가 둘 이상 같은 동가이거나 전원 미참여면 다음 차수가 자동 재생성되고, 거절·한도 등으로 낙찰자가 없으면 결렬 처리됩니다. 결과는 알림함으로 통지되고 대시보드에서 현황을 확인합니다.',
},
];
const ACTOR_CLASS: Record<Actor, string> = {
user: 'text-primary',
partner: 'text-emerald-600 dark:text-emerald-400',
system: 'text-muted-foreground',
};
// ----- 견적 마감 결과(close_and_decide 기준): 낙찰 / 재생성(동가·미참여) / 결렬(거부·한도). 모두 알림함 통지 -----
type Tone = 'win' | 'tie' | 'fail';
interface ResultCase {
key: string;
label: string;
tone: Tone;
icon: ElementType;
causes: { tag: string; desc: string }[];
outcome: string;
}
const RESULT_CASES: ResultCase[] = [
{
key: 'win',
label: '낙찰',
tone: 'win',
icon: Award,
causes: [{ tag: '단독 최저가', desc: '협상완료 중 최저가를 낸 협력사가 한 곳뿐' }],
outcome: '그 협력사로 낙찰 확정.',
},
{
key: 'regen',
label: '재생성 · 다음 차수',
tone: 'tie',
icon: RefreshCw,
causes: [
{ tag: '동가', desc: '최저가가 2개사 이상 동일 → 동가 업체끼리 다음 차수' },
{ tag: '전원 미참여', desc: '아무도 투찰 안 함 → 원 공급사 전체로 다음 차수' },
],
outcome: '해당 차수는 낙찰자 없이 마감되고 다음 차수가 자동 생성됩니다. (사유별 1회씩, 최대 2회까지)',
},
{
key: 'fail',
label: '결렬 · 그냥 마감',
tone: 'fail',
icon: XCircle,
causes: [
{ tag: '협상거부', desc: '거부한 협력사가 하나라도 있음 → 재생성 없이 마감' },
{ tag: '재생성 한도', desc: '동가·미참여 재생성을 이미 써서 더 못 돌림' },
],
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(부르는 협력사 수) -----
interface QtType {
key: string;
label: string;
isNew: boolean;
scope: string; // 1:1 / 1:N
suppliers: string; // 부르는 협력사 수
basis: 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: '인터넷최저·매입·판매 중 최소' },
];
const TAB_LABEL = { flow: '흐름 설명', type: '견적 유형', example: '마감 결과' } as const;
export function OnboardingGuideModal({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [tab, setTab] = useState<'flow' | 'type' | 'example'>('flow');
const [activeNum, setActiveNum] = useState(1);
const active = STEPS.find((s) => s.num === activeNum) ?? STEPS[0];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[88vh] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>
<Typography as="span" variant="h3">
협상은 이렇게 진행됩니다
</Typography>
</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) => (
<button
key={t}
type="button"
onClick={() => setTab(t)}
className={cn('rounded px-3 py-1 transition-colors', tab === t ? 'bg-background shadow-sm' : 'hover:bg-background/50')}
>
<Typography as="span" variant="small" className={cn('font-semibold', tab !== t && 'text-muted-foreground')}>
{TAB_LABEL[t]}
</Typography>
</button>
))}
</div>
{tab === 'flow' && (
<>
{/* 6단계 흐름 — 누르면 아래 상세가 바뀜 */}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
{STEPS.map((s) => {
const Icon = s.icon;
const isActive = s.num === activeNum;
return (
<button
key={s.num}
type="button"
onClick={() => setActiveNum(s.num)}
className={cn(
'flex flex-col gap-1.5 rounded-lg border p-3 text-left transition-colors',
s.highlight ? 'border-destructive/60' : 'border-border',
isActive
? s.highlight
? 'bg-destructive/5 ring-2 ring-destructive/20'
: 'bg-primary/5 ring-2 ring-primary/20'
: 'hover:bg-muted/50',
)}
>
<div className="flex items-center justify-between">
<Badge variant={s.highlight ? 'destructive' : 'secondary'}>{`0${s.num}`}</Badge>
<Typography as="span" variant="caption" className={ACTOR_CLASS[s.actorType]}>
[{s.actor}]
</Typography>
</div>
<Icon
size={18}
className={s.highlight ? 'text-destructive' : isActive ? 'text-primary' : 'text-muted-foreground'}
/>
<Typography variant="small" className={cn('font-semibold', s.highlight && 'text-destructive')}>
{s.title}
</Typography>
</button>
);
})}
</div>
{/* 선택 단계 상세 설명 */}
<div
className={cn(
'rounded-lg border p-4',
active.highlight ? 'border-destructive/40 bg-destructive/5' : 'border-border bg-muted/30',
)}
>
<div className="mb-1.5 flex items-center gap-2">
<Typography variant="h4" className={active.highlight ? 'text-destructive' : undefined}>
{`0${active.num}. ${active.title}`}
</Typography>
<Typography as="span" variant="caption" className={ACTOR_CLASS[active.actorType]}>
[{active.actor}]
</Typography>
</div>
<Typography variant="muted">{active.detail}</Typography>
</div>
</>
)}
{tab === 'type' && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Typography variant="h4">견적 유형에 따라 이렇게 달라집니다</Typography>
<Badge variant="outline">4종</Badge>
</div>
<Typography variant="muted">
유형은 두 축으로 갈립니다 — 신규/재(목표가 산정 기준)와 협상(1:1)/견적(1:N)(부르는 협력사 수).
</Typography>
<Typography variant="small" className="font-bold">
MD 제시가를 넣으면 유형과 무관하게 그 값이 목표가 최우선입니다.
</Typography>
<div className="grid gap-3 sm:grid-cols-2">
{QUOTATION_TYPES.map((t) => (
<div key={t.key} className={cn('flex flex-col gap-2 rounded-xl border p-3', t.isNew ? 'border-primary/30' : 'border-amber-500/40')}>
<div className="flex items-center gap-2">
<Typography as="span" variant="small" className="font-bold">{t.label}</Typography>
<Badge variant="outline">{t.scope}</Badge>
</div>
<div className="space-y-1.5">
<div className="rounded-lg bg-muted/40 p-2.5">
<Typography as="span" variant="small" className="font-semibold">협력사</Typography>
<Typography variant="caption" className="mt-0.5 block leading-relaxed">{t.suppliers}</Typography>
</div>
<div className="rounded-lg bg-muted/40 p-2.5">
<Typography as="span" variant="small" className="font-semibold">목표가 산정</Typography>
<Typography variant="caption" className="mt-0.5 block leading-relaxed">{t.basis}</Typography>
</div>
</div>
</div>
))}
</div>
<div className="space-y-2 rounded-lg border border-border bg-muted/30 p-3">
<div>
<Typography as="span" variant="small" className="font-semibold">신규 / 재</Typography>
<Typography variant="caption" className="mt-0.5 block leading-relaxed">
신규견적·신규협상은 인터넷최저가만으로 목표가를 잡고, 재견적·재협상은 매입가·판매가까지 후보에 넣어 그중 최소값을 씁니다.
</Typography>
</div>
<div>
<Typography as="span" variant="small" className="font-semibold">협상 / 견적</Typography>
<Typography variant="caption" className="mt-0.5 block leading-relaxed">
협상은 한 곳과 1:1, 견적은 여러 곳과 1:N.
</Typography>
</div>
</div>
</div>
)}
{tab === 'example' && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<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="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>
</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}
</Typography>
</div>
);
})}
</div>
</div>
)}
<DialogFooter>
<Button onClick={() => onOpenChange(false)}>시작하기</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,87 @@
import { LayoutDashboard, FileText, Mail, Users, Clock, Award, type LucideIcon } from 'lucide-react';
// 견적 생성~낙찰까지 6단계 흐름. 온보딩 가이드 모달과 대시보드 라이프사이클 스텝퍼가 공유한다.
// 단일 출처로 두 곳이 어긋나지 않게 한다.
export type Actor = 'user' | 'partner' | 'system';
export interface Step {
num: number;
title: string;
actor: string;
actorType: Actor;
short: string;
detail: string;
icon: LucideIcon;
highlight?: boolean; // 초청메일: 수동 발송 안 하면 협상이 시작 안 됨 → 강조
}
export const STEPS: Step[] = [
{
num: 1,
title: '준비',
actor: '나',
actorType: 'user',
icon: LayoutDashboard,
short: '상품·협력사·협상카드 등록',
detail:
'협상에 부칠 상품(가격 기준: 인터넷최저가·매입가·판매가 중 최소 하나)과 견적을 보낼 협력사(담당자 이메일 필수)를 먼저 등록합니다. 협상 멘트 전략인 협상카드는 기본 제공 카드는 제외되고, 직접 만든 카드만 목록에 떠 견적에 사용할 수 있습니다.',
},
{
num: 2,
title: '견적 생성',
actor: '나',
actorType: 'user',
icon: FileText,
short: '유형 선택·목표가 자동 산출',
detail:
'상품과 협력사를 묶어 견적을 만들면 상품×협력사 조합마다 협상 세션이 생기고, 상품 가격 기준으로 목표가가 자동 산정됩니다. 이 단계까지는 협력사에게 아무것도 공개되지 않습니다.',
},
{
num: 3,
title: '초청메일',
actor: '나',
actorType: 'user',
icon: Mail,
highlight: true,
short: '메일을 보내야 협상이 시작됨',
detail:
'견적 생성 후 직접 초청메일을 발송해야 협력사에게 투찰 링크가 전달되고 협상이 실제로 시작됩니다. 메일을 보내지 않으면 협력사는 견적이 생긴 줄도 모르고 아무 일도 일어나지 않습니다. 누락 건은 대시보드의 "메일 미발송" 위젯에서 바로 잡을 수 있습니다.',
},
{
num: 4,
title: '협상',
actor: '협력사',
actorType: 'partner',
icon: Users,
short: '협력사가 가격을 투찰',
detail:
'초청메일을 받은 협력사가 전용 채팅방에 입장해 AI 협상카드의 앵커링 응대에 따라 가격을 투찰합니다. 진행 현황은 견적 상세에서 실시간으로 볼 수 있습니다.',
},
{
num: 5,
title: '마감',
actor: '나/자동',
actorType: 'system',
icon: Clock,
short: '기한 도달 또는 전원 완료 시',
detail:
'설정한 마감 시각에 도달하거나 초청한 협력사가 전원 투찰을 마치면 자동으로 마감되어 판정에 들어갑니다. 필요하면 견적 상세에서 직접 수동 마감할 수도 있습니다.',
},
{
num: 6,
title: '낙찰 판정',
actor: '시스템',
actorType: 'system',
icon: Award,
short: '단독최저=낙찰 / 동가·미참여=재생성 / 결렬',
detail:
'단독 최저가면 그 협력사로 낙찰됩니다. 최저가가 둘 이상 같은 동가이거나 전원 미참여면 다음 차수가 자동 재생성되고, 거절·한도 등으로 낙찰자가 없으면 결렬 처리됩니다. 결과는 알림함으로 통지되고 대시보드에서 현황을 확인합니다.',
},
];
export const ACTOR_CLASS: Record<Actor, string> = {
user: 'text-primary',
partner: 'text-emerald-600 dark:text-emerald-400',
system: 'text-muted-foreground',
};

View File

@ -50,11 +50,11 @@ export function QuotationCreateModal({
const [selectedCardIds, setSelectedCardIds] = useState<string[]>([]);
const [memo, setMemo] = useState('');
const [mdPrice, setMdPrice] = useState(''); // MD 제시가(원). 비우면 미전송 → 서버가 상품값으로 목표가 산정
const [supplierType, setSupplierType] = useState(''); // 협력사 유형(SupplierType). 재견적 1:1 → 견적에 기록
const [supplierType, setSupplierType] = useState(''); // 협력사 유형(SupplierType). 전 유형에서 입력 → 견적에 기록
const [submitting, setSubmitting] = useState(false);
const typeOptions = QUOTATION_TYPE_OPTIONS;
// 협력사 유형은 재협상(1:1)에서만. 선택된 협력사의 직전 견적 supplier_type 을 조회해 디폴트로 채운다.
// 협력사 유형은 전 유형에서 입력받되, 재협상(1:1)이면 선택 협력사의 직전 견적 supplier_type 을 조회해 디폴트로 채운다.
const renegoSupplierId = type === QuotationType.RENEGO ? (selectedPartnerIds[0] ?? '') : '';
const lastTypeQuery = useGetSupplierLastType(renegoSupplierId, {
query: { enabled: !!renegoSupplierId },
@ -127,7 +127,7 @@ export function QuotationCreateModal({
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div className="flex flex-col items-center gap-3 rounded-xl bg-card px-8 py-6 shadow-2xl border border-border">
<Loader2 className="text-primary animate-spin" size={44} strokeWidth={2.5} />
<Typography variant="small" className="font-semibold font-mono">협상견적 생성 중…</Typography>
<Typography variant="small" className="font-semibold font-mono">견적 생성 중…</Typography>
<Typography variant="small" className="text-muted-foreground font-mono">견적 · 협상 세션 등록 중</Typography>
</div>
</div>
@ -138,7 +138,7 @@ export function QuotationCreateModal({
<div className="flex items-center justify-between pb-4 border-b border-border">
<div className="flex items-center gap-2">
<PlusSquare className="text-foreground" size={18} />
<Typography variant="small" className="font-bold">신규 협상견적 등록 (단계 {step}/3)</Typography>
<Typography variant="small" className="font-bold">신규 견적 등록 (단계 {step}/3)</Typography>
</div>
<button onClick={onClose} className="p-1 rounded text-muted-foreground hover:bg-muted cursor-pointer">
<X size={18} />
@ -146,12 +146,18 @@ export function QuotationCreateModal({
</div>
{/* Steps indicator */}
<div className="flex items-center justify-between gap-2 py-4 border-b border-border/40 text-[10px] text-muted-foreground">
<Typography as="span" variant="label" className={`font-semibold ${step >= 1 ? 'text-primary' : 'text-muted-foreground'}`}>1. 기본 정보</Typography>
<ArrowRight size={10} />
<Typography as="span" variant="label" className={`font-semibold ${step >= 2 ? 'text-primary' : 'text-muted-foreground'}`}>2. 협력사 초청</Typography>
<ArrowRight size={10} />
<Typography as="span" variant="label" className={`font-semibold ${step >= 3 ? 'text-primary' : 'text-muted-foreground'}`}>3. 설정·완료</Typography>
<div className="flex items-center justify-between gap-2 py-4 border-b border-border/40 text-muted-foreground">
<button type="button" onClick={() => setStep(1)} className="cursor-pointer hover:opacity-75 transition-opacity">
<Typography as="span" variant="body" className={`font-semibold ${step >= 1 ? 'text-primary' : 'text-muted-foreground'}`}>1. 기본 정보</Typography>
</button>
<ArrowRight size={14} className="shrink-0" />
<button type="button" onClick={() => setStep(2)} className="cursor-pointer hover:opacity-75 transition-opacity">
<Typography as="span" variant="body" className={`font-semibold ${step >= 2 ? 'text-primary' : 'text-muted-foreground'}`}>2. 협력사 초청</Typography>
</button>
<ArrowRight size={14} className="shrink-0" />
<button type="button" onClick={() => setStep(3)} className="cursor-pointer hover:opacity-75 transition-opacity">
<Typography as="span" variant="body" className={`font-semibold ${step >= 3 ? 'text-primary' : 'text-muted-foreground'}`}>3. 설정·완료</Typography>
</button>
</div>
{/* Step content */}
@ -290,7 +296,7 @@ export function QuotationCreateModal({
{step === 2 && (
<div className="space-y-3">
<Typography as="span" variant="small" className="font-semibold block">협력사 초청 ({type === QuotationType.RENEGO ? '단일선택' : '다중선택'})</Typography>
<Typography as="span" variant="label" className="block">협력사 초청 ({type === QuotationType.RENEGO ? '단일선택' : '다중선택'})</Typography>
<div className="border border-border rounded overflow-hidden max-h-56 overflow-y-auto divide-y divide-border bg-background">
{partners.map((part) => {
const isChecked = selectedPartnerIds.includes(part.id ?? '');
@ -323,29 +329,27 @@ export function QuotationCreateModal({
})}
</div>
{/* 협력사 유형 — 재협상(1:1)에서 협력사 선택 후 노출. 직전 견적 값 자동 디폴트. */}
{type === QuotationType.RENEGO && selectedPartnerIds.length > 0 && (
<div className="space-y-1 pt-3 border-t border-border/40">
<Typography as="label" variant="label">협력사 유형</Typography>
<Select value={supplierType} onValueChange={(v) => setSupplierType(v ?? '')}>
<SelectTrigger id="wizard-supplier-type" className="w-full">
<SelectValue>
{(value) => (value ? supplierTypeLabel(Number(value)) : '협력사 유형 선택...')}
</SelectValue>
</SelectTrigger>
<SelectContent>
{supplierTypeOptions.map((o) => (
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
{prevSupplierType != null && (
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">
이전 견적({prevQtNumber}) 값으로 자동 선택됨 · 수정 가능
</Typography>
)}
</div>
)}
{/* 협력사 유형 — 항상 노출(처음부터 입력 가능). 재협상(1:1)이면 선택 협력사의 직전 견적 값으로 자동 디폴트. */}
<div className="space-y-1 pt-3 border-t border-border/40">
<Typography as="label" variant="label">협력사 유형</Typography>
<Select value={supplierType} onValueChange={(v) => setSupplierType(v ?? '')}>
<SelectTrigger id="wizard-supplier-type" className="w-full">
<SelectValue>
{(value) => (value ? supplierTypeLabel(Number(value)) : '협력사 유형 선택...')}
</SelectValue>
</SelectTrigger>
<SelectContent>
{supplierTypeOptions.map((o) => (
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
{prevSupplierType != null && (
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">
이전 견적({prevQtNumber}) 값으로 자동 선택됨 · 수정 가능
</Typography>
)}
</div>
</div>
)}
@ -375,7 +379,7 @@ export function QuotationCreateModal({
</div>
<div className="space-y-2">
<Typography as="span" variant="small" className="font-semibold block">협상카드 및 와일드카드 선택</Typography>
<Typography as="span" variant="label" className="block">협상카드 및 와일드카드 선택</Typography>
<div className="grid grid-cols-2 gap-2 max-h-48 overflow-y-auto">
{cards.filter((c) => !c.isWildcard || c.status === 'ACTIVE').map((card) => {
const isChecked = selectedCardIds.includes(card.id);
@ -452,7 +456,7 @@ export function QuotationCreateModal({
</Button>
) : (
<Button type="button" size="sm" onClick={handleSubmit} disabled={submitting}>
{submitting ? '생성 중…' : '협상견적 등록'}
{submitting ? '생성 중…' : '견적 등록'}
</Button>
)}
</div>