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; 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('nego'); // 1:1 협상 / 1:N 경매 const [isNew, setIsNew] = useState(true); // 신규 / 후속(재) const [productId, setProductId] = useState(''); const [selectedPartnerIds, setSelectedPartnerIds] = useState([]); const [dueDate, setDueDate] = useState(defaultDueDateLocalInput); const [settingId, setSettingId] = useState(quotationSettings[0]?.qt_setting_id ?? ''); const [selectedCardIds, setSelectedCardIds] = useState([]); // 선택 항목 표시데이터 캐시 — 담는 순간 이름/이메일·카드메타를 적재해, 검색어가 바뀌어 콤보 목록에서 빠져도 아래 선택 테이블이 유지되게 한다. const [partnerDetails, setPartnerDetails] = useState>(() => new Map()); const [cardDetails, setCardDetails] = useState>(() => new Map()); const [memo, setMemo] = useState(''); const [mdPrice, setMdPrice] = useState(''); // MD 제시가(원). 비우면 미전송 → 서버가 상품값으로 목표가 산정 const [midAction, setMidAction] = useState(DEFAULT_MID_ACTION); // 앵커~목표가 구간: 낙찰/개찰 (1:1 전용) const [overAction, setOverAction] = useState(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(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(); (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: (
{s.name} 이메일: {s.email}
{productId && }
), })); // 선택된 협력사 표시행 — 이름/이메일은 캐시에서, 취급유형은 상품 매핑(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 (
{submitting && (
견적 생성 중… 견적 · 협상 세션 등록 중
)}
{/* Header */}
신규 견적 등록 (단계 {step}/{totalSteps})
{/* Steps indicator — 스텝 수는 유형에 따라 3(경매)/4(협상) */}
{steps.map((stepLabel, i) => { const n = i + 1; return (
{i > 0 && }
); })}
{/* Step content — 남은 높이를 채우고 내용이 길면 여기만 스크롤(작은 화면 대응) */}
{step === 1 && (
{/* 진행 방식 × 대상 2축 — 협상/경매 갈림이 아래 카드·낙찰기준 노출까지 결정한다 */}
진행 방식 selectMode(v as QuotationMode)} />
대상 setIsNew(v === 'new')} />
{label('quotation.due_date')} setDueDate(e.target.value)} />
{label('quotation.title')} setTitle(e.target.value)} placeholder="예: 6월 배터리 원부자재 견적 의뢰" />
상품 { setProductId(opt.id); setProductLabel(opt.label); }} placeholder="협상 대상 상품을 고르세요..." searchPlaceholder="상품명·코드로 검색..." emptyText="일치하는 상품이 없습니다" />
)} {step === 2 && (
협력사 초청 ({oneToOne ? '단일선택' : '다중선택'}) {/* 서버검색 다중선택 — 각 행에 선택 상품 상품조달유형 배지(미매핑=미정). oneToOne이면 togglePartner가 단일로 강제. */} togglePartner(opt.id)} searchPlaceholder="협력사명·코드·담당자 검색..." emptyText="협력사가 없습니다" maxListHeight="max-h-56" /> {/* 선택 목록 — 검색어가 바뀌어도 담은 협력사가 유지되는 고정 테이블(취급유형은 상품×협력사 매핑). */}
담긴 협력사 {selectedPartnerRows.length}곳
)} {step === 3 && (
적용할 견적 세팅 지정
{/* ── 타결 기준 (협상) — 봇이 어느 가격까지 합의하면 타결로 볼지 ── */}
타결 기준 · 협상 봇이 어느 가격까지 합의하면 타결로 볼지 정합니다
{/* 목표가 산정 후보 — 후보 택1로 목표가 결정(기본=최저). 네고율 차감 토글은 매입가·판매가 공통이라 리스트 상단에 둔다. 숨김필드는 제외. */} {productId && targetBreakdown.length > 0 && (
목표가 산정 후보 ({isReType ? '재' : '신규'})
후보를 선택하면 그 값이 목표가로 정해집니다 (기본: 최저). {negoToggleAvailable && ( )}
{targetBreakdown.map((c) => { const isMin = autoTarget != null && c.value === autoTarget; const isActive = c.key === activeCandidateKey; return (
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', )} > {/* 라디오 표시 */} {isActive && } {c.label} {c.raw.toLocaleString()} ₩{c.value.toLocaleString()} {isMin && 최저}
); })}
)} {/* 목표가(구매담당자 제시가) — 후보 최저를 자동 입력. 값이 높든 낮든 이 값이 1순위(실제 목표가). 수정 가능. */}
목표가 (구매담당자 제시가) {mdRequired ? '(필수 — 산정값 없음)' : ''} { setMdTouched(true); setMdPrice(e.target.value); }} placeholder={mdRequired ? '상품에 산정값이 없어 직접 입력이 필요합니다' : '자동 산출값 · 수정 가능'} /> {autoTarget != null ? '위에서 선택한 후보값이 목표가(1순위)로 입력됩니다. 수정 가능.' : '자동 산출값이 없어 직접 입력이 필요합니다.'} {vatUnified && ' 금액은 VAT 별도(제외) 기준입니다.'} {!targetReady && ( ⚠ 목표가를 산정할 값이 없습니다 — 직접 입력하거나 상품 상세에서 인터넷최저가·매입가를 채워주세요. )} {targetLimitExceeded && ( 목표가 ₩{estimatedTargetPrice?.toLocaleString()}이 상품단가의 2배를 초과합니다 — 조정해 주세요. )}
{/* 타결 상한 — 목표가 초과 허용폭. 봇이 이 이하로 합의하면 타결, 초과하면 결렬. 비우면 세팅 기본율. */}
타결 상한가 {/* OFF=세팅 기본율 그대로 · ON=이 견적만 직접 지정 */}
{ceilingTouched ? (
목표가 + setCeilingPct(e.target.value)} /> %
) : ( 세팅 기본 목표가 +{settingCeilingRate / 10}% 적용 )} {doneCeilingPrice != null ? ( <>최종 합의가가 ₩{doneCeilingPrice.toLocaleString()} 이하면 타결, 초과하면 결렬. ) : '목표가가 정해지면 타결 상한가가 자동 계산됩니다.'}
{/* ── 낙찰 기준 (마감) — 타결된 투찰가로 누구를 낙찰시킬지 ── */}
낙찰 기준 · 마감 타결된 투찰가로 마감 때 누구를 낙찰시킬지 정합니다
{oneToOne ? ( /* 스펙트럼 = 선택. 낙찰선을 앵커/목표가 중 택1, 목표가 초과는 항상 개찰. */ { setMidAction(v); if (v === PriceGateAction.OPEN) setOverAction(PriceGateAction.OPEN); }} onOver={(v) => { setOverAction(v); if (v === PriceGateAction.AWARD) setMidAction(PriceGateAction.AWARD); }} /> ) : ( /* 경매(1:N) — 최저가 자동 낙찰. */
최저가 자동 낙찰 1:N 견적은 가장 낮은 투찰가가 자동 낙찰됩니다. 별도 낙찰 기준 설정이 없습니다.
)}
협력사 안내 메모 (선택)