표현 아키텍처: agent 스크립트는 의미(텍스트)만 소유, 표현(굵기·색)은 프론트 소유.
Slate 는 negodata 에디터 내부에만 두고, 전송/저장은 마커 문자열 한 벌(구버전의 리치텍스트 이중관리 폐기).
- 트랙 A (negodata): serializeToMarker 추가 — Slate 마크(bold/underline/color)를 **/__/{{토큰}} 로
인코딩해 nego_cards.script 저장. edit_script(Slate 원본)는 재편집 전용. 고정 3색 → 시맨틱 토큰(강조/안내).
- 트랙 B (agent): 카드 멘트 DB 소스 — ICardScriptRepository/CardScriptDbRepository(port+adapter),
ScriptRepository.resolve_card_script 가 cards.source_type=backoffice_db 면 card.nego_cards.script 우선,
없으면 파일 폴백. action_id→card_id→nego_cards.number 매칭.
- 트랙 C (양 프론트): renderEmphasis 재귀 파서 — **굵게**·__밑줄__·{{강조|빨강}}·{{안내|파랑}} 중첩 렌더.
색은 시맨틱 토큰→디자인 토큰 클래스(다크모드 안전). negodata tokens.css 에 --info 신설. CardTable 미리보기 적용.
- supplier_items 연동: 유통코드=supplier_items.supply_type(→quotations.supplier_type 폴백),
파트너유형=상품별 매핑 협력사 수(→세션 이력 폴백).
- 가격 수용률: 기존 공급가(item_price) 기준 양보율로 정정 — 첫 라운드부터 실값(첫 제시가 기준 0 아님).
테스트: agent 86/86, 공급사 frontend·negodata front tsc 통과.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
314 lines
14 KiB
TypeScript
314 lines
14 KiB
TypeScript
import { 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 { renderEmphasis } from '@/lib/emphasis';
|
|
|
|
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;
|
|
return (
|
|
<div className="h-[500px] border border-border rounded-lg overflow-hidden bg-card flex">
|
|
{/* Sessions list */}
|
|
<div className="w-1/3 border-r border-border bg-muted/20 flex flex-col">
|
|
<Typography as="div" variant="mono" className="p-3 border-b border-border bg-muted/40 text-[10px]">
|
|
참여자 협력사 리스트
|
|
</Typography>
|
|
<div className="flex-1 min-h-0 overflow-y-auto divide-y divide-border font-sans">
|
|
{serverSessions.length === 0 && (
|
|
<Typography as="div" variant="small" className="p-4 text-center text-muted-foreground text-xs font-mono">
|
|
참여 협상 세션이 없습니다. (리스트가 비어 있습니다)
|
|
</Typography>
|
|
)}
|
|
{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 (
|
|
<button
|
|
key={sd.session_id}
|
|
onClick={() => 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'
|
|
}`}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<Typography as="span" variant="small" className="text-xs font-bold text-foreground">{name}</Typography>
|
|
<StatusPill tone={sessionStatusTone(sd.status)} className="text-[9px] px-1.5 rounded">
|
|
{statusLabel}
|
|
</StatusPill>
|
|
</div>
|
|
<div className="flex items-center justify-between mt-2">
|
|
<Typography as="span" variant="small" className="text-[10px] font-mono text-muted-foreground">최종 제의</Typography>
|
|
<Typography as="span" variant="small" className="text-[10px] font-mono font-bold text-foreground">
|
|
{sd.bid_price ? `₩${Number(sd.bid_price).toLocaleString()}` : '-'}
|
|
</Typography>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Chat zone */}
|
|
<div className="flex-1 flex flex-col bg-background justify-between">
|
|
<div className="p-3 bg-muted/30 border-b border-border flex items-center justify-between">
|
|
<Typography as="div" variant="small" className="text-xs font-mono text-muted-foreground">
|
|
협력사: <Typography as="span" variant="small" className="text-xs font-mono font-bold text-foreground">{currentSupplierName}</Typography>
|
|
</Typography>
|
|
<div className="flex items-center gap-4">
|
|
{targetPrice != null && (
|
|
<Typography as="span" variant="small" className="text-xs font-mono text-muted-foreground">
|
|
목표가: <Typography as="span" variant="small" className="text-xs font-mono font-bold text-foreground">₩{Number(targetPrice).toLocaleString()}</Typography>
|
|
</Typography>
|
|
)}
|
|
<Typography as="span" variant="small" className="text-xs font-mono text-muted-foreground">
|
|
기록: <Typography as="span" variant="small" className="text-xs font-mono font-semibold text-foreground">{chatMessages.length}</Typography> 메시지
|
|
</Typography>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 min-h-0 p-4 overflow-y-auto space-y-4">
|
|
{!effectiveSessionId ? (
|
|
<Typography as="div" variant="small" className="h-full flex items-center justify-center text-muted-foreground font-mono text-xs">
|
|
선택된 협력사가 없습니다.
|
|
</Typography>
|
|
) : chatMessages.length === 0 ? (
|
|
<Typography as="div" variant="small" className="h-full flex items-center justify-center text-muted-foreground font-mono text-xs">
|
|
기록된 협상 대화가 없습니다.
|
|
</Typography>
|
|
) : (
|
|
chatMessages.map((m) =>
|
|
m.sender === ChatSender.BOT ? (
|
|
<BotBubble
|
|
key={m.chat_id}
|
|
message={m}
|
|
currentSupplierName={currentSupplierName}
|
|
currentProduct={currentProduct}
|
|
serverCards={serverCards}
|
|
/>
|
|
) : (
|
|
<PartnerBubble
|
|
key={m.chat_id}
|
|
message={m}
|
|
currentSupplierName={currentSupplierName}
|
|
currentProduct={currentProduct}
|
|
serverCards={serverCards}
|
|
/>
|
|
),
|
|
)
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 봇(좌측) 말풍선. 진행 단계·협상 스크립트(가격 마스킹)·사용 협상카드를 보여준다.
|
|
// 간격은 부모(flex flex-col gap)에서 주고, 말풍선 박스는 block 으로 둬 긴 텍스트 줄바꿈이 깨지지 않게 한다.
|
|
function BotBubble({
|
|
message,
|
|
currentSupplierName,
|
|
currentProduct,
|
|
serverCards,
|
|
}: {
|
|
message: ChatMessageData;
|
|
currentSupplierName: string;
|
|
currentProduct: Product | undefined;
|
|
serverCards: QuotationCardData[];
|
|
}) {
|
|
const m = message;
|
|
return (
|
|
<div className="flex justify-start">
|
|
<div className="flex flex-col gap-1.5 max-w-[85%]">
|
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
|
<Typography as="span" variant="small" className="text-[10px] font-mono text-inherit">Negosium Bot</Typography>
|
|
<Typography as="span" variant="small" className="text-[10px] font-mono text-inherit">·</Typography>
|
|
<Typography as="span" variant="small" className="text-[10px] font-mono text-inherit">#{m.index}</Typography>
|
|
</div>
|
|
<div className="p-3 rounded-md border shadow-xs bg-secondary border-border text-foreground space-y-1.5">
|
|
{m.step && (
|
|
<Typography as="div" variant="mono" className="text-[10px] tracking-wide opacity-60 text-inherit">
|
|
{m.step}
|
|
</Typography>
|
|
)}
|
|
{m.script && (
|
|
<Typography as="p" variant="small" className="text-xs whitespace-pre-line leading-relaxed text-inherit">
|
|
{renderEmphasis(maskPrices(m.script))}
|
|
</Typography>
|
|
)}
|
|
<UsedCardBox
|
|
message={m}
|
|
isBot
|
|
currentSupplierName={currentSupplierName}
|
|
currentProduct={currentProduct}
|
|
serverCards={serverCards}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 협력사(우측) 말풍선. 협력사 입력값을 보여주되, 채팅 '내용'에 제시 금액이 노출되지 않도록 maskPrices 로 가린다.
|
|
function PartnerBubble({
|
|
message,
|
|
currentSupplierName,
|
|
currentProduct,
|
|
serverCards,
|
|
}: {
|
|
message: ChatMessageData;
|
|
currentSupplierName: string;
|
|
currentProduct: Product | undefined;
|
|
serverCards: QuotationCardData[];
|
|
}) {
|
|
const m = message;
|
|
return (
|
|
<div className="flex justify-end">
|
|
<div className="flex flex-col gap-1.5 max-w-[85%]">
|
|
<div className="flex items-center justify-end gap-1.5 text-muted-foreground">
|
|
<Typography as="span" variant="small" className="text-[10px] font-mono text-inherit">{currentSupplierName}</Typography>
|
|
<Typography as="span" variant="small" className="text-[10px] font-mono text-inherit">·</Typography>
|
|
<Typography as="span" variant="small" className="text-[10px] font-mono text-inherit">#{m.index}</Typography>
|
|
</div>
|
|
<div className="p-3 rounded-md border shadow-xs bg-primary border-transparent text-primary-foreground space-y-1.5">
|
|
{m.step && (
|
|
<Typography as="div" variant="mono" className="text-[10px] tracking-wide opacity-60 text-inherit">
|
|
{m.step}
|
|
</Typography>
|
|
)}
|
|
{m.script && (
|
|
<Typography as="p" variant="small" className="text-xs whitespace-pre-line leading-relaxed text-inherit">
|
|
{maskPrices(m.script)}
|
|
</Typography>
|
|
)}
|
|
<UsedCardBox
|
|
message={m}
|
|
isBot={false}
|
|
currentSupplierName={currentSupplierName}
|
|
currentProduct={currentProduct}
|
|
serverCards={serverCards}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 말풍선에 붙는 협상카드 박스(봇/협력사 공용). 카드 미사용 메시지면 아무것도 렌더하지 않는다.
|
|
// 톤(amber/primary)만 isBot 으로 가르고, 멘트/조건/메모 렌더 로직은 공유한다.
|
|
function UsedCardBox({
|
|
message,
|
|
isBot,
|
|
currentSupplierName,
|
|
currentProduct,
|
|
serverCards,
|
|
}: {
|
|
message: ChatMessageData;
|
|
isBot: boolean;
|
|
currentSupplierName: string;
|
|
currentProduct: Product | undefined;
|
|
serverCards: QuotationCardData[];
|
|
}) {
|
|
const m = message;
|
|
// 메시지가 쓴 협상카드 전체(이름만이 아니라 멘트/조건/메모까지) 를 chat_id 로 매칭.
|
|
const usedCard = m.card_used_yn
|
|
? serverCards.find((c) => c.session_card_id === m.chat_id)
|
|
: undefined;
|
|
if (!usedCard) return null;
|
|
const cardNodes = Array.isArray(usedCard.edit_script) ? (usedCard.edit_script as unknown[]) : null;
|
|
const isWildCard = usedCard.type === CardType.WILD;
|
|
|
|
return (
|
|
<div
|
|
className={`rounded border p-2 ${
|
|
isBot
|
|
? 'bg-amber-50/70 border-amber-200 dark:bg-amber-950/20 dark:border-amber-900/40'
|
|
: 'bg-white/10 border-white/20'
|
|
}`}
|
|
>
|
|
{/* 헤더: 어떤 카드인지(번호·이름·종류) */}
|
|
<div className={`flex items-center gap-1 ${isBot ? 'text-amber-800 dark:text-amber-300' : 'text-primary-foreground'}`}>
|
|
<Sparkles size={10} />
|
|
<Typography as="span" variant="small" className="text-[10px] font-semibold text-inherit">협상카드</Typography>
|
|
{usedCard.number && (
|
|
<Typography as="span" variant="small" className="text-[10px] font-mono font-semibold opacity-70 text-inherit">#{usedCard.number}</Typography>
|
|
)}
|
|
{usedCard.name && (
|
|
<Typography as="span" variant="small" className="text-[10px] font-semibold text-inherit">· {usedCard.name}</Typography>
|
|
)}
|
|
<Typography
|
|
as="span"
|
|
variant="small"
|
|
className={`ml-auto px-1.5 py-0.5 rounded text-[10px] font-bold ${
|
|
isWildCard
|
|
? 'bg-amber-200 text-amber-900 dark:bg-amber-400/25 dark:text-amber-100'
|
|
: 'bg-zinc-200 text-zinc-700 dark:bg-zinc-600/40 dark:text-zinc-100'
|
|
}`}
|
|
>
|
|
{isWildCard ? '와일드' : '협상'}
|
|
</Typography>
|
|
</div>
|
|
|
|
{/* 멘트 본문: 서식본(edit_script) 우선, 없으면 평문 script */}
|
|
{cardNodes ? (
|
|
<div className="mt-1.5">
|
|
<SlateRenderer
|
|
nodes={cardNodes}
|
|
variables={{
|
|
target_price: m.target_price,
|
|
partner_name: currentSupplierName,
|
|
product_name: currentProduct?.name ?? '',
|
|
}}
|
|
/>
|
|
</div>
|
|
) : usedCard.script ? (
|
|
<Typography as="p" variant="small" className="mt-1.5 text-xs leading-relaxed whitespace-pre-line text-foreground/85">
|
|
{usedCard.script}
|
|
</Typography>
|
|
) : null}
|
|
|
|
{/* 와일드카드 부가 정보: 사용 조건 / 메모 */}
|
|
{isWildCard && (usedCard.condition || usedCard.memo) && (
|
|
<div className="mt-1.5 pt-1.5 border-t border-amber-200/60 dark:border-amber-900/40 space-y-0.5 text-muted-foreground">
|
|
{usedCard.condition && (
|
|
<Typography as="div" variant="caption" className="text-[10px] text-inherit">
|
|
<Typography as="span" variant="caption" className="text-[10px] font-semibold text-inherit">조건:</Typography> {usedCard.condition}
|
|
</Typography>
|
|
)}
|
|
{usedCard.memo && (
|
|
<Typography as="div" variant="caption" className="text-[10px] text-inherit">
|
|
<Typography as="span" variant="caption" className="text-[10px] font-semibold text-inherit">메모:</Typography> {usedCard.memo}
|
|
</Typography>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|