import { Link } from 'react-router'; import { Download, Sparkles } from 'lucide-react'; import type { SessionData } from '@/api/generated/model/sessionData'; import type { QuotationCardData } from '@/api/generated/model/quotationCardData'; import type { ChatMessageData } from '@/api/generated/model/chatMessageData'; import { ChatSender, CardType } from '@/api/generated/model'; import SlateRenderer from '@/components/SlateRenderer'; import { Typography } from '@/components/ui/typography'; import { StatusPill, sessionStatusTone } from './StatusPill'; import { type Product, type Partner, sessionStatusLabel } from '../../types'; import { maskPrices } from '@/lib/utils'; import { useCompanySettings } from '@/features/settings/useCompanySettings'; import { renderEmphasis } from '@/lib/emphasis'; // 협상로그 JSON 다운로드(IMK #9). 가격·비율 숫자는 maskPrices 로 가려 내보낸다(화면 표기와 동일 규칙). // target_price 등 숫자 필드는 아예 제외 — 양식은 대화 흐름(순번/발화자/스텝/멘트/카드사용) 중심. function exportChatJson( session: SessionData | undefined, supplierName: string, productName: string | undefined, messages: ChatMessageData[], serverCards: QuotationCardData[], ) { const payload = { exported_at: new Date().toISOString(), session_id: session?.session_id ?? null, qt_number: session?.qt_number ?? null, supplier: supplierName || null, product: productName ?? null, status: session ? sessionStatusLabel(session.status) : null, message_count: messages.length, messages: messages.map((m) => { // 사용 카드는 화면 버블과 동일하게 card_id(=카드 PK) 로 매칭. session_card_id 는 nego/wild 카드 PK. const card = m.card_used_yn ? serverCards.find((c) => c.session_card_id === m.card_id) : undefined; return { seq: m.index, sender: m.sender === ChatSender.BOT ? 'BOT' : 'PARTNER', step: m.step ?? null, script: maskPrices(String(m.script ?? '')), card: card ? { number: card.number ?? null, name: card.name ?? null, type: card.type === CardType.WILD ? 'wild' : 'nego', } : null, }; }), }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `협상로그_${session?.qt_number ?? 'session'}_${(supplierName || '').replace(/\s+/g, '')}.json`; a.click(); URL.revokeObjectURL(url); } export function ChatTab({ serverSessions, partners, effectiveSessionId, onSelectSession, chatMessages, currentSupplierName, currentProduct, serverCards, }: { serverSessions: SessionData[]; partners: Partner[]; effectiveSessionId: string | null; onSelectSession: (sessionId: string) => void; chatMessages: ChatMessageData[]; currentSupplierName: string; currentProduct: Product | undefined; serverCards: QuotationCardData[]; }) { // 목표가는 협상(세션) 단위 고정값(sessions.target_price)이라 메시지마다가 아니라 헤더에 한 번만 표시한다. const currentSession = serverSessions.find((s) => s.session_id === effectiveSessionId); const targetPrice = currentSession?.target_price; // 협상완료 부가정보(sessions.custom) — 공급사가 타결 후 입력. 라벨은 회사 설정(session_fields)에서. const { settings } = useCompanySettings(); const sessionFields = settings.session_fields ?? []; const extraRows = (sd: SessionData | null) => { const custom = sd?.custom as Record | undefined; const rows = sessionFields .map((f) => ({ label: f.label, value: custom?.[f.key] })) .filter((r) => r.value !== undefined && r.value !== null && r.value !== ''); // 의견은 회사설정과 무관한 내장 공통 필드(custom.opinion) — 값 있으면 항상 표시 if (custom?.opinion) rows.push({ label: '의견', value: custom.opinion }); return rows; }; return (
{/* Sessions list */}
참여자 협력사 리스트
{serverSessions.length === 0 && ( 참여 협상 세션이 없습니다. (리스트가 비어 있습니다) )} {serverSessions.map((sd) => { const isSelected = sd.session_id === effectiveSessionId; const name = partners.find((p) => p.id === sd.supplier_id)?.name || sd.supplier_id; const statusLabel = sessionStatusLabel(sd.status); return (
onSelectSession(sd.session_id)} className={`w-full text-left p-3 flex flex-col justify-between transition-colors cursor-pointer ${ isSelected ? 'bg-primary/5 border-l-4 border-primary' : 'hover:bg-muted/30' }`} >
{name} {statusLabel}
최종 제의 {sd.bid_price ? `₩${Number(sd.bid_price).toLocaleString()}` : '-'}
); })}
{/* Chat zone */}
협력사: {currentSupplierName}
{targetPrice != null && ( 목표가: ₩{Number(targetPrice).toLocaleString()} )} 기록: {chatMessages.length} 메시지
{/* 채팅에선 선택 세션의 부가정보를 밴드로 전부 노출. 리스트 버튼(모달)과 병행. */} {extraRows(currentSession ?? null).length > 0 && (
협상완료 부가정보 {extraRows(currentSession ?? null).map((r) => ( {r.label}: {String(r.value)} ))}
)}
{!effectiveSessionId ? ( 선택된 협력사가 없습니다. ) : chatMessages.length === 0 ? ( 기록된 협상 대화가 없습니다. ) : ( chatMessages.map((m) => m.sender === ChatSender.BOT ? ( ) : ( ), ) )}
); } // 봇(좌측) 말풍선. 진행 단계·협상 스크립트(가격 마스킹)·사용 협상카드를 보여준다. // 간격은 부모(flex flex-col gap)에서 주고, 말풍선 박스는 block 으로 둬 긴 텍스트 줄바꿈이 깨지지 않게 한다. function BotBubble({ message, currentSupplierName, currentProduct, serverCards, }: { message: ChatMessageData; currentSupplierName: string; currentProduct: Product | undefined; serverCards: QuotationCardData[]; }) { const m = message; // 카드 사용 메시지는 같은 멘트가 아래 카드 박스에 그대로 나오므로 본문 script 는 중복 → 카드 없을 때만 노출. const usedCard = findUsedCard(m, serverCards); return (
Negosium Bot · #{m.index}
{m.step && ( {m.step} )} {m.script && !usedCard && ( {renderEmphasis(maskPrices(m.script))} )}
); } // 협력사(우측) 말풍선. 협력사 입력값을 보여주되, 채팅 '내용'에 제시 금액이 노출되지 않도록 maskPrices 로 가린다. function PartnerBubble({ message, currentSupplierName, currentProduct, serverCards, }: { message: ChatMessageData; currentSupplierName: string; currentProduct: Product | undefined; serverCards: QuotationCardData[]; }) { const m = message; return (
{currentSupplierName} · #{m.index}
{m.step && ( {m.step} )} {m.script && ( {maskPrices(m.script)} )}
); } // 말풍선에 붙는 협상카드 박스(봇/협력사 공용). 카드 미사용 메시지면 아무것도 렌더하지 않는다. // 톤(amber/primary)만 isBot 으로 가르고, 멘트/조건/메모 렌더 로직은 공유한다. function UsedCardBox({ message, isBot, currentSupplierName, currentProduct, serverCards, }: { message: ChatMessageData; isBot: boolean; currentSupplierName: string; currentProduct: Product | undefined; serverCards: QuotationCardData[]; }) { const m = message; const usedCard = findUsedCard(m, serverCards); if (!usedCard) return null; const cardNodes = Array.isArray(usedCard.edit_script) ? (usedCard.edit_script as unknown[]) : null; const isWildCard = usedCard.type === CardType.WILD; const cardDetailId = usedCard.nego_card_id ?? usedCard.wild_card_id ?? null; // 협상카드 상세(/cards?detail=) 링크용 return (
{/* 헤더: 어떤 카드인지(번호·이름·종류) */}
협상카드 {cardDetailId ? ( {usedCard.number && ( #{usedCard.number} )} {usedCard.name && ( · {usedCard.name} )} ) : ( <> {usedCard.number && ( #{usedCard.number} )} {usedCard.name && ( · {usedCard.name} )} )} {isWildCard ? '와일드' : '협상'}
{/* 멘트 본문: 서식본(edit_script) 우선, 없으면 평문 script. 가격 변수는 매핑하지 않아(값 미주입) 실가격이 노출되지 않는다. */} {cardNodes ? (
) : usedCard.script ? ( {maskPrices(usedCard.script)} ) : null} {/* 와일드카드 부가 정보: 사용 조건 / 메모 */} {isWildCard && (usedCard.condition || usedCard.memo) && (
{usedCard.condition && ( 조건: {usedCard.condition} )} {usedCard.memo && ( 메모: {usedCard.memo} )}
)}
); } // 메시지가 사용한 협상카드를 card_id(=카드 PK)로 매칭. 카드 미사용이면 undefined. function findUsedCard(m: ChatMessageData, serverCards: QuotationCardData[]): QuotationCardData | undefined { return m.card_used_yn ? serverCards.find((c) => c.session_card_id === m.card_id) : undefined; }