import { useState, type ReactNode } from 'react'; import { AlertTriangle, ArrowRight, Bell, Building2, Check, CheckCircle2, Clock3, FileCheck2, FileText, Handshake, Info, Layers, Link2, Mail, Package, RefreshCw, Target, Trophy, UserCheck, type LucideIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Typography } from '@/components/ui/typography'; import { InfoTip } from './InfoTip'; // 온보딩 상세 단계(1~5). step0(OverviewStep) 뒤에 "하나씩 자세히" 보여주는 정적 패널들. type HeaderTone = 'user' | 'partner' | 'must' | 'sys'; const NUM_TONE: Record = { user: 'bg-primary/10 text-primary', partner: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', must: 'bg-destructive/10 text-destructive', sys: 'bg-muted text-muted-foreground', }; function StepHeader({ num, kicker, actor, title, tone }: { num: number; kicker: string; actor: string; title: string; tone: HeaderTone }) { const actorCls = tone === 'partner' ? 'text-emerald-600 dark:text-emerald-400' : tone === 'sys' ? 'text-muted-foreground' : 'text-primary'; return (
{num}
{kicker} · {actor} {title}
); } type CalloutTone = 'info' | 'warning' | 'success'; const CALLOUT_TONE: Record = { info: { wrap: 'bg-muted/70', icon: 'text-muted-foreground', Icon: Info }, warning: { wrap: 'bg-destructive/10', icon: 'text-destructive', Icon: AlertTriangle }, success: { wrap: 'bg-emerald-500/10', icon: 'text-emerald-600 dark:text-emerald-400', Icon: CheckCircle2 }, }; export function Callout({ tone = 'info', title, children }: { tone?: CalloutTone; title?: string; children: ReactNode }) { const style = CALLOUT_TONE[tone]; const Icon = style.Icon; return (
{title && {title}} {children}
); } /* ── STEP 1 · 준비 ─────────────────────────────────────────── */ function RegCard({ Icon, sub, name, meta, connect, optional, tone = 'primary' }: { Icon: LucideIcon; sub: string; name: ReactNode; meta: ReactNode; connect?: boolean; optional?: boolean; tone?: 'primary' | 'must' }) { const activeCls = optional ? 'bg-muted/40 text-muted-foreground' : tone === 'must' ? 'bg-destructive/5' : 'bg-primary/5'; const activeTextCls = tone === 'must' ? 'text-destructive' : 'text-primary'; return (
{optional && ( 선택사항 )} {sub} {name} {meta}
); } const RegArrow = () => ; export function PrepareStep() { return (
무엇을 살지(상품), 누구에게 보낼지(협력사), 그리고 그 협력사가 A4용지를 어떤 방식으로 조달하는지 연결해요.
담당자 이메일 } />
협력사의 역할이 다르면 참고해야 할 협상 사례도 달라져요. A4용지를 제조·총판·유통 중 어떤 역할로 취급하는지 저장해두면, 봇이 같은 유형의 결과를 모아 다음 협상카드 선택에 참고합니다.
); } /* ── STEP 2 · 견적 ─────────────────────────────────────────── */ type QuoteStatusTone = 'auto' | 'manual'; const QUOTE_STATUS_CLS: Record = { auto: 'bg-primary/10 text-primary', manual: 'bg-muted text-muted-foreground', }; function QuoteStatus({ children, tone }: { children: ReactNode; tone: QuoteStatusTone }) { return ( {children} ); } function QuoteLine({ Icon, label, status, statusTone = 'manual', value, tip }: { Icon: LucideIcon; label: string; status?: ReactNode; statusTone?: QuoteStatusTone; value: ReactNode; tip: ReactNode }) { return (
{label} {status && {status}} {value} {tip}
); } export function QuoteStep() { return (
상품을 고르면 목표가는 자동. 진행 방식을 고르고, 한 곳과 협상할 때만 낙찰 기준을 정해요.
견적서 A4용지 협상
견적번호 EST-2026-014
여러 후보가 있으면 여러 곳 경쟁(견적), 단골 한 곳을 더 깎고 싶으면 한 곳 협상. 협상카드 밀당은 '한 곳 협상'에서 돌아요.} /> 협상에 부칠 상품과 견적을 보낼 협력사예요. 한 곳 협상은 이 협력사와 1:1로 진행됩니다.} /> 목표가는 상품 기준 가격에서 자동으로 잡고, 앵커링가는 협상 완료 기준선이에요. 협력사 제시가가 앵커링가 이하로 내려오면 협상완료로 넘어갑니다.} /> 한 곳 협상에서 봇이 쓸 협상카드를 고릅니다. 봇은 고른 카드를 한 장씩 쓰며 최대 3번까지 재제안하고, 그때까지 앵커링가 이하로 내려오지 못하면 협상은 실패로 끝납니다.} /> 협상이 끝났을 때 어디까지를 낙찰로 볼지 미리 정해요. 엄격(앵커링가까지)하면 더 싸게 사지만 낙찰이 덜 되고, 느슨(목표가까지)하면 낙찰은 잘 되는 대신 절감폭이 줄어요. (여러 곳 경쟁은 단독 최저가 자동 낙찰.)} />
여러 곳 경쟁 견적은 최저가 낙찰로 진행돼요. 한 곳 협상을 선택했을 때만 앵커링가까지 볼지, 목표가까지 볼지 낙찰 기준을 정합니다.
); } export function InviteStep() { return (
여기가 제일 놓치기 쉬운 곳이에요. 견적을 만들었다고 협력사에게 자동으로 알려지는 건 아니에요.
협력사는 견적이 생긴 줄도 몰라요. 혹시 깜빡해도 대시보드가 "아직 안 보낸 건"을 콕 집어 알려주니 거기서 바로 보내면 돼요.
); } /* ── STEP 4 · 협상 ─────────────────────────────────────────── */ type NegotiationZoneTone = 'good' | 'mid' | 'high'; interface NegotiationZone { id: NegotiationZoneTone; range: string; title: string; action: string; reply: string; } const ANCHOR_PRICE = 95_000; const TARGET_PRICE = 100_000; const MIN_OFFER = 93_000; const MAX_OFFER = 108_000; const WILD_NEAR_PRICE = Math.floor(ANCHOR_PRICE * 1.02); const NEGOTIATION_ZONES: NegotiationZone[] = [ { id: 'good', range: '95,000원 이하', title: '바로 확정', action: '협상 완료 · 투찰 확정', reply: '좋습니다. 이 금액으로 진행하겠습니다.', }, { id: 'mid', range: '앵커 초과 ~ 앵커×1.02', title: '막판 와일드카드', action: '1% 인하 요청', reply: '거의 맞았습니다. 딱 조금만 더 맞춰주시면 바로 확정할게요.', }, { id: 'high', range: '앵커×1.02 초과', title: '일반 협상카드', action: '일반 협상카드로 재제안', reply: '현재 제시가는 기준보다 높습니다. 조정 가능한 최선의 가격을 알려주세요.', }, ]; const ZONE_STYLE: Record = { good: { marker: 'bg-emerald-600', active: 'bg-emerald-500/10', text: 'text-emerald-600 dark:text-emerald-400', track: '#10b981', }, mid: { marker: 'bg-amber-500', active: 'bg-amber-500/10', text: 'text-amber-600 dark:text-amber-400', track: '#f59e0b', }, high: { marker: 'bg-primary', active: 'bg-primary/10', text: 'text-primary', track: '#5E6AD2', }, }; function formatWon(value: number) { return `${value.toLocaleString('en-US')}원`; } function sliderMarkStyle(percent: number) { const thumbRadius = 10; return { left: `calc(${percent}% + ${thumbRadius - (percent / 100) * thumbRadius * 2}px)` }; } function getNegotiationZone(value: number): NegotiationZone { if (value <= ANCHOR_PRICE) return NEGOTIATION_ZONES[0]; if (value <= WILD_NEAR_PRICE) return NEGOTIATION_ZONES[1]; return NEGOTIATION_ZONES[2]; } function NegotiationSlider() { const [offer, setOffer] = useState(102_000); const zone = getNegotiationZone(offer); const style = ZONE_STYLE[zone.id]; const pct = ((offer - MIN_OFFER) / (MAX_OFFER - MIN_OFFER)) * 100; const anchorPct = ((ANCHOR_PRICE - MIN_OFFER) / (MAX_OFFER - MIN_OFFER)) * 100; const targetPct = ((TARGET_PRICE - MIN_OFFER) / (MAX_OFFER - MIN_OFFER)) * 100; return (
협력사 제시가 {formatWon(offer)} 슬라이더를 움직여 가격 구간별 봇 판단을 확인해보세요.
봇 판단 {zone.title}
setOffer(Number(e.target.value))} className="relative z-10 h-2 w-full cursor-pointer appearance-none rounded-full bg-border accent-primary [&::-moz-range-thumb]:relative [&::-moz-range-thumb]:z-20 [&::-moz-range-thumb]:size-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-4 [&::-moz-range-thumb]:border-background [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:shadow [&::-webkit-slider-thumb]:relative [&::-webkit-slider-thumb]:z-20 [&::-webkit-slider-thumb]:size-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-4 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:shadow" style={{ background: `linear-gradient(to right, ${style.track} 0%, ${style.track} ${pct}%, var(--border) ${pct}%, var(--border) 100%)`, }} aria-label="협력사 제시가" /> 앵커링가 목표가
{zone.action} {zone.reply}
그래도 가격을 안 낮추면? 봇은 고른 협상카드를 한 장씩 쓰며 최대 3번까지 재제안해요. 그때까지 앵커링가 밑으로 못 내려오면 협상은 실패로 끝나고, 이 경우는 협상완료·투찰 확정이 아니어서 낙찰 후보가 되지 않습니다.
); } export function NegotiationStep() { return (
협력사가 가격을 부르면, 봇은 제시가가 어느 구간인지 보고 다음 행동을 고릅니다.
협상 완료선은 목표가가 아니라 앵커링가예요. 목표가 아래라도 앵커링가보다 높으면 봇은 한 번 더 낮추도록 유도합니다.
); } function CloseSignal({ Icon, title, desc }: { Icon: typeof Info; title: string; desc: string }) { return (
{title} {desc}
); } function CompareModeCard({ title, badge, tone, children }: { title: string; badge: string; tone: 'success' | 'warning'; children: ReactNode }) { const success = tone === 'success'; return (
{title} {badge}
{children}
); } function CompareLine({ label, children, tone = 'neutral' }: { label: string; children: ReactNode; tone?: 'success' | 'warning' | 'neutral' }) { const success = tone === 'success'; const warning = tone === 'warning'; return (
{label} {children}
); } function FollowupAction({ Icon, title, desc, tone }: { Icon: typeof Info; title: string; desc: ReactNode; tone: 'award' | 'regen' }) { const regen = tone === 'regen'; return (
{title} {desc}
); } /* ── STEP 5 · 마감/낙찰 ───────────────────────────────────── */ export function AwardStep() { return (
마감은 견적을 닫고 결과를 확정하는 순간이에요. 경쟁 견적과 1:1 협상은 낙찰을 판단하는 방식이 다릅니다.
낙찰 확정 · A4용지 견적
94,800원 ▼ 5,200원 절감 목표가 100,000원 대비
낙찰 협력사 우리제지
이 결과가 정해지는 방식
언제 닫히나요
방식별 마감 결과
투찰가가 있는 협력사 중 단독 최저가가 있으면 자동 낙찰 최저가가 동가이거나, 아무도 투찰하지 않거나, 전부 협상거부 협상완료 투찰가가 앵커링가까지 / 목표가까지 중 내가 고른 기준을 통과 기준 미달, 미응찰, 협상거부
최저가 동가·전원 미응찰·전부 협상거부·1:1 협상 기준 미달 케이스는 자동 낙찰하지 않고 개찰로 남아요. 이 중 동가·미응찰·가격 기준 미달처럼 다음 라운드가 필요한 경우에는 다음 견적이 자동 재생성돼요.
개찰 후 처리
투찰 완료 후보가 있으면 상세 화면에서 협력사를 고르고 낙찰 확정을 누릅니다.} /> 동가·미응찰·가격 기준 미달처럼 다시 견적이 필요한 경우 다음 라운드가 이어집니다.} />
결과는 알림함으로 오고, 대시보드와 견적 상세에서 낙찰가·절감액·마감 사유를 다시 확인할 수 있어요.
상품 등록부터 낙찰 결과 확인까지, 여기까지가 전체 흐름이에요.
); }