[feat] negodata/front: 온보딩 페이지 추가 + 이용안내 모달 재작성

This commit is contained in:
Mina Choi 2026-07-09 16:08:54 +09:00
parent b368a19f79
commit 0d62440ee1
12 changed files with 1371 additions and 278 deletions

View File

@ -1,6 +1,7 @@
import {createBrowserRouter, redirect} from 'react-router';
import {initAuth} from '../features/auth/service';
import {isLoggedIn, hasRole} from '../stores/auth';
import {hasSeenOnboarding} from '../features/onboarding/storage';
import AuthenticatedLayout from '@/components/layout/AuthenticatedLayout';
import LoginPage from '../pages/login';
import DashboardPage from '../pages/dashboard';
@ -13,6 +14,7 @@ import QuotationPage from '../pages/quotation';
import CardsPage from '../pages/cards';
import MembersPage from '../pages/members';
import NotificationsPage from '../pages/notifications';
import OnboardingPage from '../pages/onboarding';
export const router = createBrowserRouter([
// dev 전용: import.meta.env.DEV가 false인 프로덕션 빌드에선 이 배열 항목과
@ -45,6 +47,7 @@ export const router = createBrowserRouter([
Component: ForbiddenPage,
},
{
path: 'onboarding',
loader: async ({request}) => {
await initAuth();
if (!isLoggedIn()) {
@ -54,6 +57,22 @@ export const router = createBrowserRouter([
}
return null;
},
Component: OnboardingPage,
},
{
loader: async ({request}) => {
await initAuth();
if (!isLoggedIn()) {
const url = new URL(request.url);
const from = encodeURIComponent(url.pathname + url.search);
return redirect(`/login?redirect=${from}`);
}
// 최초 로그인 유저는 온보딩으로. 완료·건너뛰기 시 flag 저장되어 이후엔 통과.
if (!hasSeenOnboarding()) {
return redirect('/onboarding');
}
return null;
},
Component: AuthenticatedLayout,
children: [
{path: 'dashboard', Component: DashboardPage},

View File

@ -25,14 +25,14 @@ interface ResultCase {
const CASES: ResultCase[] = [
{
tone: 'amber',
tag: '동가 → 차수 재생성',
tag: '동가 → 개찰',
qtNo: 'QT-20260611-A',
title: 'MRO 안전화 일괄 조달 (1차수 마감)',
rows: [
{ label: '최저 투찰가', value: '42,000원' },
{ label: '동가 투찰사', value: '2개사' },
],
outcome: '최저가가 같은 2개사가 나와 2차수 협상이 자동 재생성·발송되었습니다.',
outcome: '최저가가 같은 2개사가 나와 개찰로 마감되었습니다. 담당자가 공급사만 고르면 다음 라운드는 자동 생성됩니다.',
},
{
tone: 'emerald',

View File

@ -1,4 +1,5 @@
import { useState, type ElementType } from 'react';
import { useNavigate } from 'react-router';
import { LayoutDashboard, FileText, Mail, Users, Clock, Award } from 'lucide-react';
import {
Dialog,
@ -12,9 +13,11 @@ 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단계)·'견적 유형'(4종)·'마감 판정'(가격게이트+개찰 결정표) 세 뷰.
// 읽기 전용 정적 안내(테이블/서버 없음). 첫 방문 자동 노출 + 대시보드 버튼으로 재오픈은 호출부(대시보드)에서 제어.
// 이용안내 = 쓰다가 기억 안 나는 개념을 열어보는 참조. 흐름 설명(6단계) 외 탭은 전부 '개념 간략 설명'.
// 사실 근거: 목표가·앵커링가 산정=backend quotation_service, 카드 흐름=agent chat_engine(1:1 유형(재협상·신규협상) 전용),
// 마감 판정=backend close_and_decide(1:N은 낙찰 기준 없이 단독 최저가 낙찰 / 개찰은 수동 처리).
type Actor = 'user' | 'partner' | 'system';
@ -26,75 +29,70 @@ interface Step {
short: string;
detail: string;
icon: ElementType;
highlight?: boolean; // 초청메일: 수동 발송 안 하면 협상이 시작 안 됨 → 강조
highlight?: boolean; // 이메일 발송: 수동 발송 안 하면 협상이 시작 안 됨 → 강조
}
const STEPS: Step[] = [
{
num: 1,
title: '준비',
actor: '나',
actor: '내가',
actorType: 'user',
icon: LayoutDashboard,
short: '상품·협력사·협상카드 등록',
detail:
'협상에 부칠 상품(가격 기준: 인터넷최저가·매입가·판매가 중 최소 하나)과 견적을 보낼 협력사(담당자 이메일 필수)를 먼저 등록합니다. 협상 멘트 전략인 협상카드는 기본 제공 카드는 제외되고, 직접 만든 카드만 목록에 떠 견적에 사용할 수 있습니다.',
'협상에 부칠 상품과 견적을 보낼 협력사(담당자 이메일 필수)를 먼저 등록해요. 협력사가 그 상품을 어떤 방식(제조·총판·유통)으로 조달하는지 연결해 두면 봇이 카드 선택에 참고해요(선택사항). 봇이 쓸 협상카드도 미리 만들어 둘 수 있어요.',
},
{
num: 2,
title: '견적 생성',
actor: '나',
title: '견적 만들기',
actor: '내가',
actorType: 'user',
icon: FileText,
short: '유형 선택·목표가 자동 산출',
short: '진행 방식·목표가·낙찰 기준 설정',
detail:
'상품과 협력사를 묶어 견적을 만들면 상품×협력사 조합마다 협상 세션이 생기고, 상품 가격 기준으로 목표가가 자동 산정됩니다. 이 단계까지는 협력사에게 아무것도 공개되지 않습니다.',
'상품과 협력사를 묶어 견적을 만들면 목표가는 자동으로 잡혀요. 유형(협상 1:1 / 견적 1:N)을 고르고, 협상이면 낙찰 기준도 여기서 정해요. 이 단계까지는 협력사에게 아무것도 공개되지 않아요.',
},
{
num: 3,
title: '초청메일',
actor: '나',
title: '이메일 발송',
actor: '내가',
actorType: 'user',
icon: Mail,
highlight: true,
short: '메일을 보내야 협상이 시작됨',
detail:
'견적 생성 후 직접 초청메일을 발송해야 협력사에게 투찰 링크가 전달되고 협상이 실제로 시작됩니다. 메일을 보내지 않으면 협력사는 견적이 생긴 줄도 모르고 아무 일도 일어나지 않습니다. 누락 건은 대시보드의 "메일 미발송" 위젯에서 바로 잡을 수 있습니다.',
'견적을 만들어도 협력사에게 자동으로 알려지지 않아요. 초청메일을 보내야 협력사가 메일 속 링크로 전용 채팅방에 입장해 협상이 시작돼요. 깜빡한 건은 대시보드가 짚어줘요.',
},
{
num: 4,
title: '협상',
actor: '협력사',
actor: '봇',
actorType: 'partner',
icon: Users,
short: 'AI 봇이 앵커링·와일드카드로 가격 유도(재협상)',
short: '봇이 앵커링가 기준으로 가격 유도',
detail:
'초청메일을 받은 협력사가 전용 채팅방에 입장해 가격을 제시하면, AI 협상봇이 회사가 정한 앵커링가를 기준으로 응대합니다. ' +
'단, 앵커링·협상카드·와일드카드까지 쓰는 이 밀당 흐름은 재협상(1:1) 유형에서만 돌고, 나머지 유형(재견적·신규협상·신규견적)은 카드 없이 배송형태·추가할인 요청 중심의 정형 흐름을 탑니다. ' +
'재협상 기준으로, 제시가가 앵커링가 이하로 내려오면 협상완료(투찰 확정)로 넘어갑니다. ' +
'앵커링가를 살짝(5% 이내) 초과하면 막판 인하 압박용 "와일드카드"가 한 번 발동해 앵커 이하로 유도합니다 — 앵커에 아주 근접(2% 이내)하면 1% 인하를 요청하고, 좀 더 높으면 목표 매입가에 맞춰달라고 제안합니다. ' +
'그보다 높거나 아직 여지가 있으면 일반 협상카드로 재제안을 반복하고, 카드가 소진되거나 카운터제안이 3회를 넘으면 협상이 실패로 종료됩니다. ' +
'진행 현황은 견적 상세에서 실시간으로 볼 수 있습니다.',
'협력사가 채팅방에서 가격을 제시하면 봇이 앵커링가를 기준으로 응대해요. 기준선 아래로 내려오면 협상 완료, 아니면 협상카드로 인하를 유도하고, 카드를 다 써도 안 내려오면 협상 실패예요. (자세한 동작은 「협상 진행」 탭)',
},
{
num: 5,
title: '마감',
actor: '나/자동',
actor: '자동',
actorType: 'system',
icon: Clock,
short: '기한 도달 또는 전원 완료 시',
detail:
'설정한 마감 시각에 도달하거나 초청한 협력사가 전원 투찰을 마치면 자동으로 마감되어 판정에 들어갑니다. 필요하면 견적 상세에서 직접 수동 마감할 수도 있습니다.',
'마감 시각이 되거나 모든 협상이 끝나면 자동으로 닫혀요. 필요하면 견적 상세에서 직접 닫을 수도 있어요.',
},
{
num: 6,
title: '낙찰 판정',
actor: '시스템',
actor: '자동',
actorType: 'system',
icon: Award,
short: '단독최저+기준통과=낙찰 / 그 외(목표가 초과·동가·미응찰·거부)=개찰',
short: '최저가 + 낙찰 기준 → 낙찰/개찰',
detail:
'낙찰 후보는 협상완료한 협력사 중 최저가입니다. 다만 최저가라고 바로 낙찰이 아니라, 그 최저가가 단독(동점 아님)이고 견적의 낙찰 기준(가격게이트)을 통과해야 낙찰됩니다. 낙찰 기준을 못 넘기거나(목표가 초과 등), 동가·전원 미응찰·협상거부인 경우는 결렬이 아니라 개찰 — 낙찰자 미정으로 마감됩니다. 개찰은 자동 재협상/재생성 없이 담당자가 상세에서 수동으로 다음 라운드를 생성하거나 처리합니다. 결과는 알림함으로 통지되고 대시보드에서 현황을 확인합니다.',
'협상 완료된 투찰가 중 최저가를 보고 낙찰·개찰을 판정해요. 견적(1:N)과 협상(1:1)은 기준이 달라요. (케이스별 결과는 「마감·낙찰」 탭)',
},
];
@ -104,52 +102,65 @@ const ACTOR_CLASS: Record<Actor, string> = {
system: 'text-muted-foreground',
};
// ----- 견적 마감 판정(close_and_decide): 낙찰 후보=협상완료 최저가 → 단독 최저가가 낙찰 기준(가격게이트) 통과면 낙찰, 아니면 개찰.
// 동가·협상거부·전원 미응찰도 결렬이 아니라 개찰(낙찰자 미정 마감). 자동 재협상/재생성 없음 — 다음 라운드는 담당자가 수동 재생성.
// 결정표 = close_reason 5종(낙찰 + 개찰 4: 가격/동가/미응찰/거부). -----
type Tone = 'win' | 'open';
type Tone = 'win' | 'open' | 'choice';
// (1) 가격게이트(1:1 협상): 투찰가가 앵커링가/목표가 대비 어느 구간이냐로 견적 낙찰 기준 적용. 미달이면 개찰.
const GATE_ZONES: { range: string; action: string; note: string }[] = [
{ range: '투찰가 ≤ 앵커링가', action: '무조건 낙찰', note: '고정 · 설정 불가' },
{ range: '앵커링가 < 투찰가 ≤ 목표가', action: '낙찰 기준대로 (낙찰 / 개찰)', note: '목표가까지 낙찰이면 낙찰 · 앵커링가까지면 개찰' },
{ range: '목표가 < 투찰가', action: '개찰', note: '항상 개찰(목표가 초과)' },
// 견적 유형 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: '첫 거래 후보 여러 곳에 견적을 부쳐 비교할 때. 최저가를 가리는 견적이에요.' },
];
// (2) 완료 양상별 결정. rows 는 위에서부터 순서대로 판정(close_and_decide 분기 순서와 동일).
interface DecisionRow {
cond: string;
result: string;
tone: Tone;
}
interface DecisionGroup {
group: string;
desc: string;
rows: DecisionRow[];
// 협상 진행 — 봇의 동작 흐름. 용어 정의는 용어 정리 탭이 담당, 여기선 제시가에 따라 봇이 뭘 하는지만.
const BOT_MOVES: { cond: string; action: string }[] = [
{ cond: '앵커링가 이하로 부르면', action: '바로 협상완료 — 투찰 확정' },
{ cond: '앵커링가에 아깝게 가까우면', action: '와일드카드로 마지막 압박 — 협상당 한 번' },
{ cond: '아직 높으면', action: '협상카드를 한 장씩 쓰며 재제안 요청' },
{ cond: '고른 카드를 다 써도 안 내려오면', 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 DECISION_TREE: DecisionGroup[] = [
const AWARD_CASES: AwardCase[] = [
{
group: '단독 최저가',
desc: '협상완료 협력사 중 최저가가 한 곳',
mode: '견적 (재견적·신규견적)',
note: '투찰가 중 최저가만 봐요. 낙찰 기준 선택은 없어요.',
rows: [
{ cond: '낙찰 기준 통과(앵커 이하 · 또는 목표가 이내·목표가까지 낙찰)', result: '낙찰', tone: 'win' },
{ cond: '낙찰 기준 미달(목표가 초과 등)', result: '개찰 · 낙찰자 미정', tone: 'open' },
{ cond: '단독 최저가가 있으면', result: '낙찰', tone: 'win' },
{ cond: '최저가가 동가면', result: '개찰 · 자동 재생성', tone: 'open' },
{ cond: '아무도 투찰하지 않았으면', result: '개찰 · 자동 재생성', tone: 'open' },
{ cond: '전부 협상을 거부했으면', result: '개찰', tone: 'open' },
],
},
{
group: '동가',
desc: '최저가가 2곳 이상 동일 → 단독 낙찰 불가',
mode: '협상 (재협상·신규협상)',
note: '투찰가를 목표가·앵커링가와 비교해요. 앵커링가~목표가, 목표가 초과 구간은 견적 만들 때 낙찰/개찰 중 내가 정한 대로 처리돼요.',
rows: [
{ cond: '항상', result: '개찰 · 낙찰자 미정', tone: 'open' },
],
},
{
group: '완료한 투찰 없음',
desc: '아무도 협상을 완료(투찰 확정)하지 않음',
rows: [
{ cond: '거부한 협력사가 있음', result: '개찰 · 거부', tone: 'open' },
{ cond: '전원 미응찰', result: '개찰 · 미응찰', tone: 'open' },
{ cond: '투찰가가 앵커링가 이하면', result: '낙찰', tone: 'win' },
{ cond: '앵커링가~목표가 사이면', result: '사용자 지정', tone: 'choice' },
{ cond: '목표가를 넘으면', result: '사용자 지정', tone: 'choice' },
{ cond: '미응찰·협상거부면', result: '개찰', tone: 'open' },
],
},
];
@ -157,41 +168,32 @@ const DECISION_TREE: DecisionGroup[] = [
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',
};
// ----- 견적 유형(4종): 두 축으로 갈림 — 신규/재(목표가 산정 후보) × 협상 1:1 / 견적 1:N(부르는 협력사 수).
// 추가로 '협상카드 발동'은 이 두 축과 별개다 — 라이브 봇(agent chat_engine)은 rq_type 을 재협상/재견적 둘로만 접고
// (backend chat_service `qt_type==1`만 재협상), 협상카드·와일드카드는 재협상 스크립트에만 존재한다.
// 그래서 실제로 카드가 도는 건 재협상(1) 뿐이고, 나머지 3종(재견적·신규협상·신규견적)은 카드 없는 재견적 흐름을 탄다. -----
interface QtType {
key: string;
label: string;
isNew: boolean;
scope: string; // 1:1 / 1:N
suppliers: string; // 부르는 협력사 수
basis: string; // 목표가 산정 기준
cards: string; // 실제 협상카드/와일드카드 발동 여부(런타임 기준)
}
const TAB_LABEL = { flow: '흐름 설명', type: '견적 유형', nego: '협상 진행', example: '마감·낙찰', terms: '용어 정리' } as const;
const QUOTATION_TYPES: QtType[] = [
{ key: 'renego', label: '재협상', isNew: false, scope: '1:1', suppliers: '단일 협력사', basis: '인터넷최저·매입·판매 중 최소', cards: '협상카드 · 와일드카드 발동' },
{ key: 'requote', label: '재견적', isNew: false, scope: '1:N', suppliers: '여러 협력사', basis: '인터넷최저·매입·판매 중 최소', cards: '카드 없음 · 배송형태·추가할인 흐름' },
{ key: 'new_nego', label: '신규협상', isNew: true, scope: '1:1', suppliers: '단일 협력사', basis: '인터넷최저가 기준', cards: '카드 없음 · 현재 재견적 흐름으로 진행' },
{ key: 'new_quote', label: '신규견적', isNew: true, scope: '1:N', suppliers: '여러 협력사', basis: '인터넷최저가 기준', cards: '카드 없음 · 현재 재견적 흐름으로 진행' },
// 핵심 용어 사전 — 사전이므로 다른 탭과 겹쳐도 전부 싣는다.
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장)를 정해요.' },
];
// ----- 협상 로직(agent 협상봇 chat_engine): 협력사 제시가를 회사 앵커링가 기준으로 구간별 응대. 와일드카드는 앵커 살짝 초과 시 세션당 1회 발동.
// ★ 이 카드/앵커링 흐름은 '재협상(1:1)' 스크립트(scripts_renegotiation)에만 있다. 재견적은 카드·와일드카드가 없는 별도 스크립트. -----
const NEGO_ZONES: { range: string; action: string; note: string }[] = [
{ range: '제시가 ≤ 앵커링가', action: '협상완료 · 투찰 확정', note: '앵커 이하 = 우선협상' },
{ range: '앵커링가 < 제시가 ≤ 앵커×1.02', action: '와일드카드 · 1% 인하 요청', note: '앵커에 근접 → 딱 앵커 밑으로 유도' },
{ range: '앵커×1.02 < 제시가 ≤ 앵커×1.05', action: '와일드카드 · 목표 매입가 제시', note: '막판 인하 압박' },
{ range: '제시가 > 앵커×1.05', action: '일반 협상카드로 재제안', note: '아직 여지 있음 → 계속 협상' },
{ range: '카드 소진 · 카운터제안 3회 초과', action: '협상 실패', note: '라운드 상한(MAX_ROUNDS=3)·무한 협상 방지' },
];
const TAB_LABEL = { flow: '흐름 설명', type: '견적 유형', nego: '협상 로직', example: '마감 판정' } as const;
export function OnboardingGuideModal({
open,
onOpenChange,
@ -199,10 +201,16 @@ export function OnboardingGuideModal({
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [tab, setTab] = useState<'flow' | 'type' | 'nego' | 'example'>('flow');
const navigate = useNavigate();
const [tab, setTab] = useState<'flow' | 'type' | 'nego' | 'example' | 'terms'>('flow');
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">
@ -214,14 +222,14 @@ export function OnboardingGuideModal({
</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', 'nego', 'example'] as const).map((t) => (
{(['flow', 'type', 'nego', 'example', 'terms'] as const).map((t) => (
<button
key={t}
type="button"
@ -297,208 +305,95 @@ export function OnboardingGuideModal({
{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="h4">어떤 견적을 만들지 — 4가지 유형</Typography>
<Typography variant="muted">
유형은 두 축으로 갈립니다 — 신규/재(목표가 산정 기준)와 협상(1:1)/견적(1:N)(부르는 협력사 수).
협상은 한 곳과 1:1로 밀당, 견적은 여러 곳과 1:N 경쟁. 기존 거래처면 재, 첫 거래면 신규예요.
</Typography>
<Typography variant="small" className="font-bold">
MD 제시가를 넣으면 유형과 무관하게 그 값이 목표가 최우선입니다.
</Typography>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-2 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 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>
<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 className={cn('rounded-lg p-2.5', t.key === 'renego' ? 'bg-emerald-500/10' : 'bg-muted/40')}>
<Typography as="span" variant="small" className="font-semibold">협상카드</Typography>
<Typography variant="caption" className="mt-0.5 block leading-relaxed">{t.cards}</Typography>
</div>
</div>
<Typography variant="caption" className="mt-1 block leading-relaxed">{t.desc}</Typography>
</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>
<Typography as="span" variant="small" className="font-semibold">협상카드는 재협상에서만</Typography>
<Typography variant="caption" className="mt-0.5 block leading-relaxed">
앵커링·협상카드·와일드카드를 쓰는 밀당 협상은 지금 재협상(1:1)에서만 돕니다. 재견적은 카드 없이 배송형태·추가할인 요청 흐름이고, 신규협상·신규견적도 현재 라이브 봇에선 카드 없는 재견적 흐름으로 진행됩니다. (자세한 카드 동작은 「협상 로직」 탭)
</Typography>
</div>
</div>
<Callout>
<b className="text-foreground">MD 제시가를 넣으면 유형과 무관하게 그 값이 목표가예요.</b> 없으면 신규 유형은 인터넷최저가로만 잡고, 재 유형은 매입가·판매가까지 후보에 넣어요.
</Callout>
</div>
)}
{tab === 'nego' && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Typography variant="h4">협상 중엔 이렇게 카드가 발동합니다</Typography>
<Badge variant="outline">AI 봇</Badge>
</div>
{/* 범위 경고: 이 카드 로직은 재협상 전용 — 나머지 유형엔 카드가 없다 */}
<div className="rounded-lg border border-amber-500/40 bg-amber-500/5 p-3">
<Typography as="p" variant="small" className="font-semibold text-amber-700 dark:text-amber-400">
이 카드 로직은 재협상(1:1)에서만 돕니다
</Typography>
<Typography variant="caption" className="mt-0.5 block leading-relaxed">
아래 앵커링 구간·협상카드·와일드카드는 재협상 유형의 협상봇 흐름입니다. 재견적(1:N)은 카드·와일드카드 없이 배송형태 선택·추가할인 요청으로 이어지는 별도 흐름이고, 신규협상·신규견적도 현재 라이브 봇에선 카드 없는 재견적 흐름으로 진행됩니다.
</Typography>
</div>
<div className="space-y-3">
<Typography variant="h4">봇은 이렇게 협상해요</Typography>
<Callout tone="warning">
협상카드·와일드카드는 <b className="text-foreground">협상(재협상·신규협상)에서만</b> 쓰여요. 견적(재견적·신규견적)은 카드 없이 진행돼요.
</Callout>
<Typography variant="muted">
재협상 기준으로, 협력사가 제시한 가격을 회사가 정한 앵커링가(목표가보다 낮은 압박 기준선)와 비교해 AI 협상봇이 구간별로 다르게 응대합니다.
견적을 만들면 목표가와 그보다 조금 낮은 앵커링가(협상 완료 기준선)가 자동으로 잡혀요. 봇의 목표는 협력사 제시가를 앵커링가 밑으로 끌어내리는 거예요.
</Typography>
{/* 제시가 구간별 봇 행동 */}
<div className="rounded-xl border border-border p-3">
<Typography as="p" variant="small" className="font-bold">
협력사 제시가 구간 → 봇 행동
</Typography>
<div className="mt-2 space-y-1.5">
{NEGO_ZONES.map((z) => (
<div
key={z.range}
className="flex flex-col gap-1 rounded-lg bg-muted/40 p-2.5 sm:flex-row sm:items-center sm:justify-between"
>
<Typography as="span" variant="small" className="font-mono text-[12px]">
{z.range}
</Typography>
<div className="flex items-center gap-2">
<Typography as="span" variant="caption" className="font-semibold">
{z.action}
</Typography>
<span className="shrink-0 rounded bg-background px-1.5 py-0.5">
<Typography as="span" variant="caption" className="text-muted-foreground">
{z.note}
</Typography>
</span>
</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">
{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>
{/* 협상카드 vs 와일드카드 */}
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-xl border border-border p-3">
<Typography as="p" variant="small" className="font-bold">협상카드 (일반)</Typography>
<Typography variant="caption" className="mt-0.5 block leading-relaxed">
제시가가 아직 앵커에서 여유가 있으면(앵커×1.05 초과) 매 라운드 협상카드를 한 장씩 꺼내 인하를 유도하고 재제안을 받습니다. 견적세팅의 카드 사용 횟수만큼 쓰며, 다 소진되면 협상이 종료됩니다.
</Typography>
</div>
<div className="rounded-xl border border-amber-500/40 bg-amber-500/5 p-3">
<Typography as="p" variant="small" className="font-bold text-amber-700 dark:text-amber-400">와일드카드 (막판)</Typography>
<Typography variant="caption" className="mt-0.5 block leading-relaxed">
제시가가 앵커를 살짝(5% 이내) 넘은 아까운 구간에서 앵커 밑으로 끌어내리려 발동하는 마지막 압박 카드입니다. 한 협상에서 딱 한 번만 나갑니다.
</Typography>
</div>
</div>
<Typography variant="caption" className="block text-muted-foreground">
협상카드는 직접 만든 카드만 견적에 넣어 쓸 수 있고, 몇 장까지 쓸지(사용한도, 기본 3장)는 견적 세팅에서 정해요.
</Typography>
</div>
)}
{tab === 'example' && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Typography variant="h4">마감되면 이 순서로 판정합니다</Typography>
<Badge variant="outline">자동</Badge>
</div>
<div className="space-y-3">
<Typography variant="h4">마감과 낙찰 — 케이스별 결과</Typography>
<Typography variant="muted">
낙찰 후보는 협상완료한 협력사 중 최저가입니다. 최저가라고 곧 낙찰이 아니라, 그 가격을 견적의 낙찰 기준(가격게이트)에 통과시켜 낙찰 · 개찰이 갈립니다.
낙찰은 <b className="text-foreground">협상 완료된 투찰가 중 최저가</b>를 보고 판정해요 — 그 최저가가 단독인지, 그리고 기준선을 넘는지.
</Typography>
{/* 핵심: 낙찰 기준은 견적 생성 시 정하고, 미달은 개찰 */}
<div className="rounded-lg border border-primary/40 bg-primary/5 p-3">
<Typography as="p" variant="small" className="font-semibold text-primary">
낙찰 기준은 견적 생성 시 정합니다 (1:1 협상)
</Typography>
<Typography variant="caption" className="mt-0.5 block leading-relaxed">
1:1 협상은 낙찰선을 앵커링가까지 / 목표가까지 중 택1합니다. 앵커링가 이하는 항상 낙찰, 목표가 초과는 항상 개찰이며, 그 사이 구간만 이 선택으로 갈립니다. 낙찰 기준을 못 넘기면 결렬이 아니라 개찰(낙찰자 미정 마감)이고, 자동 재협상/재생성은 없습니다. 1:N 견적은 무조건 최저가 낙찰.
</Typography>
</div>
{/* 1) 가격게이트 구간 */}
<div className="rounded-xl border border-border p-3">
<Typography as="p" variant="small" className="font-bold">
1) 가격게이트 — 투찰가 구간별 처리
</Typography>
<div className="mt-2 space-y-1.5">
{GATE_ZONES.map((z) => (
<div
key={z.range}
className="flex flex-col gap-1 rounded-lg bg-muted/40 p-2.5 sm:flex-row sm:items-center sm:justify-between"
>
<Typography as="span" variant="small" className="font-mono text-[12px]">
{z.range}
</Typography>
<div className="flex items-center gap-2">
<Typography as="span" variant="caption" className="font-semibold">
{z.action}
</Typography>
<span className="shrink-0 rounded bg-background px-1.5 py-0.5">
<Typography as="span" variant="caption" className="text-muted-foreground">
{z.note}
</Typography>
</span>
</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">
{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>
{/* 2) 완료 양상 + 게이트 + 한도 → 결과 (8행 = close_reason 8종) */}
<div className="space-y-2.5">
<Typography as="p" variant="small" className="font-bold">
2) 완료 양상 + 게이트 + 한도 → 결과
</Typography>
{DECISION_TREE.map((g) => (
<div key={g.group} className="rounded-xl border border-border p-3">
<div className="mb-2 flex flex-wrap items-center gap-2">
<Typography as="span" variant="small" className="font-bold">
{g.group}
</Typography>
<Typography as="span" variant="caption" className="leading-relaxed">
{g.desc}
</Typography>
</div>
<div className="space-y-1.5">
{g.rows.map((r) => (
{/* ② 세션 상태 — 케이스표에 나오는 어휘 */}
<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>
<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>
<Typography as="span" variant="caption" className="font-semibold">{r.result}</Typography>
</span>
</div>
))}
@ -507,19 +402,29 @@ export function OnboardingGuideModal({
))}
</div>
{/* 3) 개찰 처리 */}
<div className="rounded-lg border border-amber-500/40 bg-amber-500/5 p-3">
<Typography as="p" variant="small" className="font-semibold text-amber-700 dark:text-amber-400">
3) 개찰(낙찰자 미정 마감)
</Typography>
<Typography variant="caption" className="mt-0.5 block leading-relaxed">
낙찰이 아닌 마감(기준 미달·동가·미응찰·거부)은 결렬이 아니라 개찰입니다. 자동 재협상/재생성은 없고, 다음 라운드가 필요하면 담당자가 상세 화면에서 수동으로 만듭니다. 모든 결과는 알림함으로 통지됩니다.
</Typography>
{/* ④ 개찰이면 */}
<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>
)}
<DialogFooter>
<Button variant="outline" onClick={openOnboardingPage}>온보딩 페이지에서 보기 ↗</Button>
<Button onClick={() => onOpenChange(false)}>시작하기</Button>
</DialogFooter>
</DialogContent>

View File

@ -0,0 +1,496 @@
import { useState, type ReactNode } from 'react';
import { AlertTriangle, Bell, CheckCircle2, Clock3, FileCheck2, Info, RefreshCw, Trophy, UserCheck } 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({ ico, sub, name, meta, connect, optional, tone = 'primary' }: { ico: string; 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>
)}
<div className="text-[26px] leading-none">{ico}</div>
<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">→</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 ico="📦" sub="무엇을 살까" name="A4용지" meta="기준가 100,000원" />
<RegArrow />
<RegCard ico="🏢" sub="누구에게 보낼까" name="우리제지" meta={<>담당자 이메일 <span className="font-extrabold text-emerald-600 dark:text-emerald-400">✓</span></>} />
<RegArrow />
<RegCard ico="🔗" 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({ ico, label, status, statusTone = 'manual', value, tip }: { ico: string; 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-base">{ico}</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 ico="🤝" label="진행 방식" status="직접 설정" value="한 곳과 협상 · 1:1" tip={<>여러 후보가 있으면 <b className="text-foreground">여러 곳 경쟁(견적)</b>, 단골 한 곳을 더 깎고 싶으면 <b className="text-foreground">한 곳 협상</b>. 협상카드 밀당은 '한 곳 협상'에서 돌아요.</>} />
<QuoteLine ico="📦" label="대상" value="A4용지 · 우리제지" tip={<>협상에 부칠 상품과 견적을 보낼 협력사예요. 한 곳 협상은 이 협력사와 1:1로 진행됩니다.</>} />
<QuoteLine ico="🎯" label="가격 기준" status="자동" statusTone="auto" value="목표가 100,000원 · 앵커링가 95,000원" tip={<>목표가는 상품 기준 가격에서 자동으로 잡고, 앵커링가는 <b className="text-foreground">협상 완료 기준선</b>이에요. 협력사 제시가가 앵커링가 이하로 내려오면 협상완료로 넘어갑니다.</>} />
<QuoteLine ico="🃏" label="협상 한도" status="직접 설정" value="협상카드 선택 · 사용한도 설정" tip={<>한 곳 협상에서 봇이 쓸 협상카드를 고릅니다. 선택한 일반 협상카드를 모두 쓰거나 라운드 상한에 걸릴 때까지 앵커링가 이하로 내려오지 못하면 협상은 실패로 끝납니다.</>} />
<QuoteLine ico="🏆" 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 ico="📄" sub="만든 견적" name="A4용지 협상" meta="EST-2026-014" />
<RegArrow />
<RegCard ico="✉️" sub="꼭 해야 할 일" name="이메일 발송하기" meta="협상 링크 포함" connect tone="must" />
<RegArrow />
<RegCard ico="🏢" 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: '#2563eb',
},
};
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">선택한 협상카드</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>
);
}

View File

@ -0,0 +1,79 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { Typography } from '@/components/ui/typography';
// 항목 옆 정보 말풍선. 클릭 토글 + 바깥 클릭 닫힘. 온보딩 전반에서 "왜/무엇" 설명에 재사용.
interface InfoTipProps {
children: ReactNode;
label?: string; // pill 모드일 때 트리거 텍스트
mini?: boolean; // true = 원형 '?' 아이콘 버튼(견적 행 등)
align?: 'left' | 'right'; // 팝오버 정렬
}
export function InfoTip({ children, label = '왜 필요해요?', mini = false, align = 'left' }: InfoTipProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('click', onDoc);
return () => document.removeEventListener('click', onDoc);
}, [open]);
return (
<span ref={ref} className="relative inline-block align-middle">
<button
type="button"
aria-expanded={open}
aria-label={mini ? '설명' : undefined}
onClick={(e) => {
e.stopPropagation();
setOpen((o) => !o);
}}
className={cn(
'cursor-pointer rounded-full border border-primary/30 bg-primary/5 font-bold text-primary transition-[filter] hover:brightness-105 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary',
mini ? 'inline-grid size-[22px] place-items-center text-xs' : 'px-3 py-1 text-[11.5px]',
)}
>
{mini ? (
'?'
) : (
<Typography as="span" variant="caption" className="font-bold text-primary">
💡 {label}
</Typography>
)}
</button>
{open && (
<span
role="tooltip"
onClick={(e) => e.stopPropagation()}
className={cn(
'absolute top-[calc(100%+11px)] z-30 block w-[min(300px,74vw)] rounded-xl border border-primary/30 bg-card p-3 text-left shadow-lg',
align === 'right' ? 'right-0' : 'left-0',
)}
>
<span
aria-hidden
className={cn(
'absolute -top-[8px] size-0 border-x-[8px] border-b-[8px] border-x-transparent border-b-primary/30',
align === 'right' ? 'right-[17px]' : 'left-[17px]',
)}
/>
<span
aria-hidden
className={cn(
'absolute -top-[7px] size-0 border-x-[7px] border-b-[7px] border-x-transparent border-b-card',
align === 'right' ? 'right-[18px]' : 'left-[18px]',
)}
/>
<Typography as="span" variant="caption" className="block leading-relaxed text-muted-foreground">
{children}
</Typography>
</span>
)}
</span>
);
}

View File

@ -0,0 +1,122 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Typography } from '@/components/ui/typography';
import { OverviewStep } from './OverviewStep';
import { PrepareStep, QuoteStep, InviteStep, NegotiationStep, AwardStep } from './DetailSteps';
import { markOnboardingSeen } from '../storage';
type Tone = 'sys' | 'user' | 'must' | 'partner';
interface StepMeta { dot: string; label: string; actor: string; tone: Tone }
const STEPS: StepMeta[] = [
{ dot: '0', label: '한눈에', actor: '전체', tone: 'sys' },
{ dot: '1', label: '준비', actor: '내가', tone: 'user' },
{ dot: '2', label: '견적 만들기', actor: '내가', tone: 'user' },
{ dot: '3', label: '이메일 발송', actor: '내가', tone: 'must' },
{ dot: '4', label: '협상', actor: '봇이', tone: 'partner' },
{ dot: '5', label: '마감·낙찰', actor: '자동', tone: 'sys' },
];
const STEP_COMPONENTS = [OverviewStep, PrepareStep, QuoteStep, InviteStep, NegotiationStep, AwardStep];
const ACTOR_CLS: Record<Tone, string> = {
user: 'text-primary',
partner: 'text-emerald-600 dark:text-emerald-400',
must: 'text-destructive',
sys: 'text-muted-foreground',
};
export function OnboardingWizard() {
const [step, setStep] = useState(0);
const navigate = useNavigate();
const total = STEPS.length;
const StepComp = STEP_COMPONENTS[step];
const atEnd = step === total - 1;
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
contentRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
}, [step]);
const finish = () => {
markOnboardingSeen();
navigate('/dashboard');
};
const next = () => (atEnd ? finish() : setStep((s) => s + 1));
return (
<div className="mx-auto flex h-full w-full max-w-3xl flex-col">
{/* 건너뛰기 */}
<div className="mb-2 flex flex-none justify-end">
<button type="button" onClick={finish} className="cursor-pointer p-1 text-muted-foreground transition-colors hover:text-foreground">
<Typography as="span" variant="caption">건너뛰기 ✕</Typography>
</button>
</div>
{/* 스텝퍼 */}
<div className="-mx-1 flex-none overflow-x-auto px-1 pb-1">
<div className="relative grid min-w-[560px]" style={{ gridTemplateColumns: `repeat(${total}, minmax(0, 1fr))` }}>
<div
aria-hidden
className="pointer-events-none absolute top-[21px] z-0 h-0.5 bg-border/70"
style={{ left: `${100 / (total * 2)}%`, right: `${100 / (total * 2)}%` }}
>
<span
className="block h-full bg-primary transition-[width] duration-300"
style={{ width: `${(step / (total - 1)) * 100}%` }}
/>
</div>
{STEPS.map((s, i) => {
const active = i === step;
const done = i < step;
const must = s.tone === 'must';
return (
<button
key={s.dot}
type="button"
onClick={() => setStep(i)}
className="relative z-[1] flex cursor-pointer flex-col items-center gap-1.5 px-1 pt-1.5"
>
<span
className={cn(
'relative z-[1] grid size-[30px] place-items-center rounded-full border-2 text-xs font-bold transition',
active && !must && 'border-primary bg-primary text-primary-foreground ring-4 ring-primary/10',
active && must && 'border-destructive bg-destructive text-white ring-4 ring-destructive/10',
done && 'border-primary bg-background text-primary',
!active && !done && (must ? 'border-destructive/60 bg-background text-destructive' : 'border-border/80 bg-background text-muted-foreground'),
)}
>
{s.dot}
</span>
<Typography as="span" variant="caption" className={cn('font-bold', active || done ? (must ? 'text-destructive' : 'text-foreground') : must ? 'text-destructive' : 'text-muted-foreground')}>
{s.label}
</Typography>
<Typography as="span" variant="caption" className={cn('text-[10px]', ACTOR_CLS[s.tone])}>
{s.actor}
</Typography>
</button>
);
})}
</div>
</div>
{/* 콘텐츠 (내부 스크롤) */}
<div ref={contentRef} className="mt-5 min-h-0 flex-1 overflow-y-auto px-1 py-4 md:px-3">
<StepComp />
</div>
{/* 하단 네비 (고정) */}
<div className="mt-4 flex flex-none items-center gap-3 border-t border-border/70 pt-4">
<Button variant="outline" disabled={step === 0} onClick={() => setStep((s) => Math.max(0, s - 1))}>
← 이전
</Button>
<div className="flex-1" />
<Typography as="span" variant="caption" className="tabular-nums text-muted-foreground">
{step === 0 ? '전체 흐름 · 한눈에' : `${step} / ${total - 1} 단계`}
</Typography>
<Button onClick={next}>{step === 0 ? '하나씩 볼게요 →' : atEnd ? '시작하기 ✓' : '다음 →'}</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,341 @@
import { useEffect, useRef, useState } from 'react';
import { RotateCcw } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Typography } from '@/components/ui/typography';
// step0 "한눈에" — 등록→이메일→협상→낙찰을 씬 전환 애니메이션으로 보여준다.
// 협상은 봇↔상대회사 말풍선이 하나씩 뜨고 제시가가 내려가는 재연.
type Side = 'bot' | 'sp';
type Card = 'nego' | 'wild' | 'end';
interface Msg { side: Side; text: string; card?: Card; priceNum?: number }
const MESSAGES: Msg[] = [
{ side: 'bot', text: 'A4용지, 공급 가능한 금액을 알려주세요.' },
{ side: 'sp', priceNum: 110_000, text: '110,000원 제시' },
{ side: 'bot', card: 'nego', text: '제시하신 금액은 저희 기준보다 높습니다. 조정 가능한 최선의 가격을 알려주세요.' },
{ side: 'sp', priceNum: 102_000, text: '102,000원' },
{ side: 'bot', card: 'nego', text: '거의 왔어요. 조금만 더 조정 부탁드릴게요.' },
{ side: 'sp', priceNum: 97_000, text: '97,000원' },
{ side: 'bot', card: 'wild', text: '딱 조금만 더 맞춰주시면 바로 확정하겠습니다.' },
{ side: 'sp', priceNum: 94_800, text: '94,800원' },
{ side: 'bot', card: 'end', text: '네, 94,800원으로 협상을 마무리할게요.' },
];
const CARD_TAG: Record<Card, { label: string; cls: string }> = {
nego: { label: '🃏 협상카드', cls: 'text-primary bg-primary/10 border-primary/20' },
wild: { label: '🃏 와일드카드 · 막판', cls: 'text-amber-600 dark:text-amber-400 bg-amber-500/10 border-amber-500/40' },
end: { label: '협상 완료', cls: 'text-muted-foreground bg-muted border-border' },
};
const CHIPS = [
{ icon: '📋', label: '준비' },
{ icon: '✉️', label: '발송' },
{ icon: '🤝', label: '협상' },
{ icon: '🏆', label: '낙찰' },
];
export function OverviewStep() {
const [runId, setRunId] = useState(0);
const [phase, setPhase] = useState(0); // 0 등록 · 1 이메일 · 2 협상 · 3 낙찰
const [typeSel, setTypeSel] = useState(false);
const [target, setTarget] = useState('···');
const [envOpen, setEnvOpen] = useState(false);
const [msgIdx, setMsgIdx] = useState(-1);
const [priceNum, setPriceNum] = useState<number | null>(null);
const [drop, setDrop] = useState<string | null>(null);
const displayPrice = useCountDown(priceNum);
const [speaking, setSpeaking] = useState<Side | null>(null);
const [bidMoved, setBidMoved] = useState(false);
const [stamp, setStamp] = useState(false);
const [replay, setReplay] = useState(false);
useEffect(() => {
const timers: number[] = [];
const at = (t: number, fn: () => void) => timers.push(window.setTimeout(fn, t));
setPhase(0); setTypeSel(false); setTarget('···'); setEnvOpen(false);
setMsgIdx(-1); setPriceNum(null); setDrop(null); setSpeaking(null);
setBidMoved(false); setStamp(false); setReplay(false);
// 1) 등록 — 유형 선택 + 공략가 스마트 산정
at(900, () => setTypeSel(true));
at(1700, () => setTarget('97,000'));
at(2000, () => setTarget('94,000'));
at(2300, () => setTarget('95,800'));
at(2750, () => setTarget('95,000원'));
// 2) 이메일 — 편지 커지며 사라짐
at(3800, () => setPhase(1));
at(4100, () => setEnvOpen(true));
// 3) 협상 — 말풍선 하나씩 + 제시가 하락
const chatStart = 5500, gap = 1400;
at(chatStart - 200, () => setPhase(2));
let prev: number | null = null;
MESSAGES.forEach((m, i) => {
at(chatStart + i * gap, () => {
setMsgIdx(i);
setSpeaking(m.side);
if (m.priceNum != null) {
setDrop(prev != null ? `▼ ${(prev - m.priceNum).toLocaleString('en-US')}` : null);
setPriceNum(m.priceNum);
prev = m.priceNum;
}
});
});
// 4) 낙찰 — 스펙트럼 + 스탬프
const chatEnd = chatStart + MESSAGES.length * gap;
at(chatEnd + 400, () => { setPhase(3); setSpeaking(null); });
at(chatEnd + 850, () => setBidMoved(true));
at(chatEnd + 2800, () => setStamp(true));
at(chatEnd + 3500, () => setReplay(true));
return () => timers.forEach(clearTimeout);
}, [runId]);
const cur = msgIdx >= 0 ? MESSAGES[msgIdx] : null;
return (
<div>
<header className="mb-4 flex items-center gap-3">
<span className="grid size-9 flex-none place-items-center rounded-[10px] bg-muted text-base font-extrabold text-muted-foreground">0</span>
<div>
<Typography variant="label" className="text-[10.5px] text-muted-foreground">
전체 흐름 · <span className="font-bold">한눈에</span>
</Typography>
<Typography variant="h4" className="text-lg font-bold tracking-tight">구매 협상이 어떻게 흘러가는지 먼저 봐요</Typography>
</div>
</header>
<Typography variant="muted" className="mb-5">
준비부터 이메일 발송, 협상, 마감·낙찰까지 이어지는 흐름을 짧게 재생합니다.
</Typography>
<div>
{/* 진행 칩 */}
<div className="mb-3 grid grid-cols-4 gap-2">
{CHIPS.map((c, i) => {
const active = phase === i;
return (
<div
key={c.label}
className={cn(
'rounded-lg border px-2.5 py-2 text-center transition-all duration-300',
active
? 'border-amber-500 bg-muted/45 text-amber-600 dark:text-amber-400'
: 'border-transparent bg-muted/45 text-muted-foreground',
)}
>
<Typography as="span" variant="caption" className="block text-base leading-none">{c.icon}</Typography>
<Typography as="span" variant="caption" className="mt-1 block font-extrabold text-inherit">{c.label}</Typography>
</div>
);
})}
</div>
{/* 스테이지 */}
<div className="relative min-h-[252px] rounded-xl border border-border bg-card p-4 shadow-sm">
{phase === 0 && (
<div className="animate-in fade-in duration-300">
<div className="grid grid-cols-2 gap-2">
<div className={cn('rounded-lg p-3 text-center transition-colors', typeSel ? 'bg-primary/5 ring-1 ring-primary/15' : 'bg-muted/70')}>
<Typography variant="small" className={cn('font-bold', typeSel && 'text-primary')}>🤝 협상 · 1:1</Typography>
<Typography variant="caption" className="mt-1 block text-muted-foreground">한 곳과 밀당</Typography>
</div>
<div className="rounded-lg bg-muted/70 p-3 text-center">
<Typography variant="small" className="font-bold">🏢 견적 · 1:N</Typography>
<Typography variant="caption" className="mt-1 block text-muted-foreground">여러 곳 경쟁</Typography>
</div>
</div>
<div className="mt-3 grid min-h-[70px] grid-cols-[2rem_minmax(0,1fr)_auto] items-center gap-3 rounded-lg bg-muted/70 px-4 py-3">
<span className="grid size-8 place-items-center rounded-md bg-background text-base">📍</span>
<span className="min-w-0">
<Typography as="span" variant="caption" className="block font-bold text-muted-foreground">앵커링가 자동 산정</Typography>
<Typography as="span" variant="small" className="mt-1 block font-extrabold text-foreground">목표가보다 낮은 협상 완료 기준선</Typography>
</span>
<Typography as="span" variant="h4" className={cn('font-black tabular-nums transition-colors', target.includes('원') ? 'text-primary' : 'text-muted-foreground')}>
{target}
</Typography>
</div>
</div>
)}
{phase === 1 && (
<div className="flex min-h-[220px] flex-col items-center justify-center gap-3 overflow-hidden animate-in fade-in duration-300">
<div className={cn('text-[52px] leading-none transition-all duration-1000 ease-[cubic-bezier(.3,.6,.4,1)]', envOpen ? 'scale-[2.4] opacity-0' : 'scale-100 opacity-100')}>✉️</div>
<Typography variant="small" className="font-semibold text-muted-foreground">협력사에게 이메일을 발송했어요</Typography>
</div>
)}
{phase === 2 && (
<div className="animate-in fade-in duration-300">
{/* 제시가 바 — 이전값에서 새 제시가까지 숫자가 주르륵 내려간다 */}
<div className="mb-3 flex items-center gap-2.5 rounded-lg bg-muted/70 px-3.5 py-2.5">
<Typography as="span" variant="caption" className="font-bold text-muted-foreground">상대회사 제시가</Typography>
{drop && (
<span className="ml-auto inline-flex items-center rounded-full bg-emerald-500/10 px-2 py-0.5">
<Typography as="span" variant="caption" className="font-extrabold tabular-nums text-emerald-600 dark:text-emerald-400">{drop}</Typography>
</span>
)}
<Typography as="span" variant="h4" className={cn('font-black tabular-nums text-primary', !drop && 'ml-auto')}>
{displayPrice != null ? `${displayPrice.toLocaleString('en-US')}원` : '—'}
</Typography>
</div>
{/* 대화 (아바타 + 현재 말풍선) */}
<div className="relative min-h-[158px]">
<Avatar side="bot" speaking={speaking === 'bot'} />
<Avatar side="sp" speaking={speaking === 'sp'} />
{cur && (
<div
key={msgIdx}
className={cn(
'absolute top-1/2 max-w-[calc(100%-112px)] -translate-y-1/2 animate-in fade-in duration-300',
cur.side === 'bot' ? 'left-[54px] slide-in-from-left-2' : 'right-[54px] slide-in-from-right-2',
)}
>
{cur.side === 'bot' ? (
<div className={cn('rounded-2xl rounded-bl-md px-3.5 py-2.5', cur.card === 'wild' ? 'bg-amber-500/10' : 'bg-muted/70')}>
{cur.card && (
<span className={cn('mb-1.5 inline-block w-fit rounded-md border px-1.5 py-0.5', CARD_TAG[cur.card].cls)}>
<Typography as="span" variant="caption" className="text-[10px] font-extrabold text-inherit">{CARD_TAG[cur.card].label}</Typography>
</span>
)}
<Typography as="p" variant="small" className="text-[13px] leading-snug">{cur.text}</Typography>
</div>
) : (
<div className="rounded-2xl rounded-br-md bg-primary px-3.5 py-2.5 text-primary-foreground">
<Typography as="span" variant="small" className="text-sm font-bold tabular-nums text-primary-foreground">{cur.text}</Typography>
</div>
)}
</div>
)}
</div>
</div>
)}
{phase === 3 && (
<div className="relative animate-in fade-in duration-300">
<AwardTrack moved={bidMoved} />
{stamp && (
<div className="pointer-events-none absolute left-1/2 top-[64%] z-[4] -translate-x-1/2 -translate-y-1/2 -rotate-12 rounded-xl border-4 border-emerald-600 px-4 py-1 animate-in zoom-in duration-500 dark:border-emerald-500">
<Typography as="span" variant="h3" className="text-2xl font-black text-emerald-600 dark:text-emerald-400">낙찰!</Typography>
</div>
)}
</div>
)}
</div>
<div className="mt-3.5 h-8 text-center">
{replay && (
<button
type="button"
onClick={() => setRunId((n) => n + 1)}
className="mx-auto inline-flex cursor-pointer items-center gap-1.5 rounded-full border border-border bg-muted px-4 py-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground animate-in fade-in"
>
<RotateCcw className="size-3.5" />
<Typography as="span" variant="caption" className="font-bold text-inherit">다시 보기</Typography>
</button>
)}
</div>
<Typography variant="caption" className="mt-3 block text-center text-muted-foreground">
<b className="font-semibold">다음</b>을 누르면 각 단계를 하나씩 자세히 볼게요 ↓
</Typography>
</div>
</div>
);
}
// 낙찰 씬 트랙 — 4스텝 슬라이더와 같은 비주얼(두꺼운 라운드 트랙 + 채움 + 앵커링가/목표가 눈금 + thumb).
// 조작 없이 투찰가 마커가 높은 값(오른쪽)에서 낙찰 구간(왼쪽)으로 스르륵 들어가는 리플레이만.
function AwardTrack({ moved }: { moved: boolean }) {
const bidPct = moved ? 40 : 85;
const anchorPct = 44;
const targetPct = 66;
const glide = 'transition-[left,width] duration-[1600ms] ease-[cubic-bezier(.3,.7,.3,1)]';
return (
<div className="rounded-xl bg-muted/70 p-4">
<div className="flex items-end justify-between gap-2">
<div>
<Typography variant="caption" className="font-bold text-muted-foreground">협상 결과 · 투찰가</Typography>
<Typography as="p" variant="h3" className="mt-1 text-2xl font-black tabular-nums text-emerald-600 dark:text-emerald-400">94,800원</Typography>
</div>
<span className="rounded-full bg-emerald-500/10 px-2.5 py-1">
<Typography as="span" variant="caption" className="font-extrabold text-emerald-600 dark:text-emerald-400">낙찰 구간</Typography>
</span>
</div>
<div className="relative mt-6 pb-8">
<div className="relative h-2 w-full">
{/* 구간 색: 낙찰 구간(목표가 이하) emerald · 그 위 amber */}
<div className="flex h-full w-full overflow-hidden rounded-full">
<span className="h-full bg-emerald-500/30" style={{ width: `${targetPct}%` }} />
<span className="h-full bg-amber-500/30" style={{ width: `${100 - targetPct}%` }} />
</div>
<span className="absolute -top-1 h-4 w-px bg-muted-foreground/70" style={{ left: `${anchorPct}%` }} />
<span className="absolute -top-1 h-4 w-px bg-muted-foreground/70" style={{ left: `${targetPct}%` }} />
<div className={cn('absolute top-1/2 z-10 -translate-x-1/2 -translate-y-1/2', glide)} style={{ left: `${bidPct}%` }}>
<span className="block size-5 rounded-full border-4 border-background bg-emerald-500 shadow" />
</div>
</div>
<Typography as="span" variant="caption" className="absolute top-4 -translate-x-1/2 whitespace-nowrap font-bold text-muted-foreground" style={{ left: `${anchorPct}%` }}>앵커링가</Typography>
<Typography as="span" variant="caption" className="absolute top-4 -translate-x-1/2 whitespace-nowrap font-bold text-muted-foreground" style={{ left: `${targetPct}%` }}>목표가</Typography>
</div>
<Typography as="p" variant="caption" className="text-center leading-relaxed text-muted-foreground">
투찰가가 목표가 아래 <b className="font-bold text-emerald-600 dark:text-emerald-400">낙찰 구간</b>으로 들어왔어요
</Typography>
</div>
);
}
// 제시가가 바뀔 때 이전 값에서 새 값까지 숫자가 주르륵 내려가는 카운트다운. 첫 등장(이전값 없음)은 스냅.
function useCountDown(target: number | null, duration = 700) {
const [display, setDisplay] = useState<number | null>(null);
const fromRef = useRef<number | null>(null);
useEffect(() => {
if (target == null) {
fromRef.current = null;
setDisplay(null);
return;
}
const from = fromRef.current;
if (from == null) {
fromRef.current = target;
setDisplay(target);
return;
}
let raf = 0;
let start: number | null = null;
const tick = (ts: number) => {
if (start == null) start = ts;
const p = Math.min(1, (ts - start) / duration);
const eased = 1 - Math.pow(1 - p, 3); // easeOutCubic — 처음 빠르게, 끝에서 부드럽게 멈춤
setDisplay(Math.round(from + (target - from) * eased));
if (p < 1) raf = requestAnimationFrame(tick);
else fromRef.current = target;
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [target, duration]);
return display;
}
function Avatar({ side, speaking }: { side: Side; speaking: boolean }) {
const isBot = side === 'bot';
return (
<div className={cn('absolute top-1/2 w-12 -translate-y-1/2 text-center', isBot ? 'left-0' : 'right-0')}>
<div
className={cn(
'mx-auto grid size-10 place-items-center rounded-full border text-lg transition',
isBot ? 'border-primary/30 bg-primary/10' : 'border-border bg-muted',
speaking && 'scale-110 ring-4',
speaking && (isBot ? 'ring-primary/10' : 'ring-muted'),
)}
>
{isBot ? '🤖' : '🏢'}
</div>
<Typography variant="caption" className="mt-1 block font-bold text-muted-foreground">{isBot ? '협상봇' : '상대회사'}</Typography>
</div>
);
}

View File

@ -0,0 +1,101 @@
import { cn } from '@/lib/utils';
import { Typography } from '@/components/ui/typography';
// 가격 스펙트럼(구간 트랙). 낙찰 판정·협상 구간 등 여러 곳에서 통일된 형태로 재사용.
// 구간 톤: good=낙찰/바로확정(emerald), mid=경계/막판(amber), high=계속깎기(primary).
export type SpectrumTone = 'good' | 'mid' | 'high';
interface Zone { width: number; tone: SpectrumTone }
interface Cap { width: number; label: string; tone: SpectrumTone }
interface Mark { left: number; label: string }
interface Bid { left: number; label: string }
const ZONE_BG: Record<SpectrumTone, string> = {
good: 'bg-emerald-500/10 border-emerald-500/40',
mid: 'bg-amber-500/10 border-amber-500/40',
high: 'bg-primary/5 border-primary/25',
};
const CAP_TEXT: Record<SpectrumTone, string> = {
good: 'text-emerald-600 dark:text-emerald-400',
mid: 'text-amber-600 dark:text-amber-400',
high: 'text-primary',
};
export function PriceSpectrum({
zones,
caps,
marks,
bid,
foot,
caption,
}: {
zones: Zone[];
caps?: Cap[];
marks?: Mark[];
bid?: Bid;
foot?: string;
caption?: React.ReactNode;
}) {
return (
<div className={cn('relative px-2', marks ? 'pt-7' : 'pt-1')}>
{caps && (
<div className="mb-1.5 flex">
{caps.map((c, i) => (
<Typography key={i} as="span" variant="caption" className={cn('text-center font-bold', CAP_TEXT[c.tone])} style={{ width: `${c.width}%` }}>
{c.label}
</Typography>
))}
</div>
)}
<div className="relative flex h-4">
{zones.map((z, i) => (
<span
key={i}
className={cn(
'h-full border-y',
ZONE_BG[z.tone],
i === 0 && 'rounded-l-lg border-l',
i === zones.length - 1 && 'rounded-r-lg border-r',
)}
style={{ width: `${z.width}%` }}
/>
))}
{marks?.map((m, i) => (
<span key={i} className="absolute -top-[7px] -bottom-[7px] z-[2] w-0.5 bg-muted-foreground" style={{ left: `${m.left}%` }}>
<Typography as="span" variant="caption" className="absolute -top-[17px] left-1/2 -translate-x-1/2 whitespace-nowrap font-bold text-muted-foreground">
{m.label}
</Typography>
</span>
))}
{bid && (
<span
className="absolute top-1/2 z-[3] -translate-x-1/2 -translate-y-1/2 transition-[left] duration-[1800ms] ease-[cubic-bezier(.3,.7,.3,1)]"
style={{ left: `${bid.left}%` }}
>
<span className="block size-[15px] rounded-full border-[3px] border-background bg-primary shadow" />
<Typography as="span" variant="caption" className="absolute left-1/2 top-[17px] -translate-x-1/2 whitespace-nowrap font-bold tabular-nums text-primary">
{bid.label}
</Typography>
</span>
)}
</div>
{foot && (
<div className="mt-1.5 flex justify-between">
<Typography as="span" variant="caption" className="text-muted-foreground">싸다</Typography>
<Typography as="span" variant="caption" className="font-semibold tabular-nums text-muted-foreground">{foot}</Typography>
<Typography as="span" variant="caption" className="text-muted-foreground">비싸다</Typography>
</div>
)}
{caption && (
<Typography as="p" variant="caption" className="mt-10 text-center leading-relaxed text-muted-foreground">
{caption}
</Typography>
)}
</div>
);
}

View File

@ -74,9 +74,9 @@ export const STEPS: Step[] = [
actor: '시스템',
actorType: 'system',
icon: Award,
short: '단독최저+기준통과=낙찰 / 그 외=개찰(낙찰자 미정)',
short: '경쟁 견적=단독 최저 낙찰 / 1:1 협상=기준 적용',
detail:
'단독 최저가가 견적의 낙찰 기준을 통과하면 그 협력사로 낙찰됩니다. 기준을 못 넘기거나 동가·전원 미응찰·협상거부인 경우는 결렬이 아니라 개찰 — 낙찰자 미정으로 마감되고, 다음 라운드는 담당자가 상세에서 수동으로 만듭니다. 결과는 알림함으로 통지되고 대시보드에서 현황을 확인합니다.',
'경쟁 견적은 투찰가가 있는 협력사 중 단독 최저가가 있으면 낙찰됩니다. 1:1 협상은 앵커링가까지·목표가까지 같은 낙찰 기준을 적용합니다. 동가·전원 미응찰·가격 기준 미달은 다음 견적이 자동 재생성되고, 협상거부는 개찰 — 낙찰자 미정으로 남습니다. 결과는 알림함으로 통지되고 대시보드에서 확인합니다.',
},
];

View File

@ -0,0 +1,20 @@
// 온보딩 노출 여부 — localStorage 저장(기기별). 최초 로그인 유저에게 자동 노출하고,
// 완료('시작하기') 또는 '건너뛰기' 시 봤음 처리해 이후 자동노출을 막는다.
const ONBOARDING_SEEN_KEY = 'negodata.onboarding.seen';
export function hasSeenOnboarding(): boolean {
try {
return localStorage.getItem(ONBOARDING_SEEN_KEY) === '1';
} catch {
// 프라이빗 모드 등 localStorage 접근 불가 시엔 자동노출을 걸지 않는다(리다이렉트 루프 방지)
return true;
}
}
export function markOnboardingSeen(): void {
try {
localStorage.setItem(ONBOARDING_SEEN_KEY, '1');
} catch {
// 저장 실패는 무시 — 다음 로그인에 다시 뜰 뿐
}
}

View File

@ -157,7 +157,7 @@ export const isNewQuotationType = (t?: number | null): boolean =>
t === QuotationType.NEW_NEGO || t === QuotationType.NEW_QUOTE;
// 1:1 협상(RENEGO/NEW_NEGO) 여부. 협상카드·낙찰기준 노출, 협력사 단일선택이 이 분기에 의존.
// 나머지(REQUOTE/NEW_QUOTE)는 1:N 경매 — 무조건 최저가 낙찰.
// 나머지(REQUOTE/NEW_QUOTE)는 1:N 경매 — 단독 최저가 자동 낙찰(동가는 개찰).
export const is1v1 = (t?: number | null): boolean =>
t === QuotationType.RENEGO || t === QuotationType.NEW_NEGO;
@ -178,9 +178,9 @@ export const toQuotationType = (mode: QuotationMode, isNew: boolean): QuotationT
export const DEFAULT_MID_ACTION: PriceGateAction = PriceGateAction.AWARD; // 앵커~목표가 기본 낙찰
export const DEFAULT_OVER_ACTION: PriceGateAction = PriceGateAction.OPEN; // 목표가 초과 기본 개찰
// 견적의 낙찰 기준 한 줄 라벨(상세 드로어). 경매(1:N)=최저가 자동 낙찰. 협상=두 구간 조합 요약.
// 견적의 낙찰 기준 한 줄 라벨(상세 드로어). 경매(1:N)=단독 최저가 자동 낙찰. 협상=두 구간 조합 요약.
export function awardCriterionLabel(type?: number | null, mid?: number | null, over?: number | null): string {
if (type != null && !is1v1(type)) return '최저가 자동 낙찰';
if (type != null && !is1v1(type)) return '단독 최저가 자동 낙찰';
const m = (mid ?? PriceGateAction.AWARD) === PriceGateAction.AWARD;
const o = (over ?? PriceGateAction.AWARD) === PriceGateAction.AWARD;
if (m && o) return '목표가 초과도 낙찰';

View File

@ -0,0 +1,10 @@
import { OnboardingWizard } from '@/features/onboarding/components/OnboardingWizard';
// 신규 유저 온보딩 — 앱 레이아웃(사이드바) 없이 독립 풀스크린 페이지. 등록~낙찰 전체 흐름 안내.
export default function OnboardingPage() {
return (
<div className="h-[100dvh] bg-background px-4 py-8 md:py-12">
<OnboardingWizard />
</div>
);
}