497 lines
28 KiB
TypeScript
497 lines
28 KiB
TypeScript
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<HeaderTone, string> = {
|
||
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 (
|
||
<header className="mb-4 flex items-center gap-3">
|
||
<span className={cn('grid size-9 flex-none place-items-center rounded-[10px] text-base font-extrabold', NUM_TONE[tone])}>{num}</span>
|
||
<div>
|
||
<Typography variant="label" className="text-[10.5px] text-muted-foreground">
|
||
{kicker} · <span className={cn('font-bold', actorCls)}>{actor}</span>
|
||
</Typography>
|
||
<Typography variant="h4" className="text-lg font-bold tracking-tight">{title}</Typography>
|
||
</div>
|
||
</header>
|
||
);
|
||
}
|
||
|
||
type CalloutTone = 'info' | 'warning' | 'success';
|
||
const CALLOUT_TONE: Record<CalloutTone, { wrap: string; icon: string; Icon: typeof Info }> = {
|
||
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 (
|
||
<div className={cn('flex gap-2.5 rounded-lg px-3.5 py-3', style.wrap)}>
|
||
<Icon className={cn('mt-0.5 size-4 flex-none', style.icon)} />
|
||
<div className="min-w-0">
|
||
{title && <Typography variant="small" className={cn('font-extrabold', tone === 'warning' && 'text-destructive')}>{title}</Typography>}
|
||
<Typography variant="caption" className={cn('block leading-relaxed text-muted-foreground', title && 'mt-0.5')}>{children}</Typography>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ── 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 (
|
||
<div className={cn('relative flex-1 rounded-lg p-4 text-center', connect ? activeCls : 'bg-muted/70')}>
|
||
{optional && (
|
||
<span className="absolute right-2 top-2 rounded-full bg-muted px-2 py-0.5 text-[10px] font-semibold text-muted-foreground">
|
||
선택사항
|
||
</span>
|
||
)}
|
||
<Icon className={cn('mx-auto size-6', connect && !optional ? activeTextCls : 'text-muted-foreground')} />
|
||
<Typography variant="label" className={cn('mt-2 block text-[11px]', connect && !optional ? activeTextCls : 'text-muted-foreground')}>{sub}</Typography>
|
||
<Typography variant="small" className={cn('mt-1.5 block font-bold leading-snug', connect && !optional && activeTextCls)}>{name}</Typography>
|
||
<Typography variant="caption" className="mt-1 block text-muted-foreground">{meta}</Typography>
|
||
</div>
|
||
);
|
||
}
|
||
const RegArrow = () => <span className="flex items-center px-1.5 text-muted-foreground max-[640px]:justify-center max-[640px]:rotate-90 max-[640px]:py-1"><ArrowRight className="size-4" /></span>;
|
||
|
||
export function PrepareStep() {
|
||
return (
|
||
<div>
|
||
<StepHeader num={1} kicker="먼저 할 일" actor="내가" tone="user" title="상품·협력사를 등록하고 연결해요" />
|
||
<Typography variant="muted" className="mb-5">무엇을 살지(상품), 누구에게 보낼지(협력사), 그리고 그 협력사가 A4용지를 어떤 방식으로 조달하는지 연결해요.</Typography>
|
||
<div className="flex items-stretch justify-center gap-1 max-[640px]:flex-col">
|
||
<RegCard Icon={Package} sub="무엇을 살까" name="A4용지" meta="기준가 100,000원" />
|
||
<RegArrow />
|
||
<RegCard Icon={Building2} sub="누구에게 보낼까" name="우리제지" meta={<>담당자 이메일 <Check className="ml-0.5 inline size-3.5 align-text-bottom text-emerald-600 dark:text-emerald-400" /></>} />
|
||
<RegArrow />
|
||
<RegCard Icon={Link2} sub="어떻게 조달하나" name="우리제지 → A4용지" meta="조달방식: 제조" connect optional />
|
||
</div>
|
||
<div className="mt-4">
|
||
<Callout title="왜 조달방식을 묻나요?">
|
||
협력사의 역할이 다르면 참고해야 할 협상 사례도 달라져요. A4용지를 <b className="text-foreground">제조·총판·유통</b> 중 어떤 역할로 취급하는지 저장해두면, 봇이 같은 유형의 결과를 모아 다음 협상카드 선택에 참고합니다.
|
||
</Callout>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ── STEP 2 · 견적 ─────────────────────────────────────────── */
|
||
type QuoteStatusTone = 'auto' | 'manual';
|
||
const QUOTE_STATUS_CLS: Record<QuoteStatusTone, string> = {
|
||
auto: 'bg-primary/10 text-primary',
|
||
manual: 'bg-muted text-muted-foreground',
|
||
};
|
||
|
||
function QuoteStatus({ children, tone }: { children: ReactNode; tone: QuoteStatusTone }) {
|
||
return (
|
||
<span className={cn('rounded-full px-2 py-0.5 text-[10px] font-extrabold leading-none', QUOTE_STATUS_CLS[tone])}>
|
||
{children}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function QuoteLine({ Icon, label, status, statusTone = 'manual', value, tip }: { Icon: LucideIcon; label: string; status?: ReactNode; statusTone?: QuoteStatusTone; value: ReactNode; tip: ReactNode }) {
|
||
return (
|
||
<div className="grid min-h-[64px] grid-cols-[2rem_minmax(0,1fr)_auto] items-center gap-3 px-4 py-3">
|
||
<span className="grid size-8 flex-none place-items-center rounded-md bg-muted text-muted-foreground"><Icon className="size-4" /></span>
|
||
<span className="min-w-0">
|
||
<span className="flex flex-wrap items-center gap-1.5">
|
||
<Typography as="span" variant="caption" className="font-bold text-muted-foreground">{label}</Typography>
|
||
{status && <QuoteStatus tone={statusTone}>{status}</QuoteStatus>}
|
||
</span>
|
||
<Typography as="span" variant="small" className="mt-1 block font-extrabold leading-snug text-foreground">{value}</Typography>
|
||
</span>
|
||
<InfoTip mini align="right">{tip}</InfoTip>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function QuoteStep() {
|
||
return (
|
||
<div>
|
||
<StepHeader num={2} kicker="그다음" actor="내가" tone="user" title="견적을 만들어요" />
|
||
<Typography variant="muted" className="mb-5">상품을 고르면 목표가는 자동. 진행 방식을 고르고, 한 곳과 협상할 때만 낙찰 기준을 정해요.</Typography>
|
||
<div className="mx-auto max-w-[480px]">
|
||
<div className="rounded-xl border border-border bg-card shadow-sm">
|
||
<div className="flex items-start justify-between gap-4 rounded-t-xl border-b border-border bg-muted/45 px-5 py-4">
|
||
<div className="min-w-0">
|
||
<Typography variant="label" className="text-[10px] text-muted-foreground">견적서</Typography>
|
||
<Typography variant="h4" className="mt-1 truncate text-lg font-extrabold tracking-tight">A4용지 협상</Typography>
|
||
</div>
|
||
<div className="rounded-lg bg-background px-3 py-2 text-right">
|
||
<Typography as="span" variant="caption" className="block text-[10px] font-bold text-muted-foreground">견적번호</Typography>
|
||
<Typography as="span" variant="caption" className="block font-extrabold tabular-nums text-foreground">EST-2026-014</Typography>
|
||
</div>
|
||
</div>
|
||
<div className="divide-y divide-border/80">
|
||
<QuoteLine Icon={Handshake} label="진행 방식" status="직접 설정" value="한 곳과 협상 · 1:1" tip={<>여러 후보가 있으면 <b className="text-foreground">여러 곳 경쟁(견적)</b>, 단골 한 곳을 더 깎고 싶으면 <b className="text-foreground">한 곳 협상</b>. 협상카드 밀당은 '한 곳 협상'에서 돌아요.</>} />
|
||
<QuoteLine Icon={Package} label="대상" value="A4용지 · 우리제지" tip={<>협상에 부칠 상품과 견적을 보낼 협력사예요. 한 곳 협상은 이 협력사와 1:1로 진행됩니다.</>} />
|
||
<QuoteLine Icon={Target} label="가격 기준" status="자동" statusTone="auto" value="목표가 100,000원 · 앵커링가 95,000원" tip={<>목표가는 상품 기준 가격에서 자동으로 잡고, 앵커링가는 <b className="text-foreground">협상 완료 기준선</b>이에요. 협력사 제시가가 앵커링가 이하로 내려오면 협상완료로 넘어갑니다.</>} />
|
||
<QuoteLine Icon={Layers} label="협상 한도" status="직접 설정" value="협상카드 선택 · 사용한도 설정" tip={<>한 곳 협상에서 봇이 쓸 협상카드를 고릅니다. 봇은 고른 카드를 한 장씩 쓰며 최대 3번까지 재제안하고, 그때까지 앵커링가 이하로 내려오지 못하면 협상은 실패로 끝납니다.</>} />
|
||
<QuoteLine Icon={Trophy} label="낙찰 기준" status="직접 설정" value="앵커링가까지 · 엄격" tip={<>협상이 끝났을 때 <b className="text-foreground">어디까지를 낙찰로 볼지</b> 미리 정해요. 엄격(앵커링가까지)하면 더 싸게 사지만 낙찰이 덜 되고, 느슨(목표가까지)하면 낙찰은 잘 되는 대신 절감폭이 줄어요. (여러 곳 경쟁은 단독 최저가 자동 낙찰.)</>} />
|
||
</div>
|
||
</div>
|
||
<div className="mt-4">
|
||
<Callout>
|
||
<b className="text-foreground">여러 곳 경쟁 견적은 최저가 낙찰</b>로 진행돼요. <b className="text-foreground">한 곳 협상</b>을 선택했을 때만 앵커링가까지 볼지, 목표가까지 볼지 낙찰 기준을 정합니다.
|
||
</Callout>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function InviteStep() {
|
||
return (
|
||
<div>
|
||
<StepHeader num={3} kicker="그다음 · 놓치지 마세요" actor="내가" tone="must" title="협력사에게 이메일을 보내요" />
|
||
<Typography variant="muted" className="mb-5">여기가 제일 놓치기 쉬운 곳이에요. 견적을 만들었다고 협력사에게 자동으로 알려지는 건 아니에요.</Typography>
|
||
<div className="flex items-stretch justify-center gap-1 max-[640px]:flex-col">
|
||
<RegCard Icon={FileText} sub="만든 견적" name="A4용지 협상" meta="EST-2026-014" />
|
||
<RegArrow />
|
||
<RegCard Icon={Mail} sub="꼭 해야 할 일" name="이메일 발송하기" meta="협상 링크 포함" connect tone="must" />
|
||
<RegArrow />
|
||
<RegCard Icon={Building2} sub="협력사" name="우리제지" meta="링크 받고 입장" />
|
||
</div>
|
||
<div className="mt-3">
|
||
<Callout tone="warning" title="안 보내면 아무 일도 일어나지 않아요">
|
||
협력사는 견적이 생긴 줄도 몰라요. 혹시 깜빡해도 <b className="text-foreground">대시보드가 "아직 안 보낸 건"을 콕 집어</b> 알려주니 거기서 바로 보내면 돼요.
|
||
</Callout>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ── 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<NegotiationZoneTone, { marker: string; active: string; text: string; track: string }> = {
|
||
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 (
|
||
<div className="rounded-xl bg-muted/70 p-4">
|
||
<div className="flex flex-wrap items-end gap-2">
|
||
<div>
|
||
<Typography variant="caption" className="font-bold text-muted-foreground">협력사 제시가</Typography>
|
||
<Typography as="p" variant="h3" className={cn('mt-1 text-2xl font-black tabular-nums', style.text)}>
|
||
{formatWon(offer)}
|
||
</Typography>
|
||
<Typography variant="caption" className="mt-1 block text-muted-foreground">
|
||
슬라이더를 움직여 가격 구간별 봇 판단을 확인해보세요.
|
||
</Typography>
|
||
</div>
|
||
<div className="ml-auto rounded-lg bg-background px-3 py-2 text-right">
|
||
<Typography variant="caption" className="block font-bold text-muted-foreground">봇 판단</Typography>
|
||
<Typography variant="small" className={cn('block font-extrabold', style.text)}>{zone.title}</Typography>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="relative mt-5 pb-9">
|
||
<input
|
||
type="range"
|
||
min={MIN_OFFER}
|
||
max={MAX_OFFER}
|
||
step={500}
|
||
value={offer}
|
||
onChange={(e) => 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="협력사 제시가"
|
||
/>
|
||
<span className="pointer-events-none absolute top-[15px] h-2 w-px bg-muted-foreground" style={sliderMarkStyle(anchorPct)} />
|
||
<span className="pointer-events-none absolute top-[15px] h-2 w-px bg-muted-foreground" style={sliderMarkStyle(targetPct)} />
|
||
<Typography as="span" variant="caption" className="absolute top-6 -translate-x-1/2 whitespace-nowrap font-bold text-muted-foreground" style={sliderMarkStyle(anchorPct)}>
|
||
앵커링가
|
||
</Typography>
|
||
<Typography as="span" variant="caption" className="absolute top-6 -translate-x-1/2 whitespace-nowrap font-bold text-muted-foreground" style={sliderMarkStyle(targetPct)}>
|
||
목표가
|
||
</Typography>
|
||
</div>
|
||
|
||
<div className={cn('mt-6 rounded-lg px-3.5 py-3', style.active)}>
|
||
<Typography variant="small" className={cn('font-extrabold', style.text)}>{zone.action}</Typography>
|
||
<Typography variant="caption" className="mt-1 block leading-relaxed text-muted-foreground">{zone.reply}</Typography>
|
||
</div>
|
||
|
||
<div className="mt-3 rounded-lg bg-background px-3.5 py-3">
|
||
<Typography variant="small" className="font-extrabold text-foreground">그래도 가격을 안 낮추면?</Typography>
|
||
<Typography variant="caption" className="mt-1 block leading-relaxed text-muted-foreground">
|
||
봇은 <b className="text-foreground">고른 협상카드를 한 장씩 쓰며 최대 3번까지 재제안</b>해요. 그때까지 앵커링가 밑으로 못 내려오면 협상은 실패로 끝나고, 이 경우는 <b className="text-foreground">협상완료·투찰 확정이 아니어서</b> 낙찰 후보가 되지 않습니다.
|
||
</Typography>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function NegotiationStep() {
|
||
return (
|
||
<div>
|
||
<StepHeader num={4} kicker="여기서부턴 자동" actor="봇이" tone="partner" title="봇이 내 대신 협상해요" />
|
||
<Typography variant="muted" className="mb-4">협력사가 가격을 부르면, 봇은 제시가가 어느 구간인지 보고 다음 행동을 고릅니다.</Typography>
|
||
<NegotiationSlider />
|
||
<div className="mt-4">
|
||
<Callout>
|
||
<b className="text-foreground">협상 완료선은 목표가가 아니라 앵커링가예요.</b> 목표가 아래라도 앵커링가보다 높으면 봇은 한 번 더 낮추도록 유도합니다.
|
||
</Callout>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CloseSignal({ Icon, title, desc }: { Icon: typeof Info; title: string; desc: string }) {
|
||
return (
|
||
<div className="flex items-start gap-2.5">
|
||
<span className="grid size-8 flex-none place-items-center rounded-lg bg-muted text-muted-foreground">
|
||
<Icon className="size-4" />
|
||
</span>
|
||
<div className="min-w-0">
|
||
<Typography variant="small" className="font-extrabold">{title}</Typography>
|
||
<Typography variant="caption" className="mt-0.5 block leading-relaxed text-muted-foreground">{desc}</Typography>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CompareModeCard({ title, badge, tone, children }: { title: string; badge: string; tone: 'success' | 'warning'; children: ReactNode }) {
|
||
const success = tone === 'success';
|
||
return (
|
||
<div className={cn('rounded-lg border p-3.5', success ? 'border-emerald-500/30 bg-emerald-500/5' : 'border-primary/25 bg-primary/5')}>
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<Typography variant="small" className="font-black">{title}</Typography>
|
||
<span className={cn('rounded-full px-2 py-1 text-[10px] font-extrabold leading-none', success ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' : 'bg-primary/10 text-primary')}>
|
||
{badge}
|
||
</span>
|
||
</div>
|
||
<div className="mt-3 space-y-2">{children}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CompareLine({ label, children, tone = 'neutral' }: { label: string; children: ReactNode; tone?: 'success' | 'warning' | 'neutral' }) {
|
||
const success = tone === 'success';
|
||
const warning = tone === 'warning';
|
||
return (
|
||
<div className="grid grid-cols-[4.2rem_minmax(0,1fr)] gap-2 rounded-md bg-background/80 px-3 py-2">
|
||
<span className={cn('text-[11px] font-extrabold', success ? 'text-emerald-600 dark:text-emerald-400' : warning ? 'text-amber-700 dark:text-amber-400' : 'text-muted-foreground')}>
|
||
{label}
|
||
</span>
|
||
<Typography as="span" variant="caption" className="leading-relaxed text-muted-foreground">{children}</Typography>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function FollowupAction({ Icon, title, desc, tone }: { Icon: typeof Info; title: string; desc: ReactNode; tone: 'award' | 'regen' }) {
|
||
const regen = tone === 'regen';
|
||
return (
|
||
<div className={cn('rounded-lg border p-3.5', regen ? 'border-violet-500/30 bg-violet-500/5' : 'border-amber-500/30 bg-amber-500/5')}>
|
||
<div className="flex items-start gap-2.5">
|
||
<span className={cn('grid size-8 flex-none place-items-center rounded-lg', regen ? 'bg-violet-500/10 text-violet-600 dark:text-violet-400' : 'bg-amber-500/10 text-amber-700 dark:text-amber-400')}>
|
||
<Icon className="size-4" />
|
||
</span>
|
||
<div className="min-w-0">
|
||
<Typography variant="small" className={cn('font-black', regen ? 'text-violet-700 dark:text-violet-400' : 'text-amber-700 dark:text-amber-400')}>{title}</Typography>
|
||
<Typography variant="caption" className="mt-0.5 block leading-relaxed text-muted-foreground">{desc}</Typography>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ── STEP 5 · 마감/낙찰 ───────────────────────────────────── */
|
||
export function AwardStep() {
|
||
return (
|
||
<div>
|
||
<StepHeader num={5} kicker="마지막" actor="자동" tone="sys" title="마감되면 결과를 판정해요" />
|
||
<Typography variant="muted" className="mb-5">마감은 견적을 닫고 결과를 확정하는 순간이에요. 경쟁 견적과 1:1 협상은 낙찰을 판단하는 방식이 다릅니다.</Typography>
|
||
|
||
<div className="rounded-xl border border-emerald-500/30 bg-emerald-500/[0.06] p-4">
|
||
<div className="flex items-center gap-3.5 max-[420px]:flex-col max-[420px]:items-stretch max-[420px]:text-center">
|
||
<span className="grid size-11 flex-none place-items-center rounded-full bg-emerald-600 text-white max-[420px]:mx-auto">
|
||
<Trophy className="size-5" />
|
||
</span>
|
||
<div className="min-w-0 flex-1">
|
||
<Typography variant="label" className="text-[10px] text-emerald-700 dark:text-emerald-400">낙찰 확정 · A4용지 견적</Typography>
|
||
<div className="mt-1 flex flex-wrap items-baseline gap-x-2 gap-y-1 max-[420px]:justify-center">
|
||
<Typography as="span" variant="h3" className="text-2xl font-black tracking-tight tabular-nums">94,800원</Typography>
|
||
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-600/10 px-2 py-0.5">
|
||
<Typography as="span" variant="caption" className="font-extrabold text-emerald-700 dark:text-emerald-400">▼ 5,200원 절감</Typography>
|
||
<Typography as="span" variant="caption" className="text-muted-foreground">목표가 100,000원 대비</Typography>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex-none border-l border-emerald-500/20 pl-3.5 text-right max-[420px]:border-l-0 max-[420px]:border-t max-[420px]:border-emerald-500/20 max-[420px]:pl-0 max-[420px]:pt-3 max-[420px]:text-center">
|
||
<Typography variant="caption" className="block text-[10px] text-muted-foreground">낙찰 협력사</Typography>
|
||
<Typography variant="small" className="font-bold text-foreground">우리제지</Typography>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 rounded-xl border border-border bg-card p-5">
|
||
<Typography variant="caption" className="mb-3 block font-extrabold text-muted-foreground">이 결과가 정해지는 방식</Typography>
|
||
<div className="grid md:grid-cols-[0.8fr_1.2fr]">
|
||
<div className="border-b border-border pb-5 md:border-b-0 md:border-r md:pb-0 md:pr-5">
|
||
<Typography variant="caption" className="mb-3 block font-extrabold text-muted-foreground">언제 닫히나요</Typography>
|
||
<div className="space-y-4">
|
||
<CloseSignal Icon={Clock3} title="마감 시각 도달" desc="설정한 시간이 되면 자동으로 닫아요." />
|
||
<CloseSignal Icon={UserCheck} title="전원 협상 종료" desc="시간 전이라도 모든 세션이 끝나면 바로 판정해요." />
|
||
<CloseSignal Icon={FileCheck2} title="담당자 수동 마감" desc="필요하면 상세 화면에서 직접 닫을 수 있어요." />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="pt-5 md:pl-5 md:pt-0">
|
||
<Typography variant="caption" className="mb-3 block font-extrabold text-muted-foreground">방식별 마감 결과</Typography>
|
||
<div className="grid gap-3">
|
||
<CompareModeCard title="경쟁 견적 (1:N)" badge="최저가만 봄" tone="success">
|
||
<CompareLine label="낙찰" tone="success">
|
||
투찰가가 있는 협력사 중 <b className="text-foreground">단독 최저가</b>가 있으면 자동 낙찰
|
||
</CompareLine>
|
||
<CompareLine label="개찰" tone="warning">
|
||
최저가가 동가이거나, 아무도 투찰하지 않거나, 전부 협상거부
|
||
</CompareLine>
|
||
</CompareModeCard>
|
||
<CompareModeCard title="1:1 협상" badge="낙찰 기준 적용" tone="warning">
|
||
<CompareLine label="낙찰" tone="success">
|
||
협상완료 투찰가가 <b className="text-foreground">앵커링가까지 / 목표가까지</b> 중 내가 고른 기준을 통과
|
||
</CompareLine>
|
||
<CompareLine label="개찰" tone="warning">
|
||
기준 미달, 미응찰, 협상거부
|
||
</CompareLine>
|
||
</CompareModeCard>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="mt-4">
|
||
<Callout>
|
||
<b className="text-foreground">최저가 동가·전원 미응찰·전부 협상거부·1:1 협상 기준 미달</b> 케이스는 자동 낙찰하지 않고 개찰로 남아요. 이 중 동가·미응찰·가격 기준 미달처럼 다음 라운드가 필요한 경우에는 다음 견적이 자동 재생성돼요.
|
||
</Callout>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 rounded-xl border border-border bg-card p-4">
|
||
<Typography variant="caption" className="mb-3 block font-extrabold text-muted-foreground">개찰 후 처리</Typography>
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
<FollowupAction
|
||
Icon={Trophy}
|
||
title="직접 낙찰"
|
||
tone="award"
|
||
desc={<>투찰 완료 후보가 있으면 상세 화면에서 협력사를 고르고 낙찰 확정을 누릅니다.</>}
|
||
/>
|
||
<FollowupAction
|
||
Icon={RefreshCw}
|
||
title="다음 견적 재생성"
|
||
tone="regen"
|
||
desc={<>동가·미응찰·가격 기준 미달처럼 다시 견적이 필요한 경우 다음 라운드가 이어집니다.</>}
|
||
/>
|
||
</div>
|
||
<div className="mt-3">
|
||
<Callout>
|
||
<b className="text-foreground">결과는 알림함으로 오고</b>, 대시보드와 견적 상세에서 낙찰가·절감액·마감 사유를 다시 확인할 수 있어요.
|
||
</Callout>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-5 flex items-center justify-center gap-2 text-muted-foreground">
|
||
<Bell className="size-4" />
|
||
<Typography variant="small" className="font-semibold">상품 등록부터 낙찰 결과 확인까지, 여기까지가 전체 흐름이에요.</Typography>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|