import { useEffect, useState } from 'react'; import { useListItems } from '@/api/generated/item/item'; import { useListSuppliers } from '@/api/generated/supplier/supplier'; import { useListSettings } from '@/api/generated/quotation-setting/quotation-setting'; import { useListQuotations } from '@/api/generated/quotation/quotation'; import type { ItemResponse } from '@/api/generated/model/itemResponse'; import type { SupplierResponse } from '@/api/generated/model/supplierResponse'; import type { QuotationSettingResponse } from '@/api/generated/model/quotationSettingResponse'; import type { QuotationResponse } from '@/api/generated/model/quotationResponse'; import { showToast } from '@/lib/notify'; import type { Estimate, QuotationSetting, ChatSession, NegotiationCard, Product } from '@/types'; import { unwrap, mapItem, mapSupplier, mapSetting, mapQuotation } from '../types'; export type CreateQuotationInput = { title: string; type: 'RE_NEGOTIATION' | 'RE_ESTIMATE'; productId: string; partnerIds: string[]; dueDate: string; // datetime-local 원본값 settingId: string; cardIds: string[]; }; export type SettingInput = { targetMargin: string; anchoringValue: string; cardUseCount: string; }; // 견적 화면 데이터 허브. // 상품/협력사/세팅/견적은 서버(orval)에서 읽고, 견적·세팅·채팅은 로컬 state로 낙관적 갱신한다. // (협상카드 카탈로그/채팅은 백엔드 미연동 → 빈 상태) export function useQuotations() { const itemsQuery = useListItems({ size: 100 }); const suppliersQuery = useListSuppliers({ size: 100 }); const settingsQuery = useListSettings(); const quotationsQuery = useListQuotations(undefined); const products = (unwrap<{ items?: ItemResponse[] }>(itemsQuery.data)?.items ?? []).map(mapItem); const partners = (unwrap<{ suppliers?: SupplierResponse[] }>(suppliersQuery.data)?.suppliers ?? []).map(mapSupplier); const [quotationSettings, setQuotationSettings] = useState([]); useEffect(() => { const s = unwrap<{ settings?: QuotationSettingResponse[] }>(settingsQuery.data)?.settings; if (s) setQuotationSettings(s.map(mapSetting)); }, [settingsQuery.data]); const [quotations, setQuotations] = useState([]); useEffect(() => { const qs = unwrap<{ quotations?: QuotationResponse[] }>(quotationsQuery.data)?.quotations; if (qs) setQuotations(qs.map(mapQuotation)); }, [quotationsQuery.data]); // 협상카드 카탈로그/채팅은 아직 백엔드 미연동 → 빈 상태. const [cards] = useState([]); const [chatSessions, setChatSessions] = useState>({}); // '견적생성(PROCESSING)' 견적을 3초 후 '견적진행중'으로 전이시키는 폴링 시뮬레이션. useEffect(() => { const processingItem = quotations.find( (est) => est.status === 'PROCESSING' || est.status === '견적생성', ); if (!processingItem) return; const timer = setTimeout(() => { setQuotations((prev) => prev.map((est) => { if (est.status === 'PROCESSING' || est.status === '견적생성') { showToast(`[${est.title}] 견적 절차가 개시되었습니다. 파트너 입찰 채널이 활성화되었습니다.`, 'success'); return { ...est, status: '견적진행중' }; } return est; }), ); }, 3000); return () => clearTimeout(timer); }, [quotations]); // 협상 강제중단 → '협상보류'. const stopNegotiation = (id: string, name: string) => { if (!confirm(`현재 입찰 중인 [${name}] 단가 협상 절차를 즉시 조기 중단(강제종료)하시겠습니까?`)) return; setQuotations((prev) => prev.map((e) => (e.id === id ? { ...e, status: '협상보류' } : e))); showToast("해당 협상이 관리자에 의하여 '협상보류' 상태로 지정되었습니다.", 'info'); }; // 견적 세팅 추가. 검증 실패 시 toast 후 false. const addSetting = (input: SettingInput): boolean => { if (!input.targetMargin.trim() || !input.anchoringValue.trim() || !input.cardUseCount.trim()) { showToast('모든 세팅 항목을 입력해야 합니다.', 'error'); return false; } const newSetting: QuotationSetting = { qt_setting_id: `set-${Date.now()}`, user_id: 'user-777-uuid', target_margin: input.targetMargin.includes('%') ? input.targetMargin : `${input.targetMargin}%`, anchoring_value: input.anchoringValue, card_use_count: input.cardUseCount.includes('회') ? input.cardUseCount : `${input.cardUseCount}회`, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), deleted: false, }; setQuotationSettings((prev) => [...prev, newSetting]); showToast('새 견적 세팅 템플릿이 추가되었습니다.', 'success'); return true; }; // 견적 세팅 삭제(최소 1개 유지). const deleteSetting = (id: string) => { if (quotationSettings.length <= 1) { showToast('최소 한 개의 세팅은 유지되어야 합니다.', 'error'); return; } if (!confirm('선택한 견적 세팅을 삭제하시겠습니까?')) return; setQuotationSettings((prev) => prev.filter((qs) => qs.qt_setting_id !== id)); showToast('세팅이 삭제되었습니다.', 'info'); }; // 신규 견적 발의 — 견적 레코드 + 협력사별 채팅 세션을 생성. 검증 실패 시 toast 후 false. const createQuotation = (input: CreateQuotationInput): boolean => { if (!input.title.trim()) { showToast('견적 건명을 올바르게 작성해 주세요.', 'error'); return false; } if (!input.productId) { showToast('협상 대상 품목을 지정하지 않았습니다.', 'error'); return false; } if (input.partnerIds.length === 0) { showToast('최소 한 곳 이상의 벤더사(참여 협력사)를 선정해 주세요.', 'error'); return false; } const targetProduct = products.find((p) => p.id === input.productId); const estId = `est-${Date.now()}`; const newEstNumber = `EST-202606-${Math.floor(1000 + Math.random() * 9000)}`; const newQuotationRecord: Estimate = { id: estId, title: input.title, number: newEstNumber, type: input.type, round: 1, status: '견적생성', dueDate: input.dueDate.replace('T', ' '), participationCount: input.partnerIds.length, productId: input.productId, partnerIds: input.partnerIds, settingApplied: input.settingId, usedCardIds: input.cardIds, winnerPartnerId: undefined, finalPrice: undefined, isEqualPrice: false, }; const generatedSessions: ChatSession[] = input.partnerIds.map((pId) => buildInitialSession(pId, estId, partners, targetProduct), ); setChatSessions((prev) => ({ ...prev, [estId]: generatedSessions })); setQuotations((prev) => [newQuotationRecord, ...prev]); showToast(`신규 견정 제안서[${input.title}]가 발행되었습니다. 협상 세션 수립 중... (폴링 가동)`, 'info'); return true; }; return { products, partners, cards, quotations, quotationSettings, chatSessions, stopNegotiation, addSetting, deleteSetting, createQuotation, }; } // 참여 협력사 1곳에 대한 초기 채팅 세션(시스템/봇/협력사 메시지) 생성. function buildInitialSession( pId: string, estId: string, partners: ReturnType[], targetProduct: Product | undefined, ): ChatSession { const partnerObj = partners.find((part) => part.id === pId); const partnerName = partnerObj?.name ?? '미지정 파트너'; const initialPartnerBid = Math.round((targetProduct?.price || 1000000) * (0.95 + Math.random() * 0.1)); const targetNegoPrice = Math.round((targetProduct?.price || 1000000) * 0.9); return { id: pId, partnerName, status: 'NEGOTIATING', currentBid: initialPartnerBid, bidTime: '2026-06-11 01:10', messages: [ { id: `msg-${estId}-sys`, sender: 'SYSTEM', timestamp: '2026-06-11 01:00', content: `사전에 조율된 [${targetProduct?.name}] 입찰 참여 세션이 개시되었습니다.`, }, { id: `msg-${estId}-bot`, sender: 'BOT', timestamp: '2026-06-11 01:02', content: '안녕하세요, 구매 대행 봇입니다.', editorScript: [ { type: 'paragraph', children: [ { text: `${partnerName} 조달담당자님 안녕하십니까.\n` }, { text: `이번에 진행되는 ${targetProduct?.name} 품목에 대해 당사 타깃가는 ` }, { text: `${targetNegoPrice.toLocaleString('ko-KR')}원`, bold: true, color: 'primary' }, { text: ' 수준입니다. 상호 이익 극대화를 위해 적합 단가를 제안해주시면 검토 후 즉시 타결 또는 와일드카드 혜택이 적용됩니다.' }, ], }, ], }, { id: `msg-${estId}-part`, sender: 'PARTNER', timestamp: '2026-06-11 01:05', content: `안녕하세요. 본 제품은 제조 원가 고사양으로 인해 타사 대비 높은 정밀도가 들어갑니다. 우선 1차 투찰로 ${initialPartnerBid.toLocaleString()}원을 입력 제출합니다.`, }, ], }; }