o2o-negosium-original/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx

969 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useMemo, useEffect, useRef } from 'react';
import { X, PlusSquare, ArrowRight, Loader2, Gavel, CheckCheck } from 'lucide-react';
import { useNavigate } from 'react-router';
import { useListItemSupplyTypes } from '@/api/generated/supplier-item/supplier-item';
import { useListItems, useGetItem } from '@/api/generated/item/item';
import { useListSuppliers } from '@/api/generated/supplier/supplier';
import { useListCards } from '@/api/generated/card/card';
import { mapCardData } from '@/features/cards/types';
import { CONDITION_VARIABLE, CONDITION_LABEL } from '@/features/cards/editor/variables';
import { useLabels, useHiddenFields } from '@/features/settings/useCompanySettings';
import { Button } from '@/components/ui/button';
import { Typography, typographyVariants } from '@/components/ui/typography';
import { cn } from '@/lib/utils';
import { useScrollLock } from '@/lib/useScrollLock';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Combobox, type ComboOption } from '@/components/ui/combobox';
import type { Product, Partner, QuotationSetting, NegotiationCard } from '../types';
import type { CreateQuotationInput } from '../hooks/useQuotations';
import {
is1v1,
toQuotationType,
awardStrategySummary,
PriceGateAction,
DEFAULT_MID_ACTION,
DEFAULT_OVER_ACTION,
type QuotationMode,
} from '../types';
import { supplierTypeLabel } from '@/lib/enumLabels';
import { showToast } from '@/lib/notify';
const INTERNET_AVERAGE_FEE = 0.078;
const TARGET_PRICE_UNIT_LIMIT_MULTIPLIER = 2;
// datetime-local 값: 한국시간(Asia/Seoul)의 'YYYY-MM-DDTHH:mm'.
// sv-SE 로케일이 'YYYY-MM-DD HH:mm:ss' 를 주고, timeZone 명시로 브라우저 TZ 와 무관하게 KST 로 고정한다.
function toKstLocalInput(date: Date): string {
const s = date.toLocaleString('sv-SE', { timeZone: 'Asia/Seoul' });
return s.slice(0, 16).replace(' ', 'T');
}
function nowKstLocalInput(): string {
return toKstLocalInput(new Date());
}
function defaultDueDateLocalInput(): string {
return toKstLocalInput(new Date(Date.now() + 60 * 60 * 1000));
}
function isFutureLocalInput(value: string): boolean {
const time = new Date(value).getTime();
return Number.isFinite(time) && time > Date.now();
}
function parsePercent(value: string | undefined): number {
const n = Number(String(value ?? '').replace('%', '').trim());
return Number.isFinite(n) ? n / 100 : 0;
}
type QuotationCreateModalProps = {
open: boolean;
products: Product[];
partners: Partner[];
cards: NegotiationCard[];
quotationSettings: QuotationSetting[];
onCreate: (input: CreateQuotationInput) => boolean | Promise<boolean>;
onClose: () => void;
};
export function QuotationCreateModal({
open,
products,
partners,
cards,
quotationSettings,
onCreate,
onClose,
}: QuotationCreateModalProps) {
// 모달 열린 동안 배경(부모) 스크롤 잠금 — 뒤 페이지가 같이 스크롤되는 것 방지.
useScrollLock();
const [step, setStep] = useState(1);
const [title, setTitle] = useState('');
// 유형은 '진행 방식(협상/경매) × 대상(신규/후속)' 2축으로 받아 제출 직전 4코드로 합성한다.
const [mode, setMode] = useState<QuotationMode>('nego'); // 1:1 협상 / 1:N 경매
const [isNew, setIsNew] = useState(true); // 신규 / 후속(재)
const [productId, setProductId] = useState('');
const [selectedPartnerIds, setSelectedPartnerIds] = useState<string[]>([]);
const [dueDate, setDueDate] = useState(defaultDueDateLocalInput);
const [settingId, setSettingId] = useState(quotationSettings[0]?.qt_setting_id ?? '');
const [selectedCardIds, setSelectedCardIds] = useState<string[]>([]);
// 선택 항목 표시데이터 캐시 — 담는 순간 이름/이메일·카드메타를 적재해, 검색어가 바뀌어 콤보 목록에서 빠져도 아래 선택 테이블이 유지되게 한다.
const [partnerDetails, setPartnerDetails] = useState<Map<string, { name: string; email: string }>>(() => new Map());
const [cardDetails, setCardDetails] = useState<Map<string, { code: string; title: string; isWildcard: boolean }>>(() => new Map());
const [memo, setMemo] = useState('');
const [mdPrice, setMdPrice] = useState(''); // MD 제시가(원). 비우면 미전송 → 서버가 상품값으로 목표가 산정
const [midAction, setMidAction] = useState<number>(DEFAULT_MID_ACTION); // 앵커~목표가 구간: 낙찰/개찰 (1:1 전용)
const [overAction, setOverAction] = useState<number>(DEFAULT_OVER_ACTION); // 목표가 초과 구간: 낙찰/개찰 (1:1 전용)
const [submitting, setSubmitting] = useState(false);
const [mdTouched, setMdTouched] = useState(false); // 담당자가 제시가를 직접 건드렸는지 — 안 건드렸으면 자동 산출값을 채운다
const type = toQuotationType(mode, isNew); // 4코드 합성값
const oneToOne = is1v1(type); // 1:1 협상 여부 — 협력사 단일선택·낙찰기준·협상카드 노출을 가른다
const isReType = !isNew;
// 스텝 구성 — 1:1 협상만 협상카드 스텝 추가(작은 화면 과밀 방지). 경매는 3스텝.
const steps = oneToOne
? ['기본 정보', '협력사 초청', '낙찰 기준', '협상카드']
: ['기본 정보', '협력사 초청', '확인·완료'];
const totalSteps = steps.length;
const navigate = useNavigate();
// ── 픽리스트 서버검색(상품/협력사/카드) — size 캡 없이 검색으로 도달. 미검색이면 부모가 넘긴 목록으로 기본 노출.
const [productQ, setProductQ] = useState('');
const [productLabel, setProductLabel] = useState('');
const [supplierQ, setSupplierQ] = useState('');
const [cardQ, setCardQ] = useState('');
const productSearch = useListItems({ search: productQ || undefined, size: 30 });
const supplierSearch = useListSuppliers({ search: supplierQ || undefined, size: 30 });
const cardSearch = useListCards({ search: cardQ || undefined, size: 30 });
// 선택 상품은 검색으로 목록이 좁혀져도 파생값(목표가 후보)이 안 깨지게 id로 단건 조회한다.
const selItem = useGetItem(productId, { query: { enabled: !!productId } }).data?.item ?? null;
// 인터넷최저가·매입가·판매가는 상품 속성 — 모달에선 읽기전용으로만 보여주고, 수정은 상품 상세에서 한다.
const unitPrice = selItem?.price ?? null;
const internetLowest = selItem?.internet_lowest_price ?? null;
const purchase = selItem?.purchase_price ?? null;
const selling = selItem?.selling_price ?? null;
// 선택 상품의 협력사별 공급유형(제조/유통/총판/없음) — 협력사 리스트에 배지로 덧붙인다(리스트 자체는 재조회 안 함).
const supplyTypeQuery = useListItemSupplyTypes(productId, { query: { enabled: !!productId } });
const label = useLabels(); // 회사 설정 용어(목표 마진 등)
const isHidden = useHiddenFields(); // 회사설정으로 감춘 상품 기본필드 — 후보 리스트에서도 제외
const supplyTypeBySupplier = useMemo(() => {
const m = new Map<string, number>();
(supplyTypeQuery.data?.suppliers ?? []).forEach((s) => m.set(s.supplier_id, s.supply_type));
return m;
}, [supplyTypeQuery.data]);
// 콤보박스 옵션 — 미검색이면 부모 목록, 검색 중이면 서버결과.
const productOptions: ComboOption[] = productQ
? (productSearch.data?.items ?? []).map((it) => ({ id: it.item_id, label: `${it.name}${it.code ? ` [${it.code}]` : ''}` }))
: products.map((p) => ({ id: p.id ?? '', label: `${p.name}${p.code ? ` [${p.code}]` : ''}` }));
const supplierRows = supplierQ
? (supplierSearch.data?.suppliers ?? []).map((sp) => ({ id: sp.supplier_id, name: sp.name, email: sp.manager_email ?? '' }))
: partners.map((p) => ({ id: p.id ?? '', name: p.name, email: p.managerEmail ?? '' }));
const supplierOptions: ComboOption[] = supplierRows.map((s) => ({
id: s.id,
label: s.name,
node: (
<div className="flex items-center justify-between gap-2 w-full">
<div className="min-w-0">
<Typography as="span" variant="small" className="font-semibold block truncate">{s.name}</Typography>
<Typography as="span" variant="small" className="text-muted-foreground">이메일: {s.email}</Typography>
</div>
{productId && <SupplyTypeBadge type={supplyTypeBySupplier.get(s.id)} />}
</div>
),
}));
// 선택된 협력사 표시행 — 이름/이메일은 캐시에서, 취급유형은 상품 매핑(supplyTypeBySupplier)에서 라이브로 읽는다.
const selectedPartnerRows = selectedPartnerIds.map((id) => {
const d = partnerDetails.get(id);
return { id, name: d?.name ?? id, email: d?.email ?? '' };
});
const cardRows = cardQ ? (cardSearch.data?.cards ?? []).map(mapCardData) : cards;
// ── 카드 선택 게이팅: 협상 멘트에 변수명이 노출될 카드를 선택 단계에서 막는다 ──
// (1) 조건 전략(customer_condition) 미작성 — 저장 시 조건 내용이 있으면 script 에 실제 문구가
// 주입되고(slate.serialize), 없으면 {customer_condition} 토큰이 그대로 남는다.
const conditionUnfilled = (c: NegotiationCard) =>
(c.scriptPreview ?? '').includes(`{${CONDITION_VARIABLE}}`);
// (2) 인터넷 최저가({internet_lowest_price}) 인용 카드는 선택 상품에 최저가가 수집돼 있을 때만 —
// 최저가 없는 상품(items.internet_lowest_price=NULL/0)의 견적에 넣으면 협상 시 토큰이 노출된다.
// 상품 미선택 상태에선 판정 불가라 막지 않는다(상품 선택 후에만 게이팅).
const lowestUnavailable = !!productId && !(internetLowest && internetLowest > 0);
const lowestPriceLeak = (c: NegotiationCard) =>
lowestUnavailable && (c.scriptPreview ?? '').includes('{internet_lowest_price}');
// (3) 미승인 와일드카드(INACTIVE) — 목록·순위엔 보이되 선택은 막는다(수동 승인 전).
const blockReason = (c: NegotiationCard): string | null =>
c.isWildcard && c.status !== 'ACTIVE'
? '미승인 와일드카드'
: conditionUnfilled(c)
? `${CONDITION_LABEL} 미작성`
: lowestPriceLeak(c)
? '인터넷 최저가 미수집'
: null;
// 성공률(사용 세션 중 타결 비율) 내림차순 — 표본 없는 카드는 뒤로. 상위 3개에 1·2·3위 배지가 붙는다.
// 미승인 와일드카드도 목록·순위엔 노출(선택은 blockReason 으로 disabled).
const rankedCards = cardRows
.slice()
.sort((a, b) => b.successRate - a.successRate || b.usedCount - a.usedCount);
const cardOptions: ComboOption[] = rankedCards
.map((card, i) => {
const reason = blockReason(card);
return {
id: card.id,
label: card.title,
disabled: !!reason,
node: (
<div className={reason ? 'opacity-60' : undefined}>
<div className="flex items-center gap-1.5">
{card.usedCount > 0 && (
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${i < 3 ? 'bg-indigo-50 text-indigo-700' : 'bg-zinc-100 text-zinc-500'}`}>
{i + 1}위 · 성공률 {Math.round(card.successRate * 100)}%
</span>
)}
<Typography as="span" variant="small" className="text-muted-foreground font-mono block leading-none">{card.code}</Typography>
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${card.isWildcard ? 'bg-amber-50 text-amber-700' : 'bg-zinc-100 text-zinc-600'}`}>
{card.isWildcard ? '와일드' : '협상'}
</span>
{reason && (
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded leading-none bg-rose-50 text-rose-600">
{reason}
</span>
)}
</div>
<Typography as="span" variant="small" className="mt-1 block leading-tight">{card.title}</Typography>
</div>
),
};
});
// 1·2·3위 배지가 붙는 카드(상위 3개, 사용이력 있는 것만) — 기본 선택 대상. 선택 불가(조건 미작성·최저가 미수집) 카드는 제외.
const topRankedCards = rankedCards.filter((c) => !blockReason(c)).slice(0, 3).filter((c) => c.usedCount > 0);
const topRankedKey = topRankedCards.map((c) => c.id).join(',');
const autoSelectedRef = useRef(false);
// 모달을 열면 추천 상위 3개를 기본 선택해 둔다. 열려 있는 동안 1회만 — 이후 사용자의 추가/해제는 건드리지 않는다.
useEffect(() => {
if (!open) {
autoSelectedRef.current = false;
return;
}
if (autoSelectedRef.current || topRankedCards.length === 0) return; // 목록 로드 전이면 다음 렌더에 재시도
autoSelectedRef.current = true;
setCardDetails((m) => {
const next = new Map(m);
topRankedCards.forEach((c) => next.set(c.id, { code: c.code, title: c.title, isWildcard: c.isWildcard }));
return next;
});
setSelectedCardIds((prev) => (prev.length > 0 ? prev : topRankedCards.map((c) => c.id)));
// topRankedKey = 목록이 확정된 시점만 감지 (배열 재생성으로 매 렌더 도는 것 방지)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, topRankedKey]);
// 이미 고른 카드 중, 상품을 최저가 미수집 상품으로 바꾸면 {internet_lowest_price} 인용 카드는 자동 해제.
// (picklist disabled 는 신규 선택만 막으므로, 상품 변경 후 잔존 선택분을 여기서 정리해 노출을 막는다.)
useEffect(() => {
if (!lowestUnavailable) return;
const leakIds = new Set(
cardRows.filter((c) => (c.scriptPreview ?? '').includes('{internet_lowest_price}')).map((c) => c.id),
);
if (leakIds.size === 0) return;
setSelectedCardIds((prev) => (prev.some((id) => leakIds.has(id)) ? prev.filter((id) => !leakIds.has(id)) : prev));
// productId 변경 시점에만 정리 (cardRows 재생성으로 매 렌더 도는 것 방지)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lowestUnavailable, productId]);
// 선택된 카드 표시행 — 캐시에서 번호/유형/카드명을 읽어 검색어와 무관하게 유지한다.
const selectedCardRows = selectedCardIds.map((id) => {
const d = cardDetails.get(id);
return { id, code: d?.code ?? '', title: d?.title ?? id, isWildcard: d?.isWildcard ?? false };
});
const selectedSetting = quotationSettings.find((s) => s.qt_setting_id === settingId);
const margin = parsePercent(selectedSetting?.target_margin);
// 목표가 산정 후보(계산식+결과값) — 인터넷최저가×(1−수수료)·매입가/판매가×(1−네고율). 회사설정 숨김필드는 제외(백엔드와 동일).
const targetBreakdown = [
{ key: 'internet_lowest_price', label: `${label('item.internet_lowest_price')} × (1−수수료 ${+(INTERNET_AVERAGE_FEE * 100).toFixed(1)}%)`, raw: internetLowest, rate: INTERNET_AVERAGE_FEE, show: internetLowest != null },
{ key: 'purchase_price', label: `${label('item.purchase_price')} × (1−네고율 ${+(margin * 100).toFixed(1)}%)`, raw: purchase, rate: margin, show: isReType && purchase != null },
{ key: 'selling_price', label: `${label('item.selling_price')} × (1−네고율 ${+(margin * 100).toFixed(1)}%)`, raw: selling, rate: margin, show: isReType && selling != null },
]
.filter((c) => c.show && c.raw != null && c.raw > 0 && !isHidden(c.key))
// 서버 _candidates 와 동일하게 10원 단위 반올림(IMK #11) — 후보·목표가·저장값이 다 일치.
.map((c) => ({ key: c.key, label: c.label, raw: c.raw as number, value: Math.round(((c.raw as number) * (1 - c.rate)) / 10) * 10 }));
const autoTarget = targetBreakdown.length ? Math.min(...targetBreakdown.map((c) => c.value)) : null;
// 구매담당자 제시가 필드엔 자동 산출값을 미리 보여주되(IMK #4), 담당자가 직접 건드렸을 때만 md_price 로 전송한다.
// (자동값을 md 로 보내면 서버가 'MD 입력가'로 저장해 산정내역이 매입가 대신 MD로 잡히고 후보가 안 보인다.)
const effectiveMdPrice = mdTouched ? mdPrice : (autoTarget != null ? String(autoTarget) : mdPrice);
const mdNum = Number(effectiveMdPrice) || 0;
const hasItemCandidate = internetLowest != null || (isReType && (purchase != null || selling != null));
const mdRequired = !!productId && !hasItemCandidate;
const targetReady = mdNum > 0 || hasItemCandidate;
// 최종 목표가 = 제시가(자동/수동) 있으면 그 값, 없으면 자동 산출값.
const estimatedTargetPrice = mdNum > 0 ? mdNum : autoTarget;
const targetPriceLimit = unitPrice != null && unitPrice > 0
? unitPrice * TARGET_PRICE_UNIT_LIMIT_MULTIPLIER
: null;
const targetLimitExceeded = targetPriceLimit != null && estimatedTargetPrice != null && estimatedTargetPrice > targetPriceLimit;
if (!open) return null;
const selectMode = (next: QuotationMode) => {
setMode(next);
// 경매→협상 전환 시 다중선택으로 담긴 협력사를 1곳으로 줄인다(1:1 단일선택).
if (next === 'nego') setSelectedPartnerIds((prev) => prev.slice(0, 1));
// 경매는 3스텝뿐 — 협상카드 스텝(4)에 있던 상태면 마지막(3)으로 당긴다.
if (next === 'auction') setStep((s) => Math.min(s, 3));
};
const togglePartner = (id: string) => {
// 담는 순간 이름/이메일을 캐시에 적재 — 이후 검색어가 바뀌어 목록에서 빠져도 선택 테이블이 유지된다.
const row = supplierRows.find((r) => r.id === id);
if (row) setPartnerDetails((m) => new Map(m).set(id, { name: row.name, email: row.email }));
setSelectedPartnerIds((prev) =>
oneToOne
? prev.includes(id)
? []
: [id]
: prev.includes(id)
? prev.filter((p) => p !== id)
: [...prev, id],
);
};
const toggleCard = (id: string) => {
const row = cardRows.find((c) => c.id === id);
if (row) setCardDetails((m) => new Map(m).set(id, { code: row.code, title: row.title, isWildcard: row.isWildcard }));
setSelectedCardIds((prev) => (prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id]));
};
// 일괄 선택(IMK #22) — 현재 목록(검색 결과)의 카드를 전부 담는다. 이미 담긴 카드는 유지.
const selectAllCards = () => {
const rows = cardRows.filter((c) => !blockReason(c));
setCardDetails((m) => {
const next = new Map(m);
rows.forEach((r) => next.set(r.id, { code: r.code, title: r.title, isWildcard: r.isWildcard }));
return next;
});
setSelectedCardIds((prev) => [...new Set([...prev, ...rows.map((r) => r.id)])]);
};
const clearAllCards = () => setSelectedCardIds([]);
const handleSubmit = async () => {
if (submitting) return;
setSubmitting(true);
try {
// 목표가 산정에 쓸 값이 없으면(MD가·상품 후보 전무) 생성 차단 — 서버 산정불가 에러 선제 방어.
if (productId && !targetReady) {
showToast('목표가 산정에 쓸 값이 없습니다 — 구매담당자 제시가를 입력하거나, 상품 상세에서 인터넷최저가·매입가를 채워주세요.', 'error');
return; // finally 에서 submitting 해제
}
if (!isFutureLocalInput(dueDate)) {
showToast('마감기한은 현재 시각보다 나중으로 설정해 주세요.', 'error');
return;
}
if (targetLimitExceeded) {
showToast('목표가는 상품단가의 2배를 초과할 수 없습니다.', 'error');
return;
}
// 낙찰 기준은 1:1 협상만 전송(경매는 미전송 → 서버가 mid=over=AWARD 강제). 카드도 1:1 전용.
const ok = await onCreate({
title,
type,
productId,
partnerIds: selectedPartnerIds,
dueDate,
settingId,
cardIds: oneToOne ? selectedCardIds : [],
memo,
mdPrice: mdTouched && mdPrice ? Number(mdPrice) : null,
midAction: oneToOne ? midAction : undefined,
overAction: oneToOne ? overAction : undefined,
});
if (ok) onClose();
} finally {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-xs">
{submitting && (
<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="text-muted-foreground font-mono">견적 · 협상 세션 등록 중</Typography>
</div>
</div>
)}
<div className="w-full max-w-2xl bg-card border border-border rounded-lg shadow-2xl p-6 flex flex-col max-h-[90vh] overflow-hidden animate-scale-up font-mono">
{/* Header */}
<div className="shrink-0 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}/{totalSteps})</Typography>
</div>
<button onClick={onClose} className="p-1 rounded text-muted-foreground hover:bg-muted cursor-pointer">
<X size={18} />
</button>
</div>
{/* Steps indicator — 스텝 수는 유형에 따라 3(경매)/4(협상) */}
<div className="shrink-0 flex items-center justify-between gap-2 py-4 border-b border-border/40 text-muted-foreground">
{steps.map((stepLabel, i) => {
const n = i + 1;
return (
<div key={stepLabel} className="flex items-center gap-2 min-w-0">
{i > 0 && <ArrowRight size={14} className="shrink-0" />}
<button type="button" onClick={() => setStep(n)} className="cursor-pointer hover:opacity-75 transition-opacity truncate">
<Typography as="span" variant="body" className={`font-semibold ${step === n ? 'text-primary' : 'text-muted-foreground'}`}>{n}. {stepLabel}</Typography>
</button>
</div>
);
})}
</div>
{/* Step content — 남은 높이를 채우고 내용이 길면 여기만 스크롤(작은 화면 대응) */}
<div className="flex-1 overflow-y-auto min-h-0 my-6 pr-1 text-xs text-foreground space-y-4">
{step === 1 && (
<div className="space-y-4">
{/* 진행 방식 × 대상 2축 — 협상/경매 갈림이 아래 카드·낙찰기준 노출까지 결정한다 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-1">
<Typography as="label" variant="label">진행 방식</Typography>
<Segmented
options={[
{ value: 'nego', label: '1:1 협상', sub: '협상카드·낙찰기준 선택' },
{ value: 'auction', label: '1:N 견적', sub: '최저가 자동 낙찰' },
]}
value={mode}
onChange={(v) => selectMode(v as QuotationMode)}
/>
</div>
<div className="space-y-1">
<Typography as="label" variant="label">대상</Typography>
<Segmented
options={[
{ value: 'new', label: '신규', sub: '처음 진행하는 견적' },
{ value: 're', label: '후속·재', sub: '이전 견적에 이어 진행' },
]}
value={isNew ? 'new' : 're'}
onChange={(v) => setIsNew(v === 'new')}
/>
</div>
</div>
<div className="space-y-1">
<Typography as="label" variant="label">{label('quotation.due_date')}</Typography>
<input
id="wizard-date"
type="datetime-local"
className="w-full p-2 bg-background border border-border rounded text-xs"
value={dueDate}
min={nowKstLocalInput()}
onChange={(e) => setDueDate(e.target.value)}
/>
</div>
<div className="space-y-1">
<Typography as="label" variant="label">{label('quotation.title')}</Typography>
<Input
id="wizard-title"
type="text"
className="text-xs"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="예: 6월 배터리 원부자재 견적 의뢰"
/>
</div>
<div className="space-y-1">
<Typography as="label" variant="label">상품</Typography>
<Combobox
id="wizard-product"
options={productOptions}
loading={productSearch.isLoading}
onQueryChange={setProductQ}
value={productId || undefined}
selectedLabel={productLabel}
onSelect={(opt) => { setProductId(opt.id); setProductLabel(opt.label); }}
placeholder="협상 대상 상품을 고르세요..."
searchPlaceholder="상품명·코드로 검색..."
emptyText="일치하는 상품이 없습니다"
/>
</div>
</div>
)}
{step === 2 && (
<div className="space-y-3">
<Typography as="span" variant="label" className="block">협력사 초청 ({oneToOne ? '단일선택' : '다중선택'})</Typography>
{/* 서버검색 다중선택 — 각 행에 선택 상품 상품조달유형 배지(미매핑=미정). oneToOne이면 togglePartner가 단일로 강제. */}
<Combobox
variant="inline"
multiple
values={selectedPartnerIds}
options={supplierOptions}
loading={supplierSearch.isLoading}
onQueryChange={setSupplierQ}
onToggle={(opt) => togglePartner(opt.id)}
searchPlaceholder="협력사명·코드·담당자 검색..."
emptyText="협력사가 없습니다"
maxListHeight="max-h-56"
/>
{/* 선택 목록 — 검색어가 바뀌어도 담은 협력사가 유지되는 고정 테이블(취급유형은 상품×협력사 매핑). */}
<div className="space-y-1">
<Typography as="span" variant="label" className="block text-[10px] text-muted-foreground">
담긴 협력사 {selectedPartnerRows.length}곳
</Typography>
<SelectedPartnerTable
rows={selectedPartnerRows}
supplyTypeBySupplier={supplyTypeBySupplier}
showSupplyType={!!productId}
onRemove={togglePartner}
/>
</div>
</div>
)}
{step === 3 && (
<div className="space-y-4">
<div className="space-y-1 font-mono text-xs">
<Typography as="label" variant="label">적용할 견적 세팅 지정</Typography>
<Select value={settingId} onValueChange={(v) => setSettingId(v ?? '')}>
<SelectTrigger id="wizard-setting-select" className="w-full">
<SelectValue>
{(value) => {
const qs = quotationSettings.find((s) => s.qt_setting_id === value);
return qs
? `[${label('target_margin')}: ${qs.target_margin}] 카드 ${qs.card_use_count}`
: '';
}}
</SelectValue>
</SelectTrigger>
<SelectContent>
{quotationSettings.map((qs) => (
<SelectItem key={qs.qt_setting_id} value={qs.qt_setting_id}>
[{label('target_margin')}: {qs.target_margin}] 카드 {qs.card_use_count}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{oneToOne ? (
<>
{/* 낙찰 기준(1:1 전용) — 스펙트럼 = 선택. 낙찰선을 앵커/목표가 중 택1, 목표가 초과는 항상 개찰. */}
<AwardLinePicker
mid={midAction}
over={overAction}
// 단조성: 초과=낙찰이면 앵커~목표가도 낙찰, 앵커~목표가=개찰이면 초과도 개찰(혼합 조합 방지)
onMid={(v) => {
setMidAction(v);
if (v === PriceGateAction.OPEN) setOverAction(PriceGateAction.OPEN);
}}
onOver={(v) => {
setOverAction(v);
if (v === PriceGateAction.AWARD) setMidAction(PriceGateAction.AWARD);
}}
/>
</>
) : (
/* 경매(1:N) — 낙찰 기준·협상카드 없음. 최저가 자동 낙찰 안내만. */
<div className="rounded border border-border bg-muted/20 p-3 flex items-start gap-2.5">
<Gavel size={18} className="text-primary mt-0.5 shrink-0" />
<div>
<Typography as="span" variant="small" className="font-semibold block">최저가 자동 낙찰</Typography>
<Typography as="span" variant="small" className="text-muted-foreground text-[10px] leading-snug">
1:N 견적은 가장 낮은 투찰가가 자동 낙찰됩니다. 낙찰 기준·협상카드 설정이 없습니다.
</Typography>
</div>
</div>
)}
{/* 목표가 산정 후보(계산식+결과값) — 참고용, 위. 채택된 최저는 녹색 강조. */}
{productId && targetBreakdown.length > 0 && (
<div className="rounded border border-border bg-muted/20 p-3 space-y-2">
<div className="flex items-center justify-between">
<Typography as="span" variant="label">
목표가 산정 후보 <span className="text-muted-foreground font-normal">({isReType ? '재' : '신규'})</span>
</Typography>
<button
type="button"
onClick={() => navigate(`/products?detail=${productId}`)}
className={cn(typographyVariants({ variant: 'link' }), 'text-[10px]')}
>
상품 상세에서 수정
</button>
</div>
<div className="space-y-1">
{targetBreakdown.map((c) => {
const isMin = autoTarget != null && c.value === autoTarget;
return (
<div key={c.key} className={cn('flex items-center gap-2 rounded px-2 py-1', isMin && 'bg-emerald-50 dark:bg-emerald-950/30')}>
<Typography as="span" variant="small" className="flex-1 min-w-0 truncate text-[11px] text-muted-foreground">{c.label}</Typography>
<Typography as="span" variant="small" className="w-16 shrink-0 text-right font-mono text-[10px] tabular-nums text-muted-foreground/70">{c.raw.toLocaleString()}</Typography>
<Typography as="span" variant="small" className={cn('w-20 shrink-0 text-right font-mono font-bold tabular-nums', isMin && 'text-emerald-700 dark:text-emerald-400')}>₩{c.value.toLocaleString()}</Typography>
<span className="w-9 shrink-0 text-right">
{isMin && <span className="rounded-full border border-emerald-600 px-1.5 text-[8px] font-bold text-emerald-700 dark:text-emerald-400">최저</span>}
</span>
</div>
);
})}
</div>
</div>
)}
{/* 목표가(구매담당자 제시가) — 후보 최저를 자동 입력. 값이 높든 낮든 이 값이 1순위(실제 목표가). 수정 가능. */}
<div className="space-y-1">
<Typography as="label" variant="label" className={mdRequired ? 'text-rose-500' : undefined}>
목표가 (구매담당자 제시가) {mdRequired ? '(필수 — 산정값 없음)' : ''}
</Typography>
<Input
id="wizard-md-price"
type="number"
min={0}
className="text-xs"
value={effectiveMdPrice}
onChange={(e) => {
setMdTouched(true);
setMdPrice(e.target.value);
}}
placeholder={mdRequired ? '상품에 산정값이 없어 직접 입력이 필요합니다' : '자동 산출값 · 수정 가능'}
/>
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground">
{autoTarget != null
? '후보 중 최저가 자동 입력됨 · 이 값이 목표가(1순위)로 쓰입니다. 수정 가능.'
: '자동 산출값이 없어 직접 입력이 필요합니다.'}
</Typography>
{!targetReady && (
<Typography as="p" variant="small" className="text-rose-600 leading-snug">
⚠ 목표가를 산정할 값이 없습니다 — 직접 입력하거나 상품 상세에서 인터넷최저가·매입가를 채워주세요.
</Typography>
)}
{targetLimitExceeded && (
<Typography as="p" variant="small" className="text-rose-600 leading-snug">
목표가 ₩{estimatedTargetPrice?.toLocaleString()}이 상품단가의 2배를 초과합니다 — 조정해 주세요.
</Typography>
)}
</div>
<div className="space-y-1">
<Typography as="label" variant="label">메모 (선택)</Typography>
<textarea
id="wizard-memo"
value={memo}
onChange={(e) => setMemo(e.target.value)}
rows={2}
maxLength={100}
className="w-full p-2 bg-background border border-border rounded text-xs resize-none"
placeholder="견적 관련 메모 (선택, 최대 100자)"
/>
</div>
</div>
)}
{/* Step 4 — 협상카드(1:1 협상 전용, 별도 스텝으로 분리해 과밀 방지) */}
{step === 4 && oneToOne && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Typography as="span" variant="label" className="block">협상카드 및 와일드카드 선택 (선택)</Typography>
<div className="flex items-center gap-1.5">
<Button type="button" variant="outline" size="sm" className="h-7 px-2.5 text-[11px] gap-1" onClick={selectAllCards}>
<CheckCheck size={13} />
현재 목록 전체선택
</Button>
<Button type="button" variant="outline" size="sm" className="h-7 px-2.5 text-[11px] gap-1 text-muted-foreground" onClick={clearAllCards} disabled={selectedCardIds.length === 0}>
<X size={13} />
전체해제
</Button>
</div>
</div>
<Typography as="span" variant="small" className="block text-[10px] text-muted-foreground">
1:1 협상에서 AI 협상봇이 발동할 카드입니다.
</Typography>
<Combobox
variant="inline"
multiple
values={selectedCardIds}
options={cardOptions}
loading={cardSearch.isLoading}
onQueryChange={setCardQ}
onToggle={(opt) => toggleCard(opt.id)}
searchPlaceholder="카드명·번호·스크립트 검색..."
emptyText="협상카드가 없습니다"
maxListHeight="max-h-72"
/>
{/* 선택 목록 — 검색어가 바뀌어도 담은 카드가 유지되는 고정 테이블. */}
<div className="space-y-1">
<Typography as="span" variant="label" className="block text-[10px] text-muted-foreground">
담긴 카드 {selectedCardRows.length}장
</Typography>
<SelectedCardTable rows={selectedCardRows} onRemove={toggleCard} />
</div>
</div>
)}
</div>
{/* Footer nav */}
<div className="shrink-0 flex justify-between items-center pt-4 border-t border-border mt-6">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => (step === 1 ? onClose() : setStep((prev) => prev - 1))}
>
{step === 1 ? '취소' : '이전 보기'}
</Button>
<div className="flex gap-2">
{step < totalSteps ? (
<Button
type="button"
size="sm"
onClick={() => {
if (step === 1 && !isFutureLocalInput(dueDate)) {
showToast('마감기한은 현재 시각보다 나중으로 설정해 주세요.', 'error');
return;
}
if (step === 3 && productId && !targetReady) {
showToast('목표가 산정에 쓸 값이 없습니다 — 구매담당자 제시가를 입력하거나 상품 상세에서 값을 채워주세요.', 'error');
return;
}
if (step === 3 && targetLimitExceeded) {
showToast('목표가는 상품단가의 2배를 초과할 수 없습니다.', 'error');
return;
}
setStep((prev) => prev + 1);
}}
>
다음 단계로
</Button>
) : (
<Button type="button" size="sm" onClick={handleSubmit} disabled={submitting}>
{submitting ? '생성 중…' : '견적 등록'}
</Button>
)}
</div>
</div>
</div>
</div>
);
}
// ── 헬퍼 컴포넌트 (메인 아래) ──────────────────────────────────────────────
// 협력사 상품조달유형 배지 — type undefined = 유형 미지정(매핑 없음). 초청 협력사는 그 상품을 공급하므로 '미취급'이 아니라 '미정'.
function SupplyTypeBadge({ type }: { type?: number }) {
if (type === undefined) {
return (
<Typography as="span" variant="small" className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground shrink-0">
미정
</Typography>
);
}
return (
<Typography as="span" variant="small" className="text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-semibold shrink-0">
{supplierTypeLabel(type)}
</Typography>
);
}
// 선택된 협력사 테이블 — 콤보로 담은 협력사를 검색어와 무관하게 고정 노출한다. 취급유형 컬럼은 상품 선택 시에만.
function SelectedPartnerTable({
rows,
supplyTypeBySupplier,
showSupplyType,
onRemove,
}: {
rows: { id: string; name: string; email: string }[];
supplyTypeBySupplier: Map<string, number>;
showSupplyType: boolean;
onRemove: (id: string) => void;
}) {
if (rows.length === 0) {
return (
<div className="rounded border border-dashed border-border px-2 py-3 text-center">
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">
담긴 협력사가 없습니다 — 위에서 검색해 담아주세요.
</Typography>
</div>
);
}
return (
<div className="rounded border border-border overflow-hidden">
<table className="w-full table-fixed text-xs">
<thead>
<tr className="bg-muted/40">
<th className="px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">협력사명</Typography></th>
<th className="px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">담당자 이메일</Typography></th>
{showSupplyType && <th className="w-24 px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">상품조달유형</Typography></th>}
<th className="w-9 px-2 py-1.5" />
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className="border-t border-border">
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate font-semibold">{r.name}</Typography></td>
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate text-muted-foreground">{r.email || '-'}</Typography></td>
{showSupplyType && <td className="px-2 py-1.5"><SupplyTypeBadge type={supplyTypeBySupplier.get(r.id)} /></td>}
<td className="px-2 py-1.5 text-right">
<button type="button" onClick={() => onRemove(r.id)} title="제외" className="rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-rose-600 cursor-pointer">
<X size={13} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// 선택된 카드 테이블 — 콤보로 담은 협상/와일드카드를 검색어와 무관하게 고정 노출한다.
function SelectedCardTable({
rows,
onRemove,
}: {
rows: { id: string; code: string; title: string; isWildcard: boolean }[];
onRemove: (id: string) => void;
}) {
if (rows.length === 0) {
return (
<div className="rounded border border-dashed border-border px-2 py-3 text-center">
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">
담긴 카드가 없습니다 — 위에서 검색해 담아주세요.
</Typography>
</div>
);
}
return (
<div className="rounded border border-border overflow-hidden">
<table className="w-full table-fixed text-xs">
<thead>
<tr className="bg-muted/40">
<th className="w-24 px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">카드번호</Typography></th>
<th className="w-16 px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">유형</Typography></th>
<th className="px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">카드명</Typography></th>
<th className="w-9 px-2 py-1.5" />
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className="border-t border-border">
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate font-mono text-muted-foreground">{r.code}</Typography></td>
<td className="px-2 py-1.5">
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${r.isWildcard ? 'bg-amber-50 text-amber-700' : 'bg-zinc-100 text-zinc-600'}`}>
{r.isWildcard ? '와일드' : '협상'}
</span>
</td>
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate">{r.title}</Typography></td>
<td className="px-2 py-1.5 text-right">
<button type="button" onClick={() => onRemove(r.id)} title="제외" className="rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-rose-600 cursor-pointer">
<X size={13} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// 세그먼트 컨트롤 — 소수의 명명된 이산 선택(진행 방식·대상)에 라디오보다 명확. 값은 문자열.
function Segmented({
options,
value,
onChange,
}: {
options: { value: string; label: string; sub?: string }[];
value: string;
onChange: (v: string) => void;
}) {
return (
<div className="grid gap-2" style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}>
{options.map((o) => {
const active = o.value === value;
return (
<button
key={o.value}
type="button"
onClick={() => onChange(o.value)}
className={cn(
'rounded border p-2.5 text-left transition-all cursor-pointer',
active ? 'bg-primary/5 border-primary' : 'bg-background border-border hover:bg-muted/20',
)}
>
<Typography as="span" variant="small" className={cn('block font-semibold', active && 'text-primary')}>{o.label}</Typography>
{o.sub && <Typography as="span" variant="small" className="block text-[10px] text-muted-foreground mt-0.5 leading-tight">{o.sub}</Typography>}
</button>
);
})}
</div>
);
}
// 낙찰 기준 컨트롤 — 스펙트럼 선(線) 위 구간을 눌러 낙찰↔개찰 전환. 앵커 이하는 항상 낙찰(고정, 표시만) —
// '앵커~목표가'(mid_action)·'목표가 초과'(over_action) 두 구간만 사용자가 각각 낙찰/개찰로 정한다.
function AwardLinePicker({
mid, over, onMid, onOver,
}: {
mid: number;
over: number;
onMid: (v: number) => void;
onOver: (v: number) => void;
}) {
const A = PriceGateAction.AWARD;
const O = PriceGateAction.OPEN;
const zones = [
{ label: '앵커링가 이하', win: true, locked: true, toggle: undefined },
{ label: '앵커링가~목표가', win: mid === A, locked: false, toggle: () => onMid(mid === A ? O : A) },
{ label: '목표가 초과', win: over === A, locked: false, toggle: () => onOver(over === A ? O : A) },
];
return (
<div className="space-y-1.5 pt-1">
<div className="flex items-center justify-between">
<Typography as="span" variant="label">낙찰 기준</Typography>
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">구간을 눌러 낙찰↔개찰 · 싸다◀▶비싸다</Typography>
</div>
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground leading-snug">
협력사 <span className="font-semibold text-foreground">최저 투찰가</span>가 어느 구간에 오느냐로 낙찰/개찰이 정해집니다.
</Typography>
{/* 스펙트럼 선: 구간이 곧 선택 버튼 */}
<div className="flex rounded-md overflow-hidden border border-border text-center">
{zones.map((z, i) => {
const body = (
<>
<Typography as="span" variant="small" className="block text-[9px] leading-tight text-muted-foreground">{z.label}</Typography>
<Typography as="span" variant="small" className={cn('block text-[12px] font-bold leading-tight', z.win ? 'text-emerald-700 dark:text-emerald-400' : 'text-zinc-500')}>
{z.win ? '낙찰' : '개찰'}{z.locked ? ' 🔒' : ''}
</Typography>
</>
);
const cls = cn('flex-1 px-1 py-2', i > 0 && 'border-l border-border', z.win ? 'bg-emerald-50 dark:bg-emerald-950/30' : 'bg-muted');
return z.locked ? (
<div key={z.label} className={cls} title="앵커링가 이하는 항상 낙찰(고정)">{body}</div>
) : (
<button key={z.label} type="button" onClick={z.toggle} className={cn(cls, 'cursor-pointer transition-[filter] hover:brightness-95')}>{body}</button>
);
})}
</div>
{/* 경계 마커 — 구간 경계(1/3·2/3)에 ▲ 중앙 정렬(앵커링가·목표가) */}
<div className="relative h-6">
{[
{ left: '33.3333%', label: '앵커링가' },
{ left: '66.6667%', label: '목표가' },
].map((mk) => (
<span
key={mk.label}
className="absolute top-0 flex -translate-x-1/2 flex-col items-center text-[9px] text-muted-foreground"
style={{ left: mk.left }}
>
<span className="leading-none">▲</span>
<span className="leading-tight whitespace-nowrap">{mk.label}</span>
</span>
))}
</div>
{/* 전략 한 줄 요약(관대/기본/엄격 전략) — 기본 전략(목표가까지 낙찰·초과 개찰)일 때만 경계가 애매하니 '목표가 포함' 부기 */}
{(() => {
const t = awardStrategySummary(mid, over);
const isBasic = mid === PriceGateAction.AWARD && over === PriceGateAction.OPEN;
return (
<Typography as="p" variant="small" className="text-[11px]">
<span className="font-bold text-primary">{t.strategy}</span>
<span className="text-muted-foreground"> · {t.desc}</span>
{isBasic && (
<span className="text-emerald-700 dark:text-emerald-400 font-semibold"> · 목표가 포함</span>
)}
</Typography>
);
})()}
</div>
);
}