o2o-negosium-original/negodata/front/src/components/SlateRenderer.tsx
Mina Choi 7186446068 [feat] negodata/front: 협상대화 카드내용 렌더 + 견적상세 헤더·테이블 UI 정리
- 협상대화: 사용 카드 번호/멘트(Slate 서식본)/와일드 조건·메모 표시, SlateRenderer
  밑줄·헥스색·{변수} 치환 보정, 말풍선 우측정렬·발신자 아바타 제거
- 견적상세 헤더: DB 컬럼명 라벨 제거, 견적정보/(진행상태+세팅)/상품(이미지) 2열 재배치,
  협상카드 탭에 사용 카드 수 표시
- DataTable 행 패딩 축소, 협력사명 아바타 제거, 카드관리 본인 카드 안내문
- orval 재생성(QuotationCardData 신규 필드)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:21:58 +09:00

127 lines
4.8 KiB
TypeScript

import React from 'react';
import { variableLabel } from '@/features/cards/editor/variables';
interface SlateLeaf {
text: string;
bold?: boolean;
italic?: boolean;
underline?: boolean;
// 'primary'|'success'|'warning'|'destructive' 프리셋 토큰 또는 임의 CSS 색(예: '#ED2024')
color?: string;
}
// 프리셋 색 토큰 → 클래스. 카드 에디터는 헥스(#ED2024)를 쓰므로 토큰에 없으면 인라인 style 로 처리.
const NAMED_COLOR: Record<string, string> = {
primary: 'text-primary font-bold underline decoration-2 decoration-gray-400 dark:decoration-gray-600',
success: 'text-success font-semibold px-1 py-0.5 rounded bg-emerald-50 dark:bg-emerald-950/40 border border-emerald-200/50 dark:border-emerald-900/40',
warning: 'text-warning font-semibold px-1 py-0.5 rounded bg-amber-50 dark:bg-amber-950/40 border border-amber-200/50',
destructive: 'text-destructive font-semibold',
};
interface SlateNode {
type?: 'paragraph' | 'block-quote' | 'heading-one' | 'heading-two' | 'list-item';
children: (SlateLeaf | SlateNode)[];
}
interface SlateRendererProps {
nodes?: any[];
variables?: Record<string, string | number>;
}
export default function SlateRenderer({ nodes, variables = {} }: SlateRendererProps) {
if (!nodes || !Array.isArray(nodes)) {
return null;
}
// Helper to replace variable strings like {target_price} or {partner_type}
const replaceVariables = (text: string): string => {
let result = text;
Object.entries(variables).forEach(([key, val]) => {
const placeholder = `{${key}}`;
if (result.includes(placeholder)) {
if (typeof val === 'number') {
result = result.replace(new RegExp(placeholder, 'g'), val.toLocaleString('ko-KR'));
} else {
result = result.replace(new RegExp(placeholder, 'g'), String(val));
}
}
});
return result;
};
const renderLeaf = (leaf: SlateLeaf, key: string) => {
const text = replaceVariables(leaf.text);
let classes = '';
if (leaf.bold) classes += ' font-bold';
if (leaf.italic) classes += ' italic';
if (leaf.underline) classes += ' underline';
// color 가 프리셋 토큰이면 클래스, 임의 CSS 색(헥스 등)이면 인라인 style 로 적용.
const named = leaf.color ? NAMED_COLOR[leaf.color] : undefined;
const style = leaf.color && !named ? { color: leaf.color } : undefined;
const cls = `${classes}${named ? ` ${named}` : ''}`.trim();
return (
<span key={key} className={cls || undefined} style={style}>
{text}
</span>
);
};
const renderNode = (node: any, index: number): React.ReactNode => {
const key = `node-${index}`;
// If it's pure leaf structure (contains no children arrays, but has text attribute)
if (node.text !== undefined) {
return renderLeaf(node as SlateLeaf, key);
}
// 변수 노드(void inline): {type:'variable', name}. variables 에 값 있으면 치환, 없으면 라벨 칩.
if (node.type === 'variable') {
const raw = variables[node.name];
const hasValue = raw !== undefined && raw !== null && raw !== '';
if (hasValue) {
const text = typeof raw === 'number' ? raw.toLocaleString('ko-KR') : String(raw);
return <span key={key} className="font-semibold text-primary">{text}</span>;
}
// 값 없으면 치환하지 않고 {라벨} 형태로 그대로 노출.
return (
<span key={key} className="text-muted-foreground">
{`{${variableLabel(node.name)}}`}
</span>
);
}
const childrenElements = (node.children || []).map((child: any, childIdx: number) => {
if (child.text !== undefined) {
return renderLeaf(child as SlateLeaf, `${key}-${childIdx}`);
}
return renderNode(child, childIdx);
});
switch (node.type) {
case 'heading-one':
return <h1 key={key} className="text-xl font-bold tracking-tight text-foreground my-2">{childrenElements}</h1>;
case 'heading-two':
return <h2 key={key} className="text-lg font-semibold tracking-tight text-foreground my-1.5">{childrenElements}</h2>;
case 'block-quote':
return (
<blockquote key={key} className="border-l-4 border-foreground/50 bg-muted/60 px-4 py-2.5 my-3 rounded-r text-sm text-foreground/90 font-mono">
{childrenElements}
</blockquote>
);
case 'list-item':
return <li key={key} className="list-disc ml-5 my-1 text-sm text-foreground/80">{childrenElements}</li>;
case 'paragraph':
default:
return <p key={key} className="text-sm leading-relaxed text-foreground/85 my-1 whitespace-pre-line">{childrenElements}</p>;
}
};
return (
<div className="space-y-2">
{nodes.map((node, i) => renderNode(node, i))}
</div>
);
}