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; 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 [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(); (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; // ── 카드 선택 게이팅: 협상 멘트에 변수명이 노출될 카드를 선택 단계에서 막는다 ── // (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: (
{card.usedCount > 0 && ( {i + 1}위 · 성공률 {Math.round(card.successRate * 100)}% )} {card.code} {card.isWildcard ? '와일드' : '협상'} {reason && ( {reason} )}
{card.title}
), }; }); // 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 (
{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 && (
적용할 견적 세팅 지정
{oneToOne ? ( <> {/* 낙찰 기준(1:1 전용) — 스펙트럼 = 선택. 낙찰선을 앵커/목표가 중 택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 견적은 가장 낮은 투찰가가 자동 낙찰됩니다. 낙찰 기준·협상카드 설정이 없습니다.
)} {/* 목표가 산정 후보(계산식+결과값) — 참고용, 위. 채택된 최저는 녹색 강조. */} {productId && targetBreakdown.length > 0 && (
목표가 산정 후보 ({isReType ? '재' : '신규'})
{targetBreakdown.map((c) => { const isMin = autoTarget != null && c.value === autoTarget; return (
{c.label} {c.raw.toLocaleString()} ₩{c.value.toLocaleString()} {isMin && 최저}
); })}
)} {/* 목표가(구매담당자 제시가) — 후보 최저를 자동 입력. 값이 높든 낮든 이 값이 1순위(실제 목표가). 수정 가능. */}
목표가 (구매담당자 제시가) {mdRequired ? '(필수 — 산정값 없음)' : ''} { setMdTouched(true); setMdPrice(e.target.value); }} placeholder={mdRequired ? '상품에 산정값이 없어 직접 입력이 필요합니다' : '자동 산출값 · 수정 가능'} /> {autoTarget != null ? '후보 중 최저가 자동 입력됨 · 이 값이 목표가(1순위)로 쓰입니다. 수정 가능.' : '자동 산출값이 없어 직접 입력이 필요합니다.'} {!targetReady && ( ⚠ 목표가를 산정할 값이 없습니다 — 직접 입력하거나 상품 상세에서 인터넷최저가·매입가를 채워주세요. )} {targetLimitExceeded && ( 목표가 ₩{estimatedTargetPrice?.toLocaleString()}이 상품단가의 2배를 초과합니다 — 조정해 주세요. )}
메모 (선택)