[feat] negodata/front: 온보딩 페이지 추가 + 이용안내 모달 재작성
This commit is contained in:
parent
b368a19f79
commit
0d62440ee1
@ -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},
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -74,9 +74,9 @@ export const STEPS: Step[] = [
|
||||
actor: '시스템',
|
||||
actorType: 'system',
|
||||
icon: Award,
|
||||
short: '단독최저+기준통과=낙찰 / 그 외=개찰(낙찰자 미정)',
|
||||
short: '경쟁 견적=단독 최저 낙찰 / 1:1 협상=기준 적용',
|
||||
detail:
|
||||
'단독 최저가가 견적의 낙찰 기준을 통과하면 그 협력사로 낙찰됩니다. 기준을 못 넘기거나 동가·전원 미응찰·협상거부인 경우는 결렬이 아니라 개찰 — 낙찰자 미정으로 마감되고, 다음 라운드는 담당자가 상세에서 수동으로 만듭니다. 결과는 알림함으로 통지되고 대시보드에서 현황을 확인합니다.',
|
||||
'경쟁 견적은 투찰가가 있는 협력사 중 단독 최저가가 있으면 낙찰됩니다. 1:1 협상은 앵커링가까지·목표가까지 같은 낙찰 기준을 적용합니다. 동가·전원 미응찰·가격 기준 미달은 다음 견적이 자동 재생성되고, 협상거부는 개찰 — 낙찰자 미정으로 남습니다. 결과는 알림함으로 통지되고 대시보드에서 확인합니다.',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
20
negodata/front/src/features/onboarding/storage.ts
Normal file
20
negodata/front/src/features/onboarding/storage.ts
Normal 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 {
|
||||
// 저장 실패는 무시 — 다음 로그인에 다시 뜰 뿐
|
||||
}
|
||||
}
|
||||
@ -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 '목표가 초과도 낙찰';
|
||||
|
||||
10
negodata/front/src/pages/onboarding.tsx
Normal file
10
negodata/front/src/pages/onboarding.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user