- 협상대화: 사용 카드 번호/멘트(Slate 서식본)/와일드 조건·메모 표시, SlateRenderer
밑줄·헥스색·{변수} 치환 보정, 말풍선 우측정렬·발신자 아바타 제거
- 견적상세 헤더: DB 컬럼명 라벨 제거, 견적정보/(진행상태+세팅)/상품(이미지) 2열 재배치,
협상카드 탭에 사용 카드 수 표시
- DataTable 행 패딩 축소, 협력사명 아바타 제거, 카드관리 본인 카드 안내문
- orval 재생성(QuotationCardData 신규 필드)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
127 lines
4.8 KiB
TypeScript
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>
|
|
);
|
|
}
|