From 0d62440ee138e761529c7a72f8ca216be9dd2edf Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Thu, 9 Jul 2026 16:08:54 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20negodata/front:=20=EC=98=A8=EB=B3=B4?= =?UTF-8?q?=EB=94=A9=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?+=20=EC=9D=B4=EC=9A=A9=EC=95=88=EB=82=B4=20=EB=AA=A8=EB=8B=AC?= =?UTF-8?q?=20=EC=9E=AC=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- negodata/front/src/app/router.tsx | 19 + .../dashboard/components/ResultExamples.tsx | 4 +- .../onboarding/OnboardingGuideModal.tsx | 447 +++++++--------- .../onboarding/components/DetailSteps.tsx | 496 ++++++++++++++++++ .../onboarding/components/InfoTip.tsx | 79 +++ .../components/OnboardingWizard.tsx | 122 +++++ .../onboarding/components/OverviewStep.tsx | 341 ++++++++++++ .../onboarding/components/PriceSpectrum.tsx | 101 ++++ .../front/src/features/onboarding/steps.ts | 4 +- .../front/src/features/onboarding/storage.ts | 20 + .../front/src/features/quotations/types.ts | 6 +- negodata/front/src/pages/onboarding.tsx | 10 + 12 files changed, 1371 insertions(+), 278 deletions(-) create mode 100644 negodata/front/src/features/onboarding/components/DetailSteps.tsx create mode 100644 negodata/front/src/features/onboarding/components/InfoTip.tsx create mode 100644 negodata/front/src/features/onboarding/components/OnboardingWizard.tsx create mode 100644 negodata/front/src/features/onboarding/components/OverviewStep.tsx create mode 100644 negodata/front/src/features/onboarding/components/PriceSpectrum.tsx create mode 100644 negodata/front/src/features/onboarding/storage.ts create mode 100644 negodata/front/src/pages/onboarding.tsx diff --git a/negodata/front/src/app/router.tsx b/negodata/front/src/app/router.tsx index 72c15cd..c63580b 100644 --- a/negodata/front/src/app/router.tsx +++ b/negodata/front/src/app/router.tsx @@ -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}, diff --git a/negodata/front/src/features/dashboard/components/ResultExamples.tsx b/negodata/front/src/features/dashboard/components/ResultExamples.tsx index 49c2a98..e1b9ae8 100644 --- a/negodata/front/src/features/dashboard/components/ResultExamples.tsx +++ b/negodata/front/src/features/dashboard/components/ResultExamples.tsx @@ -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', diff --git a/negodata/front/src/features/onboarding/OnboardingGuideModal.tsx b/negodata/front/src/features/onboarding/OnboardingGuideModal.tsx index dd74866..9b2e28b 100644 --- a/negodata/front/src/features/onboarding/OnboardingGuideModal.tsx +++ b/negodata/front/src/features/onboarding/OnboardingGuideModal.tsx @@ -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 = { 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 = { 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 ( @@ -214,14 +222,14 @@ export function OnboardingGuideModal({ - 상품·협력사 등록부터 자동 협상 낙찰까지의 흐름과, 견적세팅 정책에 따른 마감 판정을 확인하세요. + 전체 흐름과 자주 찾는 개념을 언제든 다시 확인하세요. {/* 뷰 전환 탭 */}
- {(['flow', 'type', 'nego', 'example'] as const).map((t) => ( + {(['flow', 'type', 'nego', 'example', 'terms'] as const).map((t) => ( diff --git a/negodata/front/src/features/onboarding/components/DetailSteps.tsx b/negodata/front/src/features/onboarding/components/DetailSteps.tsx new file mode 100644 index 0000000..68d97bb --- /dev/null +++ b/negodata/front/src/features/onboarding/components/DetailSteps.tsx @@ -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 = { + 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 ( +
+ {num} +
+ + {kicker} · {actor} + + {title} +
+
+ ); +} + +type CalloutTone = 'info' | 'warning' | 'success'; +const CALLOUT_TONE: Record = { + 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 ( +
+ +
+ {title && {title}} + {children} +
+
+ ); +} + +/* ── 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 ( +
+ {optional && ( + + 선택사항 + + )} +
{ico}
+ {sub} + {name} + {meta} +
+ ); +} +const RegArrow = () => →; + +export function PrepareStep() { + return ( +
+ + 무엇을 살지(상품), 누구에게 보낼지(협력사), 그리고 그 협력사가 A4용지를 어떤 방식으로 조달하는지 연결해요. +
+ + + 담당자 이메일 ✓} /> + + +
+
+ + 협력사의 역할이 다르면 참고해야 할 협상 사례도 달라져요. A4용지를 제조·총판·유통 중 어떤 역할로 취급하는지 저장해두면, 봇이 같은 유형의 결과를 모아 다음 협상카드 선택에 참고합니다. + +
+
+ ); +} + +/* ── STEP 2 · 견적 ─────────────────────────────────────────── */ +type QuoteStatusTone = 'auto' | 'manual'; +const QUOTE_STATUS_CLS: Record = { + auto: 'bg-primary/10 text-primary', + manual: 'bg-muted text-muted-foreground', +}; + +function QuoteStatus({ children, tone }: { children: ReactNode; tone: QuoteStatusTone }) { + return ( + + {children} + + ); +} + +function QuoteLine({ ico, label, status, statusTone = 'manual', value, tip }: { ico: string; label: string; status?: ReactNode; statusTone?: QuoteStatusTone; value: ReactNode; tip: ReactNode }) { + return ( +
+ {ico} + + + {label} + {status && {status}} + + {value} + + {tip} +
+ ); +} + +export function QuoteStep() { + return ( +
+ + 상품을 고르면 목표가는 자동. 진행 방식을 고르고, 한 곳과 협상할 때만 낙찰 기준을 정해요. +
+
+
+
+ 견적서 + A4용지 협상 +
+
+ 견적번호 + EST-2026-014 +
+
+
+ 여러 후보가 있으면 여러 곳 경쟁(견적), 단골 한 곳을 더 깎고 싶으면 한 곳 협상. 협상카드 밀당은 '한 곳 협상'에서 돌아요.} /> + 협상에 부칠 상품과 견적을 보낼 협력사예요. 한 곳 협상은 이 협력사와 1:1로 진행됩니다.} /> + 목표가는 상품 기준 가격에서 자동으로 잡고, 앵커링가는 협상 완료 기준선이에요. 협력사 제시가가 앵커링가 이하로 내려오면 협상완료로 넘어갑니다.} /> + 한 곳 협상에서 봇이 쓸 협상카드를 고릅니다. 선택한 일반 협상카드를 모두 쓰거나 라운드 상한에 걸릴 때까지 앵커링가 이하로 내려오지 못하면 협상은 실패로 끝납니다.} /> + 협상이 끝났을 때 어디까지를 낙찰로 볼지 미리 정해요. 엄격(앵커링가까지)하면 더 싸게 사지만 낙찰이 덜 되고, 느슨(목표가까지)하면 낙찰은 잘 되는 대신 절감폭이 줄어요. (여러 곳 경쟁은 단독 최저가 자동 낙찰.)} /> +
+
+
+ + 여러 곳 경쟁 견적은 최저가 낙찰로 진행돼요. 한 곳 협상을 선택했을 때만 앵커링가까지 볼지, 목표가까지 볼지 낙찰 기준을 정합니다. + +
+
+
+ ); +} + +export function InviteStep() { + return ( +
+ + 여기가 제일 놓치기 쉬운 곳이에요. 견적을 만들었다고 협력사에게 자동으로 알려지는 건 아니에요. +
+ + + + + +
+
+ + 협력사는 견적이 생긴 줄도 몰라요. 혹시 깜빡해도 대시보드가 "아직 안 보낸 건"을 콕 집어 알려주니 거기서 바로 보내면 돼요. + +
+
+ ); +} + +/* ── 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 = { + 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 ( +
+
+
+ 협력사 제시가 + + {formatWon(offer)} + + + 슬라이더를 움직여 가격 구간별 봇 판단을 확인해보세요. + +
+
+ 봇 판단 + {zone.title} +
+
+ +
+ 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="협력사 제시가" + /> + + + + 앵커링가 + + + 목표가 + +
+ +
+ {zone.action} + {zone.reply} +
+ +
+ 그래도 가격을 안 낮추면? + + 선택한 협상카드를 다 쓰거나 라운드 상한에 걸릴 때까지 더 낮추지 못하면 협상은 실패로 끝나요. 이 경우는 협상완료·투찰 확정이 아니어서 낙찰 후보가 되지 않습니다. + +
+
+ ); +} + +export function NegotiationStep() { + return ( +
+ + 협력사가 가격을 부르면, 봇은 제시가가 어느 구간인지 보고 다음 행동을 고릅니다. + +
+ + 협상 완료선은 목표가가 아니라 앵커링가예요. 목표가 아래라도 앵커링가보다 높으면 봇은 한 번 더 낮추도록 유도합니다. + +
+
+ ); +} + +function CloseSignal({ Icon, title, desc }: { Icon: typeof Info; title: string; desc: string }) { + return ( +
+ + + +
+ {title} + {desc} +
+
+ ); +} + +function CompareModeCard({ title, badge, tone, children }: { title: string; badge: string; tone: 'success' | 'warning'; children: ReactNode }) { + const success = tone === 'success'; + return ( +
+
+ {title} + + {badge} + +
+
{children}
+
+ ); +} + +function CompareLine({ label, children, tone = 'neutral' }: { label: string; children: ReactNode; tone?: 'success' | 'warning' | 'neutral' }) { + const success = tone === 'success'; + const warning = tone === 'warning'; + return ( +
+ + {label} + + {children} +
+ ); +} + +function FollowupAction({ Icon, title, desc, tone }: { Icon: typeof Info; title: string; desc: ReactNode; tone: 'award' | 'regen' }) { + const regen = tone === 'regen'; + return ( +
+
+ + + +
+ {title} + {desc} +
+
+
+ ); +} + +/* ── STEP 5 · 마감/낙찰 ───────────────────────────────────── */ +export function AwardStep() { + return ( +
+ + 마감은 견적을 닫고 결과를 확정하는 순간이에요. 경쟁 견적과 1:1 협상은 낙찰을 판단하는 방식이 다릅니다. + +
+
+ + + +
+ 낙찰 확정 · A4용지 견적 +
+ 94,800원 + + ▼ 5,200원 절감 + 목표가 100,000원 대비 + +
+
+
+ 낙찰 협력사 + 우리제지 +
+
+
+ +
+ 이 결과가 정해지는 방식 +
+
+ 언제 닫히나요 +
+ + + +
+
+ +
+ 방식별 마감 결과 +
+ + + 투찰가가 있는 협력사 중 단독 최저가가 있으면 자동 낙찰 + + + 최저가가 동가이거나, 아무도 투찰하지 않거나, 전부 협상거부 + + + + + 협상완료 투찰가가 앵커링가까지 / 목표가까지 중 내가 고른 기준을 통과 + + + 기준 미달, 미응찰, 협상거부 + + +
+
+
+
+ + 최저가 동가·전원 미응찰·전부 협상거부·1:1 협상 기준 미달 케이스는 자동 낙찰하지 않고 개찰로 남아요. 이 중 동가·미응찰·가격 기준 미달처럼 다음 라운드가 필요한 경우에는 다음 견적이 자동 재생성돼요. + +
+
+ +
+ 개찰 후 처리 +
+ 투찰 완료 후보가 있으면 상세 화면에서 협력사를 고르고 낙찰 확정을 누릅니다.} + /> + 동가·미응찰·가격 기준 미달처럼 다시 견적이 필요한 경우 다음 라운드가 이어집니다.} + /> +
+
+ + 결과는 알림함으로 오고, 대시보드와 견적 상세에서 낙찰가·절감액·마감 사유를 다시 확인할 수 있어요. + +
+
+ +
+ + 상품 등록부터 낙찰 결과 확인까지, 여기까지가 전체 흐름이에요. +
+
+ ); +} diff --git a/negodata/front/src/features/onboarding/components/InfoTip.tsx b/negodata/front/src/features/onboarding/components/InfoTip.tsx new file mode 100644 index 0000000..87df430 --- /dev/null +++ b/negodata/front/src/features/onboarding/components/InfoTip.tsx @@ -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(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 ( + + + {open && ( + 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', + )} + > + + + + {children} + + + )} + + ); +} diff --git a/negodata/front/src/features/onboarding/components/OnboardingWizard.tsx b/negodata/front/src/features/onboarding/components/OnboardingWizard.tsx new file mode 100644 index 0000000..18eb6e1 --- /dev/null +++ b/negodata/front/src/features/onboarding/components/OnboardingWizard.tsx @@ -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 = { + 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(null); + + useEffect(() => { + contentRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); + }, [step]); + + const finish = () => { + markOnboardingSeen(); + navigate('/dashboard'); + }; + const next = () => (atEnd ? finish() : setStep((s) => s + 1)); + + return ( +
+ {/* 건너뛰기 */} +
+ +
+ + {/* 스텝퍼 */} +
+
+
+ +
+ {STEPS.map((s, i) => { + const active = i === step; + const done = i < step; + const must = s.tone === 'must'; + return ( + + ); + })} +
+
+ + {/* 콘텐츠 (내부 스크롤) */} +
+ +
+ + {/* 하단 네비 (고정) */} +
+ +
+ + {step === 0 ? '전체 흐름 · 한눈에' : `${step} / ${total - 1} 단계`} + + +
+
+ ); +} diff --git a/negodata/front/src/features/onboarding/components/OverviewStep.tsx b/negodata/front/src/features/onboarding/components/OverviewStep.tsx new file mode 100644 index 0000000..edeb12b --- /dev/null +++ b/negodata/front/src/features/onboarding/components/OverviewStep.tsx @@ -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 = { + 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(null); + const [drop, setDrop] = useState(null); + const displayPrice = useCountDown(priceNum); + const [speaking, setSpeaking] = useState(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 ( +
+
+ 0 +
+ + 전체 흐름 · 한눈에 + + 구매 협상이 어떻게 흘러가는지 먼저 봐요 +
+
+ + 준비부터 이메일 발송, 협상, 마감·낙찰까지 이어지는 흐름을 짧게 재생합니다. + + +
+ {/* 진행 칩 */} +
+ {CHIPS.map((c, i) => { + const active = phase === i; + return ( +
+ {c.icon} + {c.label} +
+ ); + })} +
+ + {/* 스테이지 */} +
+ {phase === 0 && ( +
+
+
+ 🤝 협상 · 1:1 + 한 곳과 밀당 +
+
+ 🏢 견적 · 1:N + 여러 곳 경쟁 +
+
+
+ 📍 + + 앵커링가 자동 산정 + 목표가보다 낮은 협상 완료 기준선 + + + {target} + +
+
+ )} + + {phase === 1 && ( +
+
✉️
+ 협력사에게 이메일을 발송했어요 +
+ )} + + {phase === 2 && ( +
+ {/* 제시가 바 — 이전값에서 새 제시가까지 숫자가 주르륵 내려간다 */} +
+ 상대회사 제시가 + {drop && ( + + {drop} + + )} + + {displayPrice != null ? `${displayPrice.toLocaleString('en-US')}원` : '—'} + +
+ {/* 대화 (아바타 + 현재 말풍선) */} +
+ + + {cur && ( +
+ {cur.side === 'bot' ? ( +
+ {cur.card && ( + + {CARD_TAG[cur.card].label} + + )} + {cur.text} +
+ ) : ( +
+ {cur.text} +
+ )} +
+ )} +
+
+ )} + + {phase === 3 && ( +
+ + {stamp && ( +
+ 낙찰! +
+ )} +
+ )} +
+ +
+ {replay && ( + + )} +
+ + 다음을 누르면 각 단계를 하나씩 자세히 볼게요 ↓ + +
+
+ ); +} + +// 낙찰 씬 트랙 — 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 ( +
+
+
+ 협상 결과 · 투찰가 + 94,800원 +
+ + 낙찰 구간 + +
+ +
+
+ {/* 구간 색: 낙찰 구간(목표가 이하) emerald · 그 위 amber */} +
+ + +
+ + +
+ +
+
+ 앵커링가 + 목표가 +
+ + + 투찰가가 목표가 아래 낙찰 구간으로 들어왔어요 + +
+ ); +} + +// 제시가가 바뀔 때 이전 값에서 새 값까지 숫자가 주르륵 내려가는 카운트다운. 첫 등장(이전값 없음)은 스냅. +function useCountDown(target: number | null, duration = 700) { + const [display, setDisplay] = useState(null); + const fromRef = useRef(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 ( +
+
+ {isBot ? '🤖' : '🏢'} +
+ {isBot ? '협상봇' : '상대회사'} +
+ ); +} diff --git a/negodata/front/src/features/onboarding/components/PriceSpectrum.tsx b/negodata/front/src/features/onboarding/components/PriceSpectrum.tsx new file mode 100644 index 0000000..869b5af --- /dev/null +++ b/negodata/front/src/features/onboarding/components/PriceSpectrum.tsx @@ -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 = { + 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 = { + 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 ( +
+ {caps && ( +
+ {caps.map((c, i) => ( + + {c.label} + + ))} +
+ )} + +
+ {zones.map((z, i) => ( + + ))} + + {marks?.map((m, i) => ( + + + {m.label} + + + ))} + + {bid && ( + + + + {bid.label} + + + )} +
+ + {foot && ( +
+ 싸다 + {foot} + 비싸다 +
+ )} + + {caption && ( + + {caption} + + )} +
+ ); +} diff --git a/negodata/front/src/features/onboarding/steps.ts b/negodata/front/src/features/onboarding/steps.ts index e6e6c61..ab3c5cb 100644 --- a/negodata/front/src/features/onboarding/steps.ts +++ b/negodata/front/src/features/onboarding/steps.ts @@ -74,9 +74,9 @@ export const STEPS: Step[] = [ actor: '시스템', actorType: 'system', icon: Award, - short: '단독최저+기준통과=낙찰 / 그 외=개찰(낙찰자 미정)', + short: '경쟁 견적=단독 최저 낙찰 / 1:1 협상=기준 적용', detail: - '단독 최저가가 견적의 낙찰 기준을 통과하면 그 협력사로 낙찰됩니다. 기준을 못 넘기거나 동가·전원 미응찰·협상거부인 경우는 결렬이 아니라 개찰 — 낙찰자 미정으로 마감되고, 다음 라운드는 담당자가 상세에서 수동으로 만듭니다. 결과는 알림함으로 통지되고 대시보드에서 현황을 확인합니다.', + '경쟁 견적은 투찰가가 있는 협력사 중 단독 최저가가 있으면 낙찰됩니다. 1:1 협상은 앵커링가까지·목표가까지 같은 낙찰 기준을 적용합니다. 동가·전원 미응찰·가격 기준 미달은 다음 견적이 자동 재생성되고, 협상거부는 개찰 — 낙찰자 미정으로 남습니다. 결과는 알림함으로 통지되고 대시보드에서 확인합니다.', }, ]; diff --git a/negodata/front/src/features/onboarding/storage.ts b/negodata/front/src/features/onboarding/storage.ts new file mode 100644 index 0000000..3d2ada6 --- /dev/null +++ b/negodata/front/src/features/onboarding/storage.ts @@ -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 { + // 저장 실패는 무시 — 다음 로그인에 다시 뜰 뿐 + } +} diff --git a/negodata/front/src/features/quotations/types.ts b/negodata/front/src/features/quotations/types.ts index e84fb3b..31bbe0d 100644 --- a/negodata/front/src/features/quotations/types.ts +++ b/negodata/front/src/features/quotations/types.ts @@ -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 '목표가 초과도 낙찰'; diff --git a/negodata/front/src/pages/onboarding.tsx b/negodata/front/src/pages/onboarding.tsx new file mode 100644 index 0000000..8a4a5f4 --- /dev/null +++ b/negodata/front/src/pages/onboarding.tsx @@ -0,0 +1,10 @@ +import { OnboardingWizard } from '@/features/onboarding/components/OnboardingWizard'; + +// 신규 유저 온보딩 — 앱 레이아웃(사이드바) 없이 독립 풀스크린 페이지. 등록~낙찰 전체 흐름 안내. +export default function OnboardingPage() { + return ( +
+ +
+ ); +}