763 lines
40 KiB
TypeScript
763 lines
40 KiB
TypeScript
import { useState, useMemo, useEffect } 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 { useLabels, useVatMode } 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 { Switch } from '@/components/ui/switch';
|
||
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,
|
||
PriceGateAction,
|
||
DEFAULT_MID_ACTION,
|
||
DEFAULT_OVER_ACTION,
|
||
type QuotationMode,
|
||
} from '../types';
|
||
import { showToast } from '@/lib/notify';
|
||
import { defaultDueDateLocalInput, nowKstLocalInput, isFutureLocalInput } from './quotationForm.utils';
|
||
import { SupplyTypeBadge, SelectedPartnerTable, SelectedCardTable, Segmented, AwardLinePicker } from './QuotationFormParts';
|
||
import { useTargetPrice } from '../hooks/useTargetPrice';
|
||
import { useCardGating } from '../hooks/useCardGating';
|
||
|
||
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 [ceilingPct, setCeilingPct] = useState(''); // 협상 완료 상한율(%) 이 견적 override. 비우면 세팅 기본값
|
||
const [ceilingTouched, setCeilingTouched] = useState(false); // 상한율을 직접 건드렸는지 — 안 건드렸으면 세팅값 표시
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [mdTouched, setMdTouched] = useState(false); // 담당자가 제시가를 직접 건드렸는지 — 안 건드렸으면 자동 산출값을 채운다
|
||
// 네고율 차감(매입가·판매가 공통) — 이 견적에서만 조정. 안 건드리면 세팅 기본값을 따른다(negoTouched=false). 네고율 값 자체는 세팅값 고정.
|
||
const [negoTouched, setNegoTouched] = useState(false);
|
||
const [applyNego, setApplyNego] = useState(false); // 네고율 차감 여부
|
||
// 목표가로 채택한 후보 키 — null이면 최저 후보를 기본 채택.
|
||
const [selectedCandidateKey, setSelectedCandidateKey] = useState<string | null>(null);
|
||
|
||
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();
|
||
|
||
// 세팅 목록은 비동기 도착 — useState 초기값은 최초 마운트 때 목록이 비어 있으면 ''로 굳는다.
|
||
// 목록이 채워지면 기본(첫) 세팅을 선택하고, 이미 유효한 선택은 유지한다.
|
||
useEffect(() => {
|
||
if (quotationSettings.length === 0) return;
|
||
setSettingId((prev) => (prev && quotationSettings.some((s) => s.qt_setting_id === prev) ? prev : quotationSettings[0].qt_setting_id));
|
||
}, [quotationSettings]);
|
||
|
||
// ── 픽리스트 서버검색(상품/협력사/카드) — 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 vatUnified = useVatMode() === 'unified_excluded'; // 부가세 전체 통일 — 목표가 입력 기준 안내
|
||
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;
|
||
|
||
// ── 목표가 산정 (카드 게이팅이 목표가를 참조하므로 게이팅보다 먼저 계산한다) ──
|
||
const {
|
||
settingMarginPct,
|
||
negoToggleAvailable,
|
||
targetBreakdown,
|
||
autoTarget,
|
||
activeCandidateKey,
|
||
effectiveApplyNego,
|
||
effectiveMdPrice,
|
||
mdRequired,
|
||
targetReady,
|
||
targetLimitExceeded,
|
||
estimatedTargetPrice,
|
||
submitMdPrice,
|
||
settingCeilingRate,
|
||
doneCeilingPrice,
|
||
} = useTargetPrice({
|
||
quotationSettings,
|
||
settingId,
|
||
productId,
|
||
isReType,
|
||
internetLowest,
|
||
purchase,
|
||
selling,
|
||
unitPrice,
|
||
mdPrice,
|
||
mdTouched,
|
||
negoTouched,
|
||
applyNego,
|
||
selectedCandidateKey,
|
||
doneCeilingRateOverride: ceilingTouched && ceilingPct !== '' ? Math.round(Number(ceilingPct) * 10) : null,
|
||
});
|
||
|
||
// ── 카드 선택 게이팅 — 부적합 카드 disabled·자동 선택/해제(useCardGating 이 소유) ──
|
||
const { blockReason, cardOptions, selectedCardRows } = useCardGating({
|
||
cardRows,
|
||
productId,
|
||
internetLowest,
|
||
estimatedTargetPrice,
|
||
open,
|
||
selectedCardIds,
|
||
cardDetails,
|
||
setSelectedCardIds,
|
||
setCardDetails,
|
||
});
|
||
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([]);
|
||
// 후보 택1 — 그 후보값이 목표가로 들어가게 md 직접입력 오버라이드를 해제한다.
|
||
const selectCandidate = (key: string) => {
|
||
setSelectedCandidateKey(key);
|
||
setMdTouched(false);
|
||
setMdPrice('');
|
||
};
|
||
|
||
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: submitMdPrice,
|
||
midAction: oneToOne ? midAction : undefined,
|
||
overAction: oneToOne ? overAction : undefined,
|
||
// 완료 상한율 override — 직접 건드렸을 때만 전송(‰). 비우면 서버가 세팅 기본값 사용.
|
||
doneCeilingRate: ceilingTouched && ceilingPct !== '' ? Math.round(Number(ceilingPct) * 10) : 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} · 타결상한 +${qs.done_ceiling_rate / 10}%`
|
||
: '';
|
||
}}
|
||
</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} · 타결상한 +{qs.done_ceiling_rate / 10}%
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* ── 타결 기준 (협상) — 봇이 어느 가격까지 합의하면 타결로 볼지 ── */}
|
||
<div className="rounded-lg border border-border overflow-hidden">
|
||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border bg-muted/30">
|
||
<span className="grid place-items-center h-5 w-5 rounded-md bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400 shrink-0"><CheckCheck size={12} /></span>
|
||
<div className="min-w-0">
|
||
<Typography as="span" variant="small" className="font-bold block leading-tight">타결 기준 <span className="text-muted-foreground font-normal text-[10px]">· 협상</span></Typography>
|
||
<Typography as="span" variant="small" className="text-muted-foreground text-[10px] block leading-tight">봇이 어느 가격까지 합의하면 타결로 볼지 정합니다</Typography>
|
||
</div>
|
||
</div>
|
||
<div className="p-3 space-y-4">
|
||
|
||
{/* 목표가 산정 후보 — 후보 택1로 목표가 결정(기본=최저). 네고율 차감 토글은 매입가·판매가 공통이라 리스트 상단에 둔다. 숨김필드는 제외. */}
|
||
{productId && targetBreakdown.length > 0 && (
|
||
<div className="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="flex items-center justify-between gap-2">
|
||
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground leading-snug">
|
||
후보를 선택하면 그 값이 목표가로 정해집니다 (기본: 최저).
|
||
</Typography>
|
||
{negoToggleAvailable && (
|
||
<label className="flex items-center gap-1 shrink-0 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
className="h-3.5 w-3.5 accent-primary cursor-pointer"
|
||
checked={effectiveApplyNego}
|
||
onChange={(e) => {
|
||
setNegoTouched(true);
|
||
setApplyNego(e.target.checked);
|
||
}}
|
||
/>
|
||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground whitespace-nowrap">네고율 {settingMarginPct}% 차감</Typography>
|
||
</label>
|
||
)}
|
||
</div>
|
||
<div className="space-y-1" role="radiogroup">
|
||
{targetBreakdown.map((c) => {
|
||
const isMin = autoTarget != null && c.value === autoTarget;
|
||
const isActive = c.key === activeCandidateKey;
|
||
return (
|
||
<div
|
||
key={c.key}
|
||
role="radio"
|
||
aria-checked={isActive}
|
||
tabIndex={0}
|
||
onClick={() => selectCandidate(c.key)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault();
|
||
selectCandidate(c.key);
|
||
}
|
||
}}
|
||
className={cn(
|
||
'flex items-center gap-2 rounded px-2 py-1.5 cursor-pointer transition-colors',
|
||
isActive ? 'bg-primary/5 ring-1 ring-inset ring-primary' : 'hover:bg-muted/40',
|
||
)}
|
||
>
|
||
{/* 라디오 표시 */}
|
||
<span className={cn('grid place-items-center h-3.5 w-3.5 shrink-0 rounded-full border', isActive ? 'border-primary' : 'border-muted-foreground/40')}>
|
||
{isActive && <span className="h-1.5 w-1.5 rounded-full bg-primary" />}
|
||
</span>
|
||
<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', isActive && 'text-primary')}>₩{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순위)로 입력됩니다. 수정 가능.'
|
||
: '자동 산출값이 없어 직접 입력이 필요합니다.'}
|
||
{vatUnified && ' 금액은 VAT 별도(제외) 기준입니다.'}
|
||
</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.5">
|
||
<div className="flex items-center justify-between">
|
||
<Typography as="label" variant="label">타결 상한가</Typography>
|
||
{/* OFF=세팅 기본율 그대로 · ON=이 견적만 직접 지정 */}
|
||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">이 견적만 조정</Typography>
|
||
<Switch
|
||
checked={ceilingTouched}
|
||
onCheckedChange={(on) => {
|
||
setCeilingTouched(on);
|
||
if (on && ceilingPct === '') setCeilingPct(String(settingCeilingRate / 10)); // 켤 때 세팅값에서 출발
|
||
}}
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
{ceilingTouched ? (
|
||
<div className="flex items-center gap-1.5">
|
||
<Typography as="span" variant="small" className="text-[11px] text-muted-foreground">목표가 +</Typography>
|
||
<Input
|
||
type="number"
|
||
step="0.5"
|
||
min={0}
|
||
className="h-8 w-20 text-xs text-right"
|
||
value={ceilingPct}
|
||
onChange={(e) => setCeilingPct(e.target.value)}
|
||
/>
|
||
<Typography as="span" variant="small" className="text-[11px] text-muted-foreground">%</Typography>
|
||
</div>
|
||
) : (
|
||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">
|
||
세팅 기본 <span className="font-semibold text-foreground">목표가 +{settingCeilingRate / 10}%</span> 적용
|
||
</Typography>
|
||
)}
|
||
|
||
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground leading-snug">
|
||
{doneCeilingPrice != null ? (
|
||
<>최종 합의가가 <span className="font-bold text-foreground">₩{doneCeilingPrice.toLocaleString()}</span> 이하면 타결, 초과하면 결렬.</>
|
||
) : '목표가가 정해지면 타결 상한가가 자동 계산됩니다.'}
|
||
</Typography>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── 낙찰 기준 (마감) — 타결된 투찰가로 누구를 낙찰시킬지 ── */}
|
||
<div className="rounded-lg border border-border overflow-hidden">
|
||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border bg-muted/30">
|
||
<span className="grid place-items-center h-5 w-5 rounded-md bg-primary/10 text-primary shrink-0"><Gavel size={12} /></span>
|
||
<div className="min-w-0">
|
||
<Typography as="span" variant="small" className="font-bold block leading-tight">낙찰 기준 <span className="text-muted-foreground font-normal text-[10px]">· 마감</span></Typography>
|
||
<Typography as="span" variant="small" className="text-muted-foreground text-[10px] block leading-tight">타결된 투찰가로 마감 때 누구를 낙찰시킬지 정합니다</Typography>
|
||
</div>
|
||
</div>
|
||
<div className="p-3">
|
||
{oneToOne ? (
|
||
/* 스펙트럼 = 선택. 낙찰선을 앵커/목표가 중 택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="flex items-start gap-2.5">
|
||
<Gavel size={16} 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>
|
||
)}
|
||
</div>
|
||
</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>
|
||
);
|
||
}
|