- 엑셀 데이터 내보내기(상품·협력사·협상카드): 현재 검색/페이지 필터를 무시하고 전체를 페이지 순회로 받아(fetchAllForExport, 무언 절삭 방지) 양식과 동일 헤더로 CSV 내보내기(downloadXxxData) + '데이터 다운로드' 메뉴·todayStamp 파일명 → 내려받아 수정 후 재업로드(라운드트립) - 견적상세 ChatTab: 사용 협상카드 매칭을 chat_id→card_id(카드 PK)로 교정, 카드 사용 메시지 본문 script 중복 제거(카드 박스에만 노출), 카드칩→/cards?detail= 상세 링크, 카드 멘트 실가격 마스킹(target_price 변수 미주입+maskPrices) - 견적상세 협상카드 탭: 번호·스크립트 미리보기 컬럼 추가(cardScriptPreview 평문 추출)·카드명 상세 링크 - 상세 드로어: 백드롭 좌상단 '뒤로'(navigate(-1)) 버튼 추가(데스크톱), 공용 Sheet·견적상세 동일 적용 - 협상요약(negosium): 배송 리드타임 라벨을 회사설정(labels.lead_time) 연동 → '표준납기' 표기
416 lines
19 KiB
TypeScript
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;
|
|
}
|