o2o-negosium-original/negodata/front/src/features/onboarding/OnboardingGuideModal.tsx

499 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, type ElementType } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
import { LayoutDashboard, FileText, Mail, Users, Clock, Award } 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';
import { Callout } from './components/DetailSteps';
// 이용안내 = 쓰다가 기억 안 나는 개념을 열어보는 참조. 흐름 설명(6단계) 외 탭은 전부 '개념 간략 설명'.
// 사실 근거: 목표가·앵커링가 산정=backend quotation_service, 카드 흐름=agent chat_engine(1:1 유형(재협상·신규협상) 전용),
// 마감 판정=backend close_and_decide(1:N은 낙찰 기준 없이 단독 최저가 낙찰 / 개찰은 수동 처리).
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:
'상품과 협력사를 묶어 견적을 만들면 목표가는 자동으로 잡혀요. 유형(협상 1:1 / 견적 1:N)을 고르고, 협상이면 낙찰 기준도 여기서 정해요. 이 단계까지는 협력사에게 아무것도 공개되지 않아요.',
},
{
num: 3,
title: '이메일 발송',
actor: '내가',
actorType: 'user',
icon: Mail,
highlight: true,
short: '메일을 보내야 협상이 시작됨',
detail:
'견적을 만들어도 협력사에게 자동으로 알려지지 않아요. 초청메일을 보내야 협력사가 메일 속 링크로 전용 채팅방에 입장해 협상이 시작돼요. 깜빡한 건은 대시보드가 짚어줘요.',
},
{
num: 4,
title: '협상',
actor: '봇',
actorType: 'partner',
icon: Users,
short: '봇이 앵커링가 기준으로 가격 유도',
detail:
'협력사가 채팅방에서 가격을 제시하면 봇이 앵커링가를 기준으로 응대해요. 기준선 아래로 내려오면 협상 완료, 아니면 협상카드로 인하를 유도하고, 카드를 다 써도 안 내려오면 협상 실패예요. (자세한 동작은 「협상 진행」 탭)',
},
{
num: 5,
title: '마감',
actor: '자동',
actorType: 'system',
icon: Clock,
short: '기한 도달 또는 전원 완료 시',
detail:
'마감 시각이 되거나 모든 협상이 끝나면 자동으로 닫혀요. 필요하면 견적 상세에서 직접 닫을 수도 있어요.',
},
{
num: 6,
title: '낙찰 판정',
actor: '자동',
actorType: 'system',
icon: Award,
short: '최저가 + 낙찰 기준 → 낙찰/개찰',
detail:
'협상 완료된 투찰가 중 최저가를 보고 낙찰·개찰을 판정해요. 견적(1:N)과 협상(1:1)은 기준이 달라요. (케이스별 결과는 「마감·낙찰」 탭)',
},
];
const ACTOR_CLASS: Record<Actor, string> = {
user: 'text-primary',
partner: 'text-emerald-600 dark:text-emerald-400',
system: 'text-muted-foreground',
};
type Tone = 'win' | 'open' | 'choice';
// 견적 유형 4종 — 언제 고르는 유형인지.
const QUOTATION_TYPES: { key: string; label: string; scope: string; desc: string }[] = [
{ key: 'renego', label: '재협상', scope: '1:1', desc: '거래하던 한 곳의 단가를 더 깎고 싶을 때. 봇이 협상카드로 밀당해요.' },
{ key: 'requote', label: '재견적', scope: '1:N', desc: '거래하던 여러 곳에 다시 견적을 부칠 때. 최저가를 가리는 견적이에요.' },
{ key: 'new_nego', label: '신규협상', scope: '1:1', desc: '첫 거래할 한 곳과 가격을 맞출 때. 재협상과 같은 카드 밀당으로 진행돼요.' },
{ key: 'new_quote', label: '신규견적', scope: '1:N', desc: '첫 거래 후보 여러 곳에 견적을 부쳐 비교할 때. 최저가를 가리는 견적이에요.' },
];
// 협상 진행 — 봇의 동작 흐름. 용어 정의는 용어 정리 탭이 담당, 여기선 제시가에 따라 봇이 뭘 하는지만.
const BOT_MOVES: { cond: string; action: string }[] = [
{ cond: '앵커링가 이하로 부르면', action: '바로 협상완료 — 투찰 확정' },
{ cond: '앵커링가에 아깝게 가까우면', action: '와일드카드로 마지막 압박 — 협상당 한 번' },
{ cond: '아직 높으면', action: '협상카드를 한 장씩 쓰며 재제안 — 최대 3번' },
{ cond: '카드를 다 쓰거나 3번을 넘겨도 안 내려오면', action: '협상 실패 — 투찰 없음, 낙찰 후보 아님' },
];
// 마감·낙찰 — 언제 닫히고, 무엇을 보고, 케이스별로 어떻게 갈리나.
const CLOSE_SIGNALS: { title: string; desc: string }[] = [
{ title: '마감 시각 도달', desc: '설정한 시각이 지나면 자동으로 닫혀요.' },
{ title: '전원 협상 종료', desc: '시간 전이라도 모든 협력사의 협상이 끝나면 바로 닫혀요.' },
{ title: '수동 마감', desc: '견적 상세에서 직접 닫을 수 있어요.' },
];
// 세션 상태 5종 — 견적 상세의 협력사별 뱃지. 케이스표의 어휘(투찰·거부·미응찰)가 여기서 나온다. 라벨은 SESSION_STATUS_LABEL과 동일.
const SESSION_STATES: { label: string; desc: string }[] = [
{ label: '협상생성', desc: '세션이 만들어졌지만 협력사가 아직 입장하지 않은 상태.' },
{ label: '협상중', desc: '협력사가 채팅방에 입장해 봇과 협상하는 중.' },
{ label: '협상완료', desc: '투찰이 확정된 상태. 낙찰 후보가 돼요.' },
{ label: '협상거부', desc: '협력사가 협상을 거부한 상태.' },
{ label: '미참여', desc: '마감될 때까지 협상을 끝내지 못하면 자동으로 미참여 처리돼요.' },
];
interface AwardCase {
mode: string;
note: string;
rows: { cond: string; result: string; tone: Tone }[];
}
const AWARD_CASES: AwardCase[] = [
{
mode: '견적 (재견적·신규견적)',
note: '투찰가 중 최저가만 봐요. 낙찰 기준 선택은 없어요.',
rows: [
{ cond: '단독 최저가가 있으면', result: '낙찰', tone: 'win' },
{ cond: '최저가가 동가면', result: '개찰 · 자동 재생성', tone: 'open' },
{ cond: '아무도 투찰하지 않았으면', result: '개찰 · 자동 재생성', tone: 'open' },
{ cond: '전부 협상을 거부했으면', result: '개찰', tone: 'open' },
],
},
{
mode: '협상 (재협상·신규협상)',
note: '투찰가를 목표가·앵커링가와 비교해요. 앵커링가~목표가, 목표가 초과 구간은 견적 만들 때 낙찰/개찰 중 내가 정한 대로 처리돼요.',
rows: [
{ cond: '투찰가가 앵커링가 이하면', result: '낙찰', tone: 'win' },
{ cond: '앵커링가~목표가 사이면', result: '사용자 지정', tone: 'choice' },
{ cond: '목표가를 넘으면', result: '사용자 지정', tone: 'choice' },
{ cond: '미응찰·협상거부면', result: '개찰', tone: 'open' },
],
},
];
const TONE_CHIP: Record<Tone, string> = {
win: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
open: 'bg-amber-500/10 text-amber-600 dark:text-amber-400',
choice: 'bg-primary/10 text-primary',
};
export const GUIDE_TABS = ['flow', 'type', 'nego', 'example', 'terms', 'faq'] as const;
export type GuideTab = (typeof GUIDE_TABS)[number];
export const TAB_LABEL = { flow: '흐름 설명', type: '견적 유형', nego: '협상 진행', example: '마감·낙찰', terms: '용어 정리', faq: '자주 묻는 질문' } as const;
// 자주 묻는 질문 — "어디서 하는지"를 찾는 용도라 답에 경로(메뉴 › 화면 › 버튼)를 반드시 적는다.
const FAQS: { q: string; a: string }[] = [
{
q: '협상 채팅 내역은 어디서 다운로드하나요?',
a: '견적관리 › 견적 클릭 › 「채팅」 탭 › 우측 상단 [JSON] 버튼을 누르면 파일로 저장돼요. 협력사가 여러 곳이면 좌측에서 협력사를 고른 뒤 받으면 그 협력사와의 대화만 내려받아요. 대화가 한 건도 없으면 버튼이 비활성이에요.',
},
{
q: '다운로드한 채팅 파일에는 뭐가 들어 있나요?',
a: '견적번호·협력사·상품·상태와 대화 전체(순번, 발화자(BOT/협력사), 단계, 멘트, 사용한 협상카드)가 들어 있어요. 금액 숫자는 화면과 같은 규칙으로 가려서 저장되니 외부 공유용으로 써도 돼요.',
},
{
q: '초청메일을 다시 보내려면?',
a: '견적 상세 › 「협상현황」 탭 › 협력사 줄 오른쪽의 발송 아이콘을 누르면 돼요. 메일을 보내야 협상이 시작되니, 만들어만 두고 안 보낸 건은 대시보드에서 짚어줘요.',
},
{
q: '협상이 끝난 뒤 협력사에게 납기·수량 같은 정보를 더 받을 수 있나요?',
a: '회사 설정 › 커스텀 필드 › 「협상완료 부가정보 필드」에 받을 항목을 먼저 정의해야 써요. 정의해 두면 협력사가 타결 직후 그 항목을 입력하고, 견적 상세 › 「협상현황」 탭 › 협력사 줄의 부가정보 아이콘에서 확인할 수 있어요. 정의가 비어 있으면 입력 화면도 뜨지 않아요.',
},
{
q: '목표가는 어떻게 정해지나요?',
a: 'MD 제시가를 넣으면 그 값이 그대로 목표가예요. 안 넣으면 신규는 인터넷 최저가에서 수수료를 뺀 값, 재견적·재협상은 거기에 매입가와 판매가(마진 차감)까지 후보로 놓고 그중 가장 낮은 값을 써요.',
},
{
q: '상품·협력사를 한 번에 등록하려면?',
a: '상품관리(또는 협력사관리) › [⋯] › 양식 다운로드로 파일을 받아 채운 뒤 엑셀 업로드로 올려요. 양식 헤더는 회사 설정 용어를 따라가고, 업로드 전 미리보기에서 오류 행을 표시해 줘요.',
},
{
q: '여러 건을 한꺼번에 지우려면?',
a: '목록 왼쪽 체크박스로 여러 건을 고르면 상단에 삭제 버튼이 나와요. 상품·협력사 모두 같은 방식이에요.',
},
{
q: '협상카드는 어떤 순서로 보이나요?',
a: '견적 생성의 카드 선택에서 성공률(카드를 쓴 협상 중 타결된 비율) 높은 순으로 정렬되고, 상위 3개에는 순위 배지가 붙어요. 아직 쓰인 적 없는 카드는 표본이 없어 뒤로 밀려요.',
},
{
q: '협력사가 재협상을 요청하면 어디서 처리하나요?',
a: '낙찰 없이 개찰(결렬)로 닫힌 건에 한해, 그 견적 마지막 라운드에 참여했던 협력사가 포털에서 재협상을 요청할 수 있어요. (낙찰된 건은 제외이고, 협상을 거부했거나 미참여였던 협력사도 조건이 바뀌면 요청할 수 있어요.) 요청은 「재협상」 화면에서 현황을 보고 승인 또는 반려해요. 승인하면 그 협력사를 포함해 재협상 견적이 바로 생성되고(초청메일은 견적 상세에서 수동 발송), 반려하면 사유가 협력사에게 그대로 보여요.',
},
];
// 핵심 용어 사전 — 사전이므로 다른 탭과 겹쳐도 전부 싣는다.
const TERMS: { term: string; desc: string }[] = [
{ term: '목표가', desc: '상품 가격 기준으로 자동 산정되는 매입 목표 금액. "이 값이면 산다"의 기준선.' },
{ term: '앵커링가', desc: '목표가보다 조금 더 낮게 잡는 협상 완료 기준선. 제시가가 이 밑으로 오면 바로 협상 완료.' },
{ term: 'MD 제시가', desc: '견적 만들 때 직접 넣는 목표 금액. 넣으면 유형과 무관하게 목표가로 그대로 쓰여요.' },
{ term: '투찰가', desc: '협상이 끝난 뒤 협력사가 최종 확정한 금액. 낙찰 판정의 대상이에요.' },
{ term: '협상 완료', desc: '제시가가 앵커링가 이하로 내려와 투찰이 확정된 상태. 낙찰 후보가 돼요.' },
{ term: '협상카드', desc: '봇이 인하를 요청할 때 쓰는 멘트 전략. 직접 만든 카드만 견적에 넣어 써요.' },
{ term: '와일드카드', desc: '제시가가 앵커링가를 살짝 넘은 막판에 한 번만 발동하는 인하 압박 카드.' },
{ term: '낙찰', desc: '마감 결과 투찰가가 기준을 통과해 최종 선정된 것. 승자는 한 곳.' },
{ term: '개찰', desc: '낙찰자를 정하지 못하고 닫힌 마감. 동가·미응찰·협상거부·기준 미달일 때.' },
{ term: '낙찰 기준', desc: '협상(1:1)에서 앵커링가~목표가·목표가 초과 구간을 낙찰/개찰 중 어느 쪽으로 볼지 견적 만들 때 정하는 값.' },
{ term: '협상 · 견적', desc: '협상은 한 곳과 1:1로 밀당, 견적은 여러 곳과 1:N 경쟁. 협상카드는 협상에서만 쓰여요.' },
{ term: '세션', desc: '견적의 상품×협력사 조합마다 하나씩 생기는 협상 방(채팅) 단위. 상태와 투찰가가 세션에 기록돼요.' },
{ term: '라운드(차수)', desc: '같은 견적번호로 재생성될 때마다 1씩 늘어나는 회차. 최대 3라운드까지 이어져요.' },
{ term: '미참여', desc: '마감될 때까지 협상을 끝내지 못한 세션의 최종 상태. 자동으로 처리돼요.' },
{ term: '마감 사유', desc: '견적이 왜 그렇게 닫혔는지 — 낙찰, 또는 개찰(가격 미달·동가·미응찰·협상거부).' },
{ term: '권한', desc: '수정·마감·재생성은 만든 본인과 최고관리자만. 협력사 삭제·회사 전체 대시보드·회원 관리는 최고관리자 전용, 공용 협상카드는 예외로 누구나 수정할 수 있어요.' },
{ term: '견적 세팅', desc: '내 견적 전체에 적용되는 기본값. 목표 마진율(판매가로 목표가를 잡을 때 차감)과 협상카드 사용한도(기본 3장)를 정해요.' },
];
export function OnboardingGuideModal({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const navigate = useNavigate();
// 탭은 ?guide=<tab> 으로 URL 에 남긴다 — 링크 공유·새로고침·메뉴 빠른이동에서 같은 탭으로 열리게.
const [searchParams, setSearchParams] = useSearchParams();
const tabParam = searchParams.get('guide');
const tab: GuideTab = (GUIDE_TABS as readonly string[]).includes(tabParam ?? '') ? (tabParam as GuideTab) : 'flow';
const setTab = (next: GuideTab) => {
const params = new URLSearchParams(searchParams);
params.set('guide', next);
setSearchParams(params, { replace: true });
};
const [activeNum, setActiveNum] = useState(1);
const active = STEPS.find((s) => s.num === activeNum) ?? STEPS[0];
const openOnboardingPage = () => {
onOpenChange(false);
navigate('/onboarding');
};
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">
{GUIDE_TABS.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">
<Typography variant="h4">어떤 견적을 만들지 — 4가지 유형</Typography>
<Typography variant="muted">
협상은 한 곳과 1:1로 밀당, 견적은 여러 곳과 1:N 경쟁. 기존 거래처면 재, 첫 거래면 신규예요.
</Typography>
<div className="grid gap-2 sm:grid-cols-2">
{QUOTATION_TYPES.map((t) => (
<div key={t.key} className="rounded-lg border border-border bg-muted/30 p-3">
<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>
<Typography variant="caption" className="mt-1 block leading-relaxed">{t.desc}</Typography>
</div>
))}
</div>
<Callout>
<b className="text-foreground">MD 제시가를 넣으면 유형과 무관하게 그 값이 목표가예요.</b> 없으면 신규 유형은 인터넷최저가로만 잡고, 재 유형은 매입가·판매가까지 후보에 넣어요.
</Callout>
</div>
)}
{tab === 'nego' && (
<div className="space-y-3">
<Typography variant="h4">봇은 이렇게 협상해요</Typography>
<Callout tone="warning">
협상카드·와일드카드는 <b className="text-foreground">협상(재협상·신규협상)에서만</b> 쓰여요. 견적(재견적·신규견적)은 카드 없이 진행돼요.
</Callout>
<Typography variant="muted">
견적을 만들면 목표가와 그보다 조금 낮은 앵커링가(협상 완료 기준선)가 자동으로 잡혀요. 봇의 목표는 협력사 제시가를 앵커링가 밑으로 끌어내리는 거예요.
</Typography>
<div className="rounded-lg border border-border bg-muted/30 p-3">
<Typography as="p" variant="small" className="font-bold">협력사가 가격을 부르면</Typography>
<div className="mt-1.5 space-y-1">
{BOT_MOVES.map((m) => (
<Typography key={m.cond} variant="caption" className="block leading-relaxed">
<b className="text-foreground">{m.cond}</b> — {m.action}
</Typography>
))}
</div>
</div>
<Typography variant="caption" className="block text-muted-foreground">
협상카드는 직접 만든 카드만 견적에 넣어 쓸 수 있고, 몇 장까지 쓸지(사용한도, 기본 3장)는 견적 세팅에서 정해요.
</Typography>
</div>
)}
{tab === 'example' && (
<div className="space-y-3">
<Typography variant="h4">마감과 낙찰 — 케이스별 결과</Typography>
<Typography variant="muted">
낙찰은 <b className="text-foreground">협상 완료된 투찰가 중 최저가</b>를 보고 판정해요 — 그 최저가가 단독인지, 그리고 기준선을 넘는지.
</Typography>
{/* ① 언제 닫히나 */}
<div className="rounded-lg border border-border bg-muted/30 p-3">
<Typography as="p" variant="small" className="font-bold">언제 닫히나</Typography>
<div className="mt-1.5 space-y-1">
{CLOSE_SIGNALS.map((s) => (
<Typography key={s.title} variant="caption" className="block leading-relaxed">
<b className="text-foreground">{s.title}</b> — {s.desc}
</Typography>
))}
</div>
</div>
{/* ② 세션 상태 — 케이스표에 나오는 어휘 */}
<div className="rounded-lg border border-border bg-muted/30 p-3">
<Typography as="p" variant="small" className="font-bold">세션 상태 — 견적 상세의 협력사별 뱃지</Typography>
<div className="mt-1.5 space-y-1">
{SESSION_STATES.map((s) => (
<Typography key={s.label} variant="caption" className="block leading-relaxed">
<b className="text-foreground">{s.label}</b> — {s.desc}
</Typography>
))}
</div>
</div>
{/* ③ 방식별 케이스 */}
<div className="grid gap-3 sm:grid-cols-2">
{AWARD_CASES.map((m) => (
<div key={m.mode} className="rounded-xl border border-border p-3">
<Typography as="p" variant="small" className="font-bold">{m.mode}</Typography>
<Typography variant="caption" className="mt-0.5 block leading-relaxed text-muted-foreground">{m.note}</Typography>
<div className="mt-2 space-y-1.5">
{m.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>
{/* ④ 개찰이면 */}
<Callout title="개찰이면 뭘 하나">
개찰은 낙찰자를 정하지 못하고 닫힌 마감이에요(결렬 아님). 동가·미응찰·가격 기준 미달처럼 다음 라운드가 필요한 경우엔 다음 견적이 자동 재생성돼요 — 재생성은 <b className="text-foreground">최대 2회(한 견적번호로 최대 3라운드)</b>까지고, 그 뒤엔 그대로 마감돼요. 재생성된 견적은 목표가를 이어받고 앵커링가만 다시 계산해요. 투찰한 후보가 있으면 상세에서 직접 낙찰을 확정할 수도 있어요. 결과는 알림함으로 와요.
</Callout>
</div>
)}
{tab === 'terms' && (
<div className="space-y-3">
<Typography variant="h4">자주 나오는 용어</Typography>
<div className="grid gap-2 sm:grid-cols-2">
{TERMS.map((t) => (
<div key={t.term} className="rounded-lg border border-border bg-muted/30 p-3">
<Typography as="span" variant="small" className="font-bold">{t.term}</Typography>
<Typography variant="caption" className="mt-0.5 block leading-relaxed">{t.desc}</Typography>
</div>
))}
</div>
</div>
)}
{tab === 'faq' && (
<div className="space-y-2">
<Typography variant="h4">자주 묻는 질문</Typography>
{FAQS.map((f) => (
<details key={f.q} className="rounded-lg border border-border bg-muted/30 p-3">
<summary className="cursor-pointer list-none">
<Typography as="span" variant="small" className="font-bold">Q. {f.q}</Typography>
</summary>
<Typography variant="caption" className="mt-1.5 block leading-relaxed">{f.a}</Typography>
</details>
))}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={openOnboardingPage}>온보딩 페이지에서 보기 ↗</Button>
<Button onClick={() => onOpenChange(false)}>시작하기</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}