From 615167ad41158883428bad4ab74e27ee9bf78ede Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Tue, 30 Jun 2026 17:25:15 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20negodata/front:=20=EA=B2=AC=EC=A0=81?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20UX=20=EA=B0=9C=EC=84=A0=20+=20=EC=98=A8?= =?UTF-8?q?=EB=B3=B4=EB=94=A9=20=EA=B2=AC=EC=A0=81=EC=9C=A0=ED=98=95=20?= =?UTF-8?q?=EC=95=88=EB=82=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 견적 생성: 협력사 유형 전 유형 입력(항상 노출), 명칭 협상견적→견적, 단계 인디케이터 확대·클릭 이동, 라벨 통일 - 온보딩 가이드: 견적 유형 차이 안내 탭(목표가 산정·협력사 수, MD 제시가 우선 강조) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../onboarding/OnboardingGuideModal.tsx | 390 ++++++++++++++++++ .../front/src/features/onboarding/steps.ts | 87 ++++ .../components/QuotationCreateModal.tsx | 76 ++-- 3 files changed, 517 insertions(+), 36 deletions(-) create mode 100644 negodata/front/src/features/onboarding/OnboardingGuideModal.tsx create mode 100644 negodata/front/src/features/onboarding/steps.ts diff --git a/negodata/front/src/features/onboarding/OnboardingGuideModal.tsx b/negodata/front/src/features/onboarding/OnboardingGuideModal.tsx new file mode 100644 index 0000000..21ae9bd --- /dev/null +++ b/negodata/front/src/features/onboarding/OnboardingGuideModal.tsx @@ -0,0 +1,390 @@ +import { useState, type ElementType } from 'react'; +import { LayoutDashboard, FileText, Mail, Users, Clock, Award, RefreshCw, XCircle } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Typography } from '@/components/ui/typography'; +import { cn } from '@/lib/utils'; + +// 신규 유저용 프로세스 안내. '흐름 설명'(6단계)과 '마감 결과'(낙찰/재생성/결렬 + 사유) 두 뷰. +// 읽기 전용 정적 안내(테이블/서버 없음). 첫 방문 자동 노출 + 대시보드 버튼으로 재오픈은 호출부(대시보드)에서 제어. + +type Actor = 'user' | 'partner' | 'system'; + +interface Step { + num: number; + title: string; + actor: string; + actorType: Actor; + short: string; + detail: string; + icon: ElementType; + highlight?: boolean; // 초청메일: 수동 발송 안 하면 협상이 시작 안 됨 → 강조 +} + +const STEPS: Step[] = [ + { + num: 1, + title: '준비', + actor: '나', + actorType: 'user', + icon: LayoutDashboard, + short: '상품·협력사·협상카드 등록', + detail: + '협상에 부칠 상품(가격 기준: 인터넷최저가·매입가·판매가 중 최소 하나)과 견적을 보낼 협력사(담당자 이메일 필수)를 먼저 등록합니다. 협상 멘트 전략인 협상카드는 기본 제공 카드는 제외되고, 직접 만든 카드만 목록에 떠 견적에 사용할 수 있습니다.', + }, + { + num: 2, + title: '견적 생성', + actor: '나', + actorType: 'user', + icon: FileText, + short: '유형 선택·목표가 자동 산출', + detail: + '상품과 협력사를 묶어 견적을 만들면 상품×협력사 조합마다 협상 세션이 생기고, 상품 가격 기준으로 목표가가 자동 산정됩니다. 이 단계까지는 협력사에게 아무것도 공개되지 않습니다.', + }, + { + num: 3, + title: '초청메일', + actor: '나', + actorType: 'user', + icon: Mail, + highlight: true, + short: '메일을 보내야 협상이 시작됨', + detail: + '견적 생성 후 직접 초청메일을 발송해야 협력사에게 투찰 링크가 전달되고 협상이 실제로 시작됩니다. 메일을 보내지 않으면 협력사는 견적이 생긴 줄도 모르고 아무 일도 일어나지 않습니다. 누락 건은 대시보드의 "메일 미발송" 위젯에서 바로 잡을 수 있습니다.', + }, + { + num: 4, + title: '협상', + actor: '협력사', + actorType: 'partner', + icon: Users, + short: '협력사가 가격을 투찰', + detail: + '초청메일을 받은 협력사가 전용 채팅방에 입장해 AI 협상카드의 앵커링 응대에 따라 가격을 투찰합니다. 진행 현황은 견적 상세에서 실시간으로 볼 수 있습니다.', + }, + { + num: 5, + title: '마감', + actor: '나/자동', + actorType: 'system', + icon: Clock, + short: '기한 도달 또는 전원 완료 시', + detail: + '설정한 마감 시각에 도달하거나 초청한 협력사가 전원 투찰을 마치면 자동으로 마감되어 판정에 들어갑니다. 필요하면 견적 상세에서 직접 수동 마감할 수도 있습니다.', + }, + { + num: 6, + title: '낙찰 판정', + actor: '시스템', + actorType: 'system', + icon: Award, + short: '단독최저=낙찰 / 동가·미참여=재생성 / 결렬', + detail: + '단독 최저가면 그 협력사로 낙찰됩니다. 최저가가 둘 이상 같은 동가이거나 전원 미참여면 다음 차수가 자동 재생성되고, 거절·한도 등으로 낙찰자가 없으면 결렬 처리됩니다. 결과는 알림함으로 통지되고 대시보드에서 현황을 확인합니다.', + }, +]; + +const ACTOR_CLASS: Record = { + user: 'text-primary', + partner: 'text-emerald-600 dark:text-emerald-400', + system: 'text-muted-foreground', +}; + +// ----- 견적 마감 결과(close_and_decide 기준): 낙찰 / 재생성(동가·미참여) / 결렬(거부·한도). 모두 알림함 통지 ----- +type Tone = 'win' | 'tie' | 'fail'; + +interface ResultCase { + key: string; + label: string; + tone: Tone; + icon: ElementType; + causes: { tag: string; desc: string }[]; + outcome: string; +} + +const RESULT_CASES: ResultCase[] = [ + { + key: 'win', + label: '낙찰', + tone: 'win', + icon: Award, + causes: [{ tag: '단독 최저가', desc: '협상완료 중 최저가를 낸 협력사가 한 곳뿐' }], + outcome: '그 협력사로 낙찰 확정.', + }, + { + key: 'regen', + label: '재생성 · 다음 차수', + tone: 'tie', + icon: RefreshCw, + causes: [ + { tag: '동가', desc: '최저가가 2개사 이상 동일 → 동가 업체끼리 다음 차수' }, + { tag: '전원 미참여', desc: '아무도 투찰 안 함 → 원 공급사 전체로 다음 차수' }, + ], + outcome: '해당 차수는 낙찰자 없이 마감되고 다음 차수가 자동 생성됩니다. (사유별 1회씩, 최대 2회까지)', + }, + { + key: 'fail', + label: '결렬 · 그냥 마감', + tone: 'fail', + icon: XCircle, + causes: [ + { tag: '협상거부', desc: '거부한 협력사가 하나라도 있음 → 재생성 없이 마감' }, + { tag: '재생성 한도', desc: '동가·미참여 재생성을 이미 써서 더 못 돌림' }, + ], + outcome: '낙찰자 없이 협상이 종료됩니다.', + }, +]; + +const TONE_BORDER: Record = { + win: 'border-emerald-500/40', + tie: 'border-amber-500/40', + fail: 'border-destructive/40', +}; +const TONE_TEXT: Record = { + win: 'text-emerald-600 dark:text-emerald-400', + tie: 'text-amber-600 dark:text-amber-400', + fail: 'text-destructive', +}; +const TONE_CHIP: Record = { + win: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', + tie: 'bg-amber-500/10 text-amber-600 dark:text-amber-400', + fail: 'bg-destructive/10 text-destructive', +}; + +// ----- 견적 유형(4종): 두 축으로 갈림 — 신규/재(목표가 산정 후보) × 협상 1:1 / 견적 1:N(부르는 협력사 수) ----- +interface QtType { + key: string; + label: string; + isNew: boolean; + scope: string; // 1:1 / 1:N + suppliers: string; // 부르는 협력사 수 + basis: string; // 목표가 산정 기준 +} + +const QUOTATION_TYPES: QtType[] = [ + { key: 'new_quote', label: '신규견적', isNew: true, scope: '1:N', suppliers: '여러 협력사', basis: '인터넷최저가 기준' }, + { key: 'new_nego', label: '신규협상', isNew: true, scope: '1:1', suppliers: '단일 협력사', basis: '인터넷최저가 기준' }, + { key: 'requote', label: '재견적', isNew: false, scope: '1:N', suppliers: '여러 협력사', basis: '인터넷최저·매입·판매 중 최소' }, + { key: 'renego', label: '재협상', isNew: false, scope: '1:1', suppliers: '단일 협력사', basis: '인터넷최저·매입·판매 중 최소' }, +]; + +const TAB_LABEL = { flow: '흐름 설명', type: '견적 유형', example: '마감 결과' } as const; + +export function OnboardingGuideModal({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [tab, setTab] = useState<'flow' | 'type' | 'example'>('flow'); + const [activeNum, setActiveNum] = useState(1); + const active = STEPS.find((s) => s.num === activeNum) ?? STEPS[0]; + + return ( + + + + + + 협상은 이렇게 진행됩니다 + + + + + 상품·협력사 등록부터 자동 협상 낙찰까지의 흐름과, 마감 결과 케이스를 확인하세요. + + + + + {/* 뷰 전환 탭 */} +
+ {(['flow', 'type', 'example'] as const).map((t) => ( + + ))} +
+ + {tab === 'flow' && ( + <> + {/* 6단계 흐름 — 누르면 아래 상세가 바뀜 */} +
+ {STEPS.map((s) => { + const Icon = s.icon; + const isActive = s.num === activeNum; + return ( + + ); + })} +
+ + {/* 선택 단계 상세 설명 */} +
+
+ + {`0${active.num}. ${active.title}`} + + + [{active.actor}] + +
+ {active.detail} +
+ + )} + + {tab === 'type' && ( +
+
+ 견적 유형에 따라 이렇게 달라집니다 + 4종 +
+ + 유형은 두 축으로 갈립니다 — 신규/재(목표가 산정 기준)와 협상(1:1)/견적(1:N)(부르는 협력사 수). + + + + MD 제시가를 넣으면 유형과 무관하게 그 값이 목표가 최우선입니다. + + +
+ {QUOTATION_TYPES.map((t) => ( +
+
+ {t.label} + {t.scope} +
+
+
+ 협력사 + {t.suppliers} +
+
+ 목표가 산정 + {t.basis} +
+
+
+ ))} +
+ +
+
+ 신규 / 재 + + 신규견적·신규협상은 인터넷최저가만으로 목표가를 잡고, 재견적·재협상은 매입가·판매가까지 후보에 넣어 그중 최소값을 씁니다. + +
+
+ 협상 / 견적 + + 협상은 한 곳과 1:1, 견적은 여러 곳과 1:N. + +
+
+
+ )} + + {tab === 'example' && ( +
+
+ 견적 마감 결과는 이렇게 나뉩니다 + 예시 +
+ + 협력사 투찰 양상에 따라 마감 결과는 낙찰 / 재생성 / 결렬 셋으로 갈리며, 모든 결과는 알림함으로 통지됩니다. + + +
+ {RESULT_CASES.map((c) => { + const Icon = c.icon; + return ( +
+
+ + + + + {c.label} + +
+ +
+ {c.causes.map((cause) => ( +
+ + {cause.tag} + + + {cause.desc} + +
+ ))} +
+ + + → {c.outcome} + +
+ ); + })} +
+
+ )} + + + + +
+
+ ); +} diff --git a/negodata/front/src/features/onboarding/steps.ts b/negodata/front/src/features/onboarding/steps.ts new file mode 100644 index 0000000..5761836 --- /dev/null +++ b/negodata/front/src/features/onboarding/steps.ts @@ -0,0 +1,87 @@ +import { LayoutDashboard, FileText, Mail, Users, Clock, Award, type LucideIcon } from 'lucide-react'; + +// 견적 생성~낙찰까지 6단계 흐름. 온보딩 가이드 모달과 대시보드 라이프사이클 스텝퍼가 공유한다. +// 단일 출처로 두 곳이 어긋나지 않게 한다. + +export type Actor = 'user' | 'partner' | 'system'; + +export interface Step { + num: number; + title: string; + actor: string; + actorType: Actor; + short: string; + detail: string; + icon: LucideIcon; + highlight?: boolean; // 초청메일: 수동 발송 안 하면 협상이 시작 안 됨 → 강조 +} + +export const STEPS: Step[] = [ + { + num: 1, + title: '준비', + actor: '나', + actorType: 'user', + icon: LayoutDashboard, + short: '상품·협력사·협상카드 등록', + detail: + '협상에 부칠 상품(가격 기준: 인터넷최저가·매입가·판매가 중 최소 하나)과 견적을 보낼 협력사(담당자 이메일 필수)를 먼저 등록합니다. 협상 멘트 전략인 협상카드는 기본 제공 카드는 제외되고, 직접 만든 카드만 목록에 떠 견적에 사용할 수 있습니다.', + }, + { + num: 2, + title: '견적 생성', + actor: '나', + actorType: 'user', + icon: FileText, + short: '유형 선택·목표가 자동 산출', + detail: + '상품과 협력사를 묶어 견적을 만들면 상품×협력사 조합마다 협상 세션이 생기고, 상품 가격 기준으로 목표가가 자동 산정됩니다. 이 단계까지는 협력사에게 아무것도 공개되지 않습니다.', + }, + { + num: 3, + title: '초청메일', + actor: '나', + actorType: 'user', + icon: Mail, + highlight: true, + short: '메일을 보내야 협상이 시작됨', + detail: + '견적 생성 후 직접 초청메일을 발송해야 협력사에게 투찰 링크가 전달되고 협상이 실제로 시작됩니다. 메일을 보내지 않으면 협력사는 견적이 생긴 줄도 모르고 아무 일도 일어나지 않습니다. 누락 건은 대시보드의 "메일 미발송" 위젯에서 바로 잡을 수 있습니다.', + }, + { + num: 4, + title: '협상', + actor: '협력사', + actorType: 'partner', + icon: Users, + short: '협력사가 가격을 투찰', + detail: + '초청메일을 받은 협력사가 전용 채팅방에 입장해 AI 협상카드의 앵커링 응대에 따라 가격을 투찰합니다. 진행 현황은 견적 상세에서 실시간으로 볼 수 있습니다.', + }, + { + num: 5, + title: '마감', + actor: '나/자동', + actorType: 'system', + icon: Clock, + short: '기한 도달 또는 전원 완료 시', + detail: + '설정한 마감 시각에 도달하거나 초청한 협력사가 전원 투찰을 마치면 자동으로 마감되어 판정에 들어갑니다. 필요하면 견적 상세에서 직접 수동 마감할 수도 있습니다.', + }, + { + num: 6, + title: '낙찰 판정', + actor: '시스템', + actorType: 'system', + icon: Award, + short: '단독최저=낙찰 / 동가·미참여=재생성 / 결렬', + detail: + '단독 최저가면 그 협력사로 낙찰됩니다. 최저가가 둘 이상 같은 동가이거나 전원 미참여면 다음 차수가 자동 재생성되고, 거절·한도 등으로 낙찰자가 없으면 결렬 처리됩니다. 결과는 알림함으로 통지되고 대시보드에서 현황을 확인합니다.', + }, +]; + +export const ACTOR_CLASS: Record = { + user: 'text-primary', + partner: 'text-emerald-600 dark:text-emerald-400', + system: 'text-muted-foreground', +}; diff --git a/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx index 05359ce..ac53594 100644 --- a/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx +++ b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx @@ -50,11 +50,11 @@ export function QuotationCreateModal({ const [selectedCardIds, setSelectedCardIds] = useState([]); const [memo, setMemo] = useState(''); const [mdPrice, setMdPrice] = useState(''); // MD 제시가(원). 비우면 미전송 → 서버가 상품값으로 목표가 산정 - const [supplierType, setSupplierType] = useState(''); // 협력사 유형(SupplierType). 재견적 1:1 → 견적에 기록 + const [supplierType, setSupplierType] = useState(''); // 협력사 유형(SupplierType). 전 유형에서 입력 → 견적에 기록 const [submitting, setSubmitting] = useState(false); const typeOptions = QUOTATION_TYPE_OPTIONS; - // 협력사 유형은 재협상(1:1)에서만. 선택된 협력사의 직전 견적 supplier_type 을 조회해 디폴트로 채운다. + // 협력사 유형은 전 유형에서 입력받되, 재협상(1:1)이면 선택 협력사의 직전 견적 supplier_type 을 조회해 디폴트로 채운다. const renegoSupplierId = type === QuotationType.RENEGO ? (selectedPartnerIds[0] ?? '') : ''; const lastTypeQuery = useGetSupplierLastType(renegoSupplierId, { query: { enabled: !!renegoSupplierId }, @@ -127,7 +127,7 @@ export function QuotationCreateModal({
- 협상견적 생성 중… + 견적 생성 중… 견적 · 협상 세션 등록 중
@@ -138,7 +138,7 @@ export function QuotationCreateModal({
- 신규 협상견적 등록 (단계 {step}/3) + 신규 견적 등록 (단계 {step}/3)
{/* Steps indicator */} -
- = 1 ? 'text-primary' : 'text-muted-foreground'}`}>1. 기본 정보 - - = 2 ? 'text-primary' : 'text-muted-foreground'}`}>2. 협력사 초청 - - = 3 ? 'text-primary' : 'text-muted-foreground'}`}>3. 설정·완료 +
+ + + + +
{/* Step content */} @@ -290,7 +296,7 @@ export function QuotationCreateModal({ {step === 2 && (
- 협력사 초청 ({type === QuotationType.RENEGO ? '단일선택' : '다중선택'}) + 협력사 초청 ({type === QuotationType.RENEGO ? '단일선택' : '다중선택'})
{partners.map((part) => { const isChecked = selectedPartnerIds.includes(part.id ?? ''); @@ -323,29 +329,27 @@ export function QuotationCreateModal({ })}
- {/* 협력사 유형 — 재협상(1:1)에서 협력사 선택 후 노출. 직전 견적 값 자동 디폴트. */} - {type === QuotationType.RENEGO && selectedPartnerIds.length > 0 && ( -
- 협력사 유형 - - {prevSupplierType != null && ( - - 이전 견적({prevQtNumber}) 값으로 자동 선택됨 · 수정 가능 - - )} -
- )} + {/* 협력사 유형 — 항상 노출(처음부터 입력 가능). 재협상(1:1)이면 선택 협력사의 직전 견적 값으로 자동 디폴트. */} +
+ 협력사 유형 + + {prevSupplierType != null && ( + + 이전 견적({prevQtNumber}) 값으로 자동 선택됨 · 수정 가능 + + )} +
)} @@ -375,7 +379,7 @@ export function QuotationCreateModal({
- 협상카드 및 와일드카드 선택 + 협상카드 및 와일드카드 선택
{cards.filter((c) => !c.isWildcard || c.status === 'ACTIVE').map((card) => { const isChecked = selectedCardIds.includes(card.id); @@ -452,7 +456,7 @@ export function QuotationCreateModal({ ) : ( )}