[feat] negodata/front: 견적 생성 UX 개선 + 온보딩 견적유형 안내
- 견적 생성: 협력사 유형 전 유형 입력(항상 노출), 명칭 협상견적→견적, 단계 인디케이터 확대·클릭 이동, 라벨 통일 - 온보딩 가이드: 견적 유형 차이 안내 탭(목표가 산정·협력사 수, MD 제시가 우선 강조) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1d111a031e
commit
615167ad41
390
negodata/front/src/features/onboarding/OnboardingGuideModal.tsx
Normal file
390
negodata/front/src/features/onboarding/OnboardingGuideModal.tsx
Normal file
@ -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<Actor, string> = {
|
||||
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<Tone, string> = {
|
||||
win: 'border-emerald-500/40',
|
||||
tie: 'border-amber-500/40',
|
||||
fail: 'border-destructive/40',
|
||||
};
|
||||
const TONE_TEXT: Record<Tone, string> = {
|
||||
win: 'text-emerald-600 dark:text-emerald-400',
|
||||
tie: 'text-amber-600 dark:text-amber-400',
|
||||
fail: 'text-destructive',
|
||||
};
|
||||
const TONE_CHIP: Record<Tone, string> = {
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[88vh] overflow-y-auto sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Typography as="span" variant="h3">
|
||||
협상은 이렇게 진행됩니다
|
||||
</Typography>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Typography as="span" variant="muted">
|
||||
상품·협력사 등록부터 자동 협상 낙찰까지의 흐름과, 마감 결과 케이스를 확인하세요.
|
||||
</Typography>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 뷰 전환 탭 */}
|
||||
<div className="flex w-fit gap-1 rounded-lg bg-muted p-1">
|
||||
{(['flow', 'type', 'example'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setTab(t)}
|
||||
className={cn('rounded px-3 py-1 transition-colors', tab === t ? 'bg-background shadow-sm' : 'hover:bg-background/50')}
|
||||
>
|
||||
<Typography as="span" variant="small" className={cn('font-semibold', tab !== t && 'text-muted-foreground')}>
|
||||
{TAB_LABEL[t]}
|
||||
</Typography>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'flow' && (
|
||||
<>
|
||||
{/* 6단계 흐름 — 누르면 아래 상세가 바뀜 */}
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{STEPS.map((s) => {
|
||||
const Icon = s.icon;
|
||||
const isActive = s.num === activeNum;
|
||||
return (
|
||||
<button
|
||||
key={s.num}
|
||||
type="button"
|
||||
onClick={() => setActiveNum(s.num)}
|
||||
className={cn(
|
||||
'flex flex-col gap-1.5 rounded-lg border p-3 text-left transition-colors',
|
||||
s.highlight ? 'border-destructive/60' : 'border-border',
|
||||
isActive
|
||||
? s.highlight
|
||||
? 'bg-destructive/5 ring-2 ring-destructive/20'
|
||||
: 'bg-primary/5 ring-2 ring-primary/20'
|
||||
: 'hover:bg-muted/50',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant={s.highlight ? 'destructive' : 'secondary'}>{`0${s.num}`}</Badge>
|
||||
<Typography as="span" variant="caption" className={ACTOR_CLASS[s.actorType]}>
|
||||
[{s.actor}]
|
||||
</Typography>
|
||||
</div>
|
||||
<Icon
|
||||
size={18}
|
||||
className={s.highlight ? 'text-destructive' : isActive ? 'text-primary' : 'text-muted-foreground'}
|
||||
/>
|
||||
<Typography variant="small" className={cn('font-semibold', s.highlight && 'text-destructive')}>
|
||||
{s.title}
|
||||
</Typography>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 선택 단계 상세 설명 */}
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border p-4',
|
||||
active.highlight ? 'border-destructive/40 bg-destructive/5' : 'border-border bg-muted/30',
|
||||
)}
|
||||
>
|
||||
<div className="mb-1.5 flex items-center gap-2">
|
||||
<Typography variant="h4" className={active.highlight ? 'text-destructive' : undefined}>
|
||||
{`0${active.num}. ${active.title}`}
|
||||
</Typography>
|
||||
<Typography as="span" variant="caption" className={ACTOR_CLASS[active.actorType]}>
|
||||
[{active.actor}]
|
||||
</Typography>
|
||||
</div>
|
||||
<Typography variant="muted">{active.detail}</Typography>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'type' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Typography variant="h4">견적 유형에 따라 이렇게 달라집니다</Typography>
|
||||
<Badge variant="outline">4종</Badge>
|
||||
</div>
|
||||
<Typography variant="muted">
|
||||
유형은 두 축으로 갈립니다 — 신규/재(목표가 산정 기준)와 협상(1:1)/견적(1:N)(부르는 협력사 수).
|
||||
</Typography>
|
||||
|
||||
<Typography variant="small" className="font-bold">
|
||||
MD 제시가를 넣으면 유형과 무관하게 그 값이 목표가 최우선입니다.
|
||||
</Typography>
|
||||
|
||||
<div className="grid gap-3 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 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>
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'example' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Typography variant="h4">견적 마감 결과는 이렇게 나뉩니다</Typography>
|
||||
<Badge variant="outline">예시</Badge>
|
||||
</div>
|
||||
<Typography variant="muted">
|
||||
협력사 투찰 양상에 따라 마감 결과는 낙찰 / 재생성 / 결렬 셋으로 갈리며, 모든 결과는 알림함으로 통지됩니다.
|
||||
</Typography>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{RESULT_CASES.map((c) => {
|
||||
const Icon = c.icon;
|
||||
return (
|
||||
<div key={c.key} className={cn('flex flex-col gap-2 rounded-xl border p-3', TONE_BORDER[c.tone])}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('flex size-7 items-center justify-center rounded-lg', TONE_CHIP[c.tone])}>
|
||||
<Icon size={15} />
|
||||
</span>
|
||||
<Typography as="span" variant="small" className={cn('font-semibold', TONE_TEXT[c.tone])}>
|
||||
{c.label}
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{c.causes.map((cause) => (
|
||||
<div key={cause.tag} className="rounded-lg bg-muted/40 p-2.5">
|
||||
<Typography as="span" variant="small" className="font-semibold">
|
||||
{cause.tag}
|
||||
</Typography>
|
||||
<Typography variant="caption" className="mt-0.5 block leading-relaxed">
|
||||
{cause.desc}
|
||||
</Typography>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Typography variant="caption" className="mt-auto block leading-relaxed">
|
||||
→ {c.outcome}
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onOpenChange(false)}>시작하기</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
87
negodata/front/src/features/onboarding/steps.ts
Normal file
87
negodata/front/src/features/onboarding/steps.ts
Normal file
@ -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<Actor, string> = {
|
||||
user: 'text-primary',
|
||||
partner: 'text-emerald-600 dark:text-emerald-400',
|
||||
system: 'text-muted-foreground',
|
||||
};
|
||||
@ -50,11 +50,11 @@ export function QuotationCreateModal({
|
||||
const [selectedCardIds, setSelectedCardIds] = useState<string[]>([]);
|
||||
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({
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl bg-card px-8 py-6 shadow-2xl border border-border">
|
||||
<Loader2 className="text-primary animate-spin" size={44} strokeWidth={2.5} />
|
||||
<Typography variant="small" className="font-semibold font-mono">협상견적 생성 중…</Typography>
|
||||
<Typography variant="small" className="font-semibold font-mono">견적 생성 중…</Typography>
|
||||
<Typography variant="small" className="text-muted-foreground font-mono">견적 · 협상 세션 등록 중</Typography>
|
||||
</div>
|
||||
</div>
|
||||
@ -138,7 +138,7 @@ export function QuotationCreateModal({
|
||||
<div className="flex items-center justify-between pb-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<PlusSquare className="text-foreground" size={18} />
|
||||
<Typography variant="small" className="font-bold">신규 협상견적 등록 (단계 {step}/3)</Typography>
|
||||
<Typography variant="small" className="font-bold">신규 견적 등록 (단계 {step}/3)</Typography>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 rounded text-muted-foreground hover:bg-muted cursor-pointer">
|
||||
<X size={18} />
|
||||
@ -146,12 +146,18 @@ export function QuotationCreateModal({
|
||||
</div>
|
||||
|
||||
{/* Steps indicator */}
|
||||
<div className="flex items-center justify-between gap-2 py-4 border-b border-border/40 text-[10px] text-muted-foreground">
|
||||
<Typography as="span" variant="label" className={`font-semibold ${step >= 1 ? 'text-primary' : 'text-muted-foreground'}`}>1. 기본 정보</Typography>
|
||||
<ArrowRight size={10} />
|
||||
<Typography as="span" variant="label" className={`font-semibold ${step >= 2 ? 'text-primary' : 'text-muted-foreground'}`}>2. 협력사 초청</Typography>
|
||||
<ArrowRight size={10} />
|
||||
<Typography as="span" variant="label" className={`font-semibold ${step >= 3 ? 'text-primary' : 'text-muted-foreground'}`}>3. 설정·완료</Typography>
|
||||
<div className="flex items-center justify-between gap-2 py-4 border-b border-border/40 text-muted-foreground">
|
||||
<button type="button" onClick={() => setStep(1)} className="cursor-pointer hover:opacity-75 transition-opacity">
|
||||
<Typography as="span" variant="body" className={`font-semibold ${step >= 1 ? 'text-primary' : 'text-muted-foreground'}`}>1. 기본 정보</Typography>
|
||||
</button>
|
||||
<ArrowRight size={14} className="shrink-0" />
|
||||
<button type="button" onClick={() => setStep(2)} className="cursor-pointer hover:opacity-75 transition-opacity">
|
||||
<Typography as="span" variant="body" className={`font-semibold ${step >= 2 ? 'text-primary' : 'text-muted-foreground'}`}>2. 협력사 초청</Typography>
|
||||
</button>
|
||||
<ArrowRight size={14} className="shrink-0" />
|
||||
<button type="button" onClick={() => setStep(3)} className="cursor-pointer hover:opacity-75 transition-opacity">
|
||||
<Typography as="span" variant="body" className={`font-semibold ${step >= 3 ? 'text-primary' : 'text-muted-foreground'}`}>3. 설정·완료</Typography>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
@ -290,7 +296,7 @@ export function QuotationCreateModal({
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-3">
|
||||
<Typography as="span" variant="small" className="font-semibold block">협력사 초청 ({type === QuotationType.RENEGO ? '단일선택' : '다중선택'})</Typography>
|
||||
<Typography as="span" variant="label" className="block">협력사 초청 ({type === QuotationType.RENEGO ? '단일선택' : '다중선택'})</Typography>
|
||||
<div className="border border-border rounded overflow-hidden max-h-56 overflow-y-auto divide-y divide-border bg-background">
|
||||
{partners.map((part) => {
|
||||
const isChecked = selectedPartnerIds.includes(part.id ?? '');
|
||||
@ -323,29 +329,27 @@ export function QuotationCreateModal({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 협력사 유형 — 재협상(1:1)에서 협력사 선택 후 노출. 직전 견적 값 자동 디폴트. */}
|
||||
{type === QuotationType.RENEGO && selectedPartnerIds.length > 0 && (
|
||||
<div className="space-y-1 pt-3 border-t border-border/40">
|
||||
<Typography as="label" variant="label">협력사 유형</Typography>
|
||||
<Select value={supplierType} onValueChange={(v) => setSupplierType(v ?? '')}>
|
||||
<SelectTrigger id="wizard-supplier-type" className="w-full">
|
||||
<SelectValue>
|
||||
{(value) => (value ? supplierTypeLabel(Number(value)) : '협력사 유형 선택...')}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{supplierTypeOptions.map((o) => (
|
||||
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{prevSupplierType != null && (
|
||||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">
|
||||
이전 견적({prevQtNumber}) 값으로 자동 선택됨 · 수정 가능
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* 협력사 유형 — 항상 노출(처음부터 입력 가능). 재협상(1:1)이면 선택 협력사의 직전 견적 값으로 자동 디폴트. */}
|
||||
<div className="space-y-1 pt-3 border-t border-border/40">
|
||||
<Typography as="label" variant="label">협력사 유형</Typography>
|
||||
<Select value={supplierType} onValueChange={(v) => setSupplierType(v ?? '')}>
|
||||
<SelectTrigger id="wizard-supplier-type" className="w-full">
|
||||
<SelectValue>
|
||||
{(value) => (value ? supplierTypeLabel(Number(value)) : '협력사 유형 선택...')}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{supplierTypeOptions.map((o) => (
|
||||
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{prevSupplierType != null && (
|
||||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">
|
||||
이전 견적({prevQtNumber}) 값으로 자동 선택됨 · 수정 가능
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -375,7 +379,7 @@ export function QuotationCreateModal({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Typography as="span" variant="small" className="font-semibold block">협상카드 및 와일드카드 선택</Typography>
|
||||
<Typography as="span" variant="label" className="block">협상카드 및 와일드카드 선택</Typography>
|
||||
<div className="grid grid-cols-2 gap-2 max-h-48 overflow-y-auto">
|
||||
{cards.filter((c) => !c.isWildcard || c.status === 'ACTIVE').map((card) => {
|
||||
const isChecked = selectedCardIds.includes(card.id);
|
||||
@ -452,7 +456,7 @@ export function QuotationCreateModal({
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" size="sm" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? '생성 중…' : '협상견적 등록'}
|
||||
{submitting ? '생성 중…' : '견적 등록'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user