499 lines
27 KiB
TypeScript
499 lines
27 KiB
TypeScript
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>
|
||
);
|
||
}
|