o2o-negosium-original/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx
Mina Choi 2a004734d8 [feat] negodata·negosium: 엑셀 데이터 내보내기·견적상세 협상카드 연동·드로어 뒤로가기
- 엑셀 데이터 내보내기(상품·협력사·협상카드): 현재 검색/페이지 필터를 무시하고 전체를 페이지 순회로 받아(fetchAllForExport, 무언 절삭 방지) 양식과 동일 헤더로 CSV 내보내기(downloadXxxData) + '데이터 다운로드' 메뉴·todayStamp 파일명 → 내려받아 수정 후 재업로드(라운드트립)
- 견적상세 ChatTab: 사용 협상카드 매칭을 chat_id→card_id(카드 PK)로 교정, 카드 사용 메시지 본문 script 중복 제거(카드 박스에만 노출), 카드칩→/cards?detail= 상세 링크, 카드 멘트 실가격 마스킹(target_price 변수 미주입+maskPrices)
- 견적상세 협상카드 탭: 번호·스크립트 미리보기 컬럼 추가(cardScriptPreview 평문 추출)·카드명 상세 링크
- 상세 드로어: 백드롭 좌상단 '뒤로'(navigate(-1)) 버튼 추가(데스크톱), 공용 Sheet·견적상세 동일 적용
- 협상요약(negosium): 배송 리드타임 라벨을 회사설정(labels.lead_time) 연동 → '표준납기' 표기
2026-07-30 11:03:51 +09:00

416 lines
19 KiB
TypeScript

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<string, unknown> | 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 (
<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 (
<div
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>
</div>
);
})}
</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>
<button
type="button"
onClick={() => exportChatJson(currentSession, currentSupplierName, currentProduct?.name, chatMessages, serverCards)}
disabled={chatMessages.length === 0}
title="협상로그 JSON 내보내기 (금액 숫자는 가려서 저장)"
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded border border-border text-[10px] font-mono text-muted-foreground hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
>
<Download size={11} />
JSON
</button>
</div>
</div>
{/* 채팅에선 선택 세션의 부가정보를 밴드로 전부 노출. 리스트 버튼(모달)과 병행. */}
{extraRows(currentSession ?? null).length > 0 && (
<div className="px-3 py-2 bg-emerald-500/5 border-b border-border flex flex-wrap items-center gap-x-3 gap-y-1">
<Typography as="span" variant="small" className="text-[10px] font-mono font-bold text-emerald-700 dark:text-emerald-400">협상완료 부가정보</Typography>
{extraRows(currentSession ?? null).map((r) => (
<Typography key={r.label} as="span" variant="small" className="text-[11px] font-mono text-muted-foreground">
{r.label}: <span className="font-semibold text-foreground">{String(r.value)}</span>
</Typography>
))}
</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;
// 카드 사용 메시지는 같은 멘트가 아래 카드 박스에 그대로 나오므로 본문 script 는 중복 → 카드 없을 때만 노출.
const usedCard = findUsedCard(m, serverCards);
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 && !usedCard && (
<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;
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 (
<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>
{cardDetailId ? (
<Link
to={`/cards?detail=${cardDetailId}`}
className="inline-flex items-center gap-1 text-inherit underline underline-offset-2 decoration-1"
title={`${usedCard.name ?? ''} — 협상카드 상세로 이동`}
>
{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>
)}
</Link>
) : (
<>
{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={{
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">
{maskPrices(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>
);
}
// 메시지가 사용한 협상카드를 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;
}