[feat] negodata: 카드 에디터 조건 전략 변수 + 미리보기 변수 라벨 치환

① 카드 목록 스크립트 미리보기에서 {변수} 토큰을 한글 라벨 칩으로 렌더
   (renderCardScriptPreview) — 원시 토큰({prev_partner_price} 등) 노출 제거.
② "조건 전략"(customer_condition) 특수 변수 추가:
   - 툴바 '+ 조건 전략' → 본문에 위치 칩 삽입, 본문 아래 별도 Slate 입력창 노출
   - 조건 내용은 변수 노드에 중첩 보관(edit_script 배열 유지·하위호환) + 저장 시
     script 의 해당 위치에 주입 → agent 는 완성된 문구를 그대로 읽음(무변경)
   - 서브 에디터는 조건 전략 버튼 숨김(중첩 방지), 빈 조건 저장 차단

DB·백엔드·agent 변경 없음(edit_script 타입 unknown, 조건 노드 내 중첩).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
hbyang 2026-07-10 15:18:11 +09:00
parent 26d9fa3c4b
commit b41f7c67f1
8 changed files with 210 additions and 18 deletions

View File

@ -13,7 +13,15 @@ import { type NegotiationCard, type CardTab, generateCardCode } from '../types';
import { CardUsageType } from '@/api/generated/model';
import { CARD_USAGE_TYPE_LABEL, CARD_USAGE_TYPE_OPTIONS } from '@/lib/enumLabels';
import type { CardInput } from '../hooks/useCards';
import { CardScriptEditor, deserialize, serializeToText } from '../editor';
import {
CardScriptEditor,
deserialize,
serializeToText,
hasConditionVariable,
extractCondition,
attachCondition,
CONDITION_LABEL,
} from '../editor';
const schema = z.object({
isWildcard: z.boolean(),
@ -23,10 +31,15 @@ const schema = z.object({
editorScript: z
.custom<Descendant[]>((v) => Array.isArray(v))
.refine((v) => serializeToText(v).trim().length > 0, '스크립트를 작성해 주십시오.'),
conditionScript: z.custom<Descendant[]>((v) => Array.isArray(v)),
status: z.enum(['ACTIVE', 'INACTIVE']),
triggerCondition: z.string(),
memo: z.string(),
});
}).refine(
// 조건 전략 칩을 넣었으면 조건 내용도 작성해야 한다(빈 상태로 저장 시 문구가 비어버림).
(v) => !hasConditionVariable(v.editorScript) || serializeToText(v.conditionScript).trim().length > 0,
{ message: '조건 전략 내용을 작성해 주십시오.', path: ['conditionScript'] },
);
type FormValues = z.infer<typeof schema>;
@ -47,13 +60,16 @@ function buildDefaults(
activeTab: CardTab,
): FormValues {
if (mode === 'edit' && card) {
const editorScript = deserialize(card.editorScript, card.scriptPreview);
return {
isWildcard: card.isWildcard,
usageType: card.usageType,
code: card.code,
title: card.title,
// 저장된 Slate JSON 우선, 없으면 레거시 평문 script 를 변수 노드로 복원.
editorScript: deserialize(card.editorScript, card.scriptPreview),
editorScript,
// 조건 전략 내용은 본문 변수 노드에서 분리해 별도 에디터로 편집한다.
conditionScript: extractCondition(editorScript),
status: card.status,
triggerCondition: card.triggerCondition || '',
memo: card.memo || '',
@ -66,6 +82,7 @@ function buildDefaults(
code: generateCardCode(wild),
title: '',
editorScript: deserialize(), // 새 빈 값(공용 상수 mutate 방지)
conditionScript: deserialize(),
status: 'ACTIVE',
triggerCondition: '',
memo: '',
@ -98,6 +115,8 @@ export function CardFormSheet({
});
const isWildcard = watch('isWildcard');
// 본문에 조건 전략 칩이 있으면 조건 내용 입력용 별도 에디터를 노출한다.
const showConditionEditor = hasConditionVariable(watch('editorScript') || []);
// 수정·삭제 게이팅 — 공용(기본 제공) 카드는 누구도 불가, 개인 카드는 본인 또는 최고관리자만(백엔드와 동일 규칙).
const myUserId = useAuthStore((s) => s.user?.userId);
@ -109,10 +128,15 @@ export function CardFormSheet({
: '본인이 등록한 카드만 수정·삭제할 수 있습니다.';
const onValid = async (v: FormValues) => {
// 조건 전략 칩이 있으면 본문 변수 노드에 조건 내용을 병합 → 저장 시 script 에 주입되고
// edit_script(배열)엔 노드 안에 조건이 함께 보관되어 재편집 시 복원된다.
const editorScript = hasConditionVariable(v.editorScript)
? attachCondition(v.editorScript, v.conditionScript)
: v.editorScript;
const input: CardInput = {
title: v.title,
code: v.code,
editorScript: v.editorScript,
editorScript,
status: v.status,
isWildcard: v.isWildcard,
usageType: v.usageType,
@ -305,6 +329,35 @@ export function CardFormSheet({
)}
</div>
{/* .
. */}
{showConditionEditor && (
<div className="space-y-1.5 p-3 bg-amber-50/60 dark:bg-amber-400/10 rounded border border-amber-300/60 dark:border-amber-400/30">
<Typography as="label" variant="small" className="font-bold flex items-center gap-1.5 text-amber-900 dark:text-amber-200">
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" />
{CONDITION_LABEL}
</Typography>
<p className="text-[10px] text-muted-foreground">
· (: 물량 , ) . {CONDITION_LABEL} .
</p>
<Controller
control={control}
name="conditionScript"
render={({ field }) => (
<CardScriptEditor
value={field.value}
onChange={field.onChange}
hideConditionVar
placeholder="예: 6개월 물량 보장 및 대금 현금 결제"
/>
)}
/>
{errors.conditionScript && (
<p className="text-[10px] text-rose-500">{errors.conditionScript.message as string}</p>
)}
</div>
)}
{/* Wildcard-only fields */}
{isWildcard && (
<div className="space-y-4 p-3 bg-muted/40 rounded border border-border">

View File

@ -1,6 +1,6 @@
import type { ReactNode } from 'react';
import { DataTable } from '@/components/ui/data-table';
import { renderEmphasis } from '@/lib/emphasis';
import { renderCardScriptPreview } from '../editor';
import type { NegotiationCard } from '../types';
type CardTableProps = {
@ -60,8 +60,8 @@ export function CardTable({ data, onEdit, footer }: CardTableProps) {
mobileBlock: true, // 긴 미리보기 블록 → 모바일 카드뷰에서 라벨 아래 풀폭
cell: (card) => (
<div className="max-w-[22rem] truncate bg-muted/20 px-2 py-1 rounded border border-border/30 text-[11px] leading-snug">
{/* 마커(**굵게**·{{색}})를 스타일로 렌더 — 표 미리보기에 원시 마커가 보이지 않게 */}
{renderEmphasis(card.scriptPreview)}
{/* 마커(**굵게**·{{색}})는 스타일로, {변수} 토큰은 한글 라벨 칩으로 렌더 */}
{renderCardScriptPreview(card.scriptPreview)}
</div>
),
},

View File

@ -11,11 +11,12 @@ type CardScriptEditorProps = {
onChange: (value: Descendant[]) => void;
disabled?: boolean;
placeholder?: string;
hideConditionVar?: boolean; // 조건 전략 입력용 서브 에디터에선 조건 전략 버튼 숨김(중첩 방지)
};
// 협상카드 스크립트 리치텍스트 에디터. 굵게/밑줄/색상 마크 + {변수} 칩 지원.
// 값(Slate JSON)은 폼이 들고, 평문(script) 직렬화는 저장 시 serializeToText 로 파생한다.
export function CardScriptEditor({ value, onChange, disabled, placeholder }: CardScriptEditorProps) {
export function CardScriptEditor({ value, onChange, disabled, placeholder, hideConditionVar }: CardScriptEditorProps) {
const editor = useCardScriptEditor();
const renderElement = useCallback((props: RenderElementProps) => <Element {...props} />, []);
@ -30,7 +31,7 @@ export function CardScriptEditor({ value, onChange, disabled, placeholder }: Car
return (
<div className="border border-border rounded-md overflow-hidden bg-background focus-within:border-foreground/30 transition-all">
<Slate editor={editor} initialValue={value} onChange={handleChange}>
{!disabled && <CardScriptToolbar />}
{!disabled && <CardScriptToolbar hideConditionVar={hideConditionVar} />}
<Editable
readOnly={disabled}
spellCheck={false}

View File

@ -10,7 +10,7 @@ import {
toggleMark,
type TextMark,
} from './slate';
import { CARD_VARIABLES } from './variables';
import { CARD_VARIABLES, CONDITION_LABEL, CONDITION_VARIABLE } from './variables';
// 색상 프리셋 — 기본/강조(빨강)/보조(파랑).
// negowiz 동일 프리셋: 검정 / 빨강 / 파랑.
@ -28,7 +28,7 @@ function hold(handler: () => void) {
};
}
export function CardScriptToolbar() {
export function CardScriptToolbar({ hideConditionVar }: { hideConditionVar?: boolean }) {
const editor = useSlate();
const curColor = activeColor(editor);
@ -75,6 +75,21 @@ export function CardScriptToolbar() {
{v.label}
</button>
))}
{!hideConditionVar && (
<>
<Divider />
{/* 조건 전략 — 삽입 시 본문에 위치 칩이 들어가고, 본문 아래 별도 조건 입력 에디터가 열린다. */}
<button
type="button"
title="조건 전략 삽입 — 조건 내용은 아래 별도 입력창에서 작성"
onMouseDown={hold(() => insertVariable(editor, CONDITION_VARIABLE))}
className="px-1.5 py-0.5 rounded border border-amber-300 bg-amber-50 text-[10px] font-semibold text-amber-900 hover:bg-amber-100 cursor-pointer dark:border-amber-400/40 dark:bg-amber-400/15 dark:text-amber-200"
>
+ {CONDITION_LABEL}
</button>
</>
)}
</div>
);
}

View File

@ -1,4 +1,12 @@
export { CardScriptEditor } from './CardScriptEditor';
// deserialize() 는 항상 새 배열을 반환 → Slate initialValue 로 안전. 공용 mutable 상수는 export 하지 않는다.
export { deserialize, serializeToText, serializeToMarker } from './slate';
export { CARD_VARIABLES } from './variables';
export {
deserialize,
serializeToText,
serializeToMarker,
hasConditionVariable,
extractCondition,
attachCondition,
} from './slate';
export { CARD_VARIABLES, CONDITION_VARIABLE, CONDITION_LABEL, variableLabel, isKnownVariable } from './variables';
export { renderCardScriptPreview } from './preview';

View File

@ -0,0 +1,37 @@
import { Fragment, type ReactNode } from 'react';
import { renderEmphasis } from '@/lib/emphasis';
import { isKnownVariable, variableLabel } from './variables';
// 카드 스크립트(script) 미리보기 렌더 — 표/목록용.
// 마커(**굵게**·{{색}})는 emphasis 로 스타일링하고, {변수} 토큰은 한글 라벨 칩으로 치환해
// 원시 토큰({prev_partner_price} 등)이 그대로 노출되지 않게 한다.
const VAR_TOKEN_RE = /\{(\w+)\}/g;
export function renderCardScriptPreview(text: string): ReactNode {
if (!text) return text;
const out: ReactNode[] = [];
let last = 0;
let m: RegExpExecArray | null;
let seq = 0;
VAR_TOKEN_RE.lastIndex = 0;
while ((m = VAR_TOKEN_RE.exec(text)) !== null) {
if (m.index > last) out.push(<Fragment key={`t-${seq}`}>{renderEmphasis(text.slice(last, m.index))}</Fragment>);
const name = m[1];
if (isKnownVariable(name)) {
out.push(
<span
key={`v-${seq}`}
className="mx-0.5 rounded border border-amber-300/60 bg-amber-100/70 px-1 text-[10px] font-semibold text-amber-900 dark:border-amber-400/30 dark:bg-amber-400/15 dark:text-amber-200"
>
{variableLabel(name)}
</span>,
);
} else {
out.push(<Fragment key={`v-${seq}`}>{m[0]}</Fragment>); // 미등록 토큰은 원형 유지
}
last = VAR_TOKEN_RE.lastIndex;
seq += 1;
}
if (last < text.length) out.push(<Fragment key={`t-${seq}`}>{renderEmphasis(text.slice(last))}</Fragment>);
return <Fragment>{out}</Fragment>;
}

View File

@ -2,7 +2,7 @@ import { createEditor, Editor, Element as SlateElement, Transforms } from 'slate
import type { BaseEditor, Descendant } from 'slate';
import type { ReactEditor } from 'slate-react';
import type { HistoryEditor } from 'slate-history';
import { isKnownVariable } from './variables';
import { isConditionVariable, isKnownVariable } from './variables';
// ── 스키마 ────────────────────────────────────────────────
// negowiz 는 곳곳에서 `as any` 로 노드 타입을 우회했지만, 여기선 Slate 의 CustomTypes
@ -20,6 +20,9 @@ export type VariableElement = {
type: 'variable';
name: string; // CardVariable.name
children: CustomText[]; // void 라 비어있음([{text:''}])
// 조건 전략(customer_condition) 전용: 별도 에디터에서 작성한 조건 내용(Slate 서브트리).
// 저장 직전 attachCondition 으로 채워지고, 직렬화 시 이 위치에 조건 마커가 주입된다.
condition?: Descendant[];
};
export type ParagraphElement = {
@ -78,10 +81,61 @@ export function clearColor(editor: Editor): void {
export function insertVariable(editor: Editor, name: string): void {
const node: VariableElement = { type: 'variable', name, children: [{ text: '' }] };
// 조건 전략은 본문에 위치 칩만 두고, 내용은 별도 에디터가 담당한다(condition 은 저장 시 병합).
Transforms.insertNodes(editor, node);
Transforms.move(editor); // 삽입된 void 뒤로 커서 이동
}
const emptyParagraph = (): Descendant[] => [{ type: 'paragraph', children: [{ text: '' }] }];
// ── 조건 전략(customer_condition) 헬퍼 — 본문 노드 ↔ 별도 조건 에디터 분리/병합 ──
export function hasConditionVariable(nodes: Descendant[]): boolean {
return nodes.some(
(n) => SlateElement.isElement(n) && n.type === 'variable' && isConditionVariable(n.name),
) || nodes.some(
(n) => SlateElement.isElement(n) && n.type === 'paragraph'
&& n.children.some((c) => 'type' in c && c.type === 'variable' && isConditionVariable(c.name)),
);
}
// 저장된 노드에서 조건 서브트리를 꺼낸다(재편집용). 없으면 빈 문단.
export function extractCondition(nodes: Descendant[]): Descendant[] {
const found = findConditionNode(nodes);
const c = found?.condition;
return Array.isArray(c) && c.length > 0 ? (JSON.parse(JSON.stringify(c)) as Descendant[]) : emptyParagraph();
}
// 본문 노드의 조건 변수에 조건 서브트리를 얹은 새 배열을 만든다(저장 직전).
export function attachCondition(nodes: Descendant[], condition: Descendant[]): Descendant[] {
const clone = JSON.parse(JSON.stringify(nodes)) as Descendant[];
for (const n of clone) {
if (SlateElement.isElement(n) && n.type === 'variable' && isConditionVariable(n.name)) {
(n as VariableElement).condition = condition;
} else if (SlateElement.isElement(n) && n.type === 'paragraph') {
for (const c of n.children) {
if ('type' in c && c.type === 'variable' && isConditionVariable(c.name)) {
(c as VariableElement).condition = condition;
}
}
}
}
return clone;
}
function findConditionNode(nodes: Descendant[]): VariableElement | undefined {
for (const n of nodes) {
if (SlateElement.isElement(n)) {
if (n.type === 'variable' && isConditionVariable(n.name)) return n;
if (n.type === 'paragraph') {
for (const c of n.children) {
if ('type' in c && c.type === 'variable' && isConditionVariable(c.name)) return c;
}
}
}
}
return undefined;
}
// ── 직렬화: Slate 값 → 평문(script) ─────────────────────────
// 변수는 {name} 토큰으로, 마크는 버리고 텍스트만. 백엔드 script(미리보기) 컬럼에 저장.
export function serializeToText(nodes: Descendant[]): string {
@ -90,7 +144,13 @@ export function serializeToText(nodes: Descendant[]): string {
function serializeNode(node: Descendant): string {
if (SlateElement.isElement(node)) {
if (node.type === 'variable') return `{${node.name}}`;
if (node.type === 'variable') {
// 조건 전략: 저장 위치에 조건 내용(평문)을 주입. 미작성 시 토큰 유지.
if (isConditionVariable(node.name)) {
return node.condition && node.condition.length ? serializeToText(node.condition) : `{${node.name}}`;
}
return `{${node.name}}`;
}
return node.children.map(serializeNode).join('');
}
return node.text;
@ -111,7 +171,16 @@ export function serializeToMarker(nodes: Descendant[]): string {
function serializeMarkerNode(node: Descendant): string {
if (SlateElement.isElement(node)) {
if (node.type === 'variable') return applyMarks(`{${node.name}}`, node.children[0]);
if (node.type === 'variable') {
// 조건 전략: 저장 위치에 조건 내용(마커 포함)을 주입 → agent 는 이미 완성된 문구를 읽는다.
// 미작성 시 토큰 유지(폴백). 그 외 변수는 {name} 토큰 그대로.
if (isConditionVariable(node.name)) {
return node.condition && node.condition.length
? serializeToMarker(node.condition)
: applyMarks(`{${node.name}}`, node.children[0]);
}
return applyMarks(`{${node.name}}`, node.children[0]);
}
return node.children.map(serializeMarkerNode).join('');
}
return applyMarks(node.text, node);

View File

@ -21,9 +21,18 @@ export const CARD_VARIABLES: CardVariable[] = [
{ name: 'discount_rate', label: '인하율' }, // 기존 공급가 대비 인하율(%)
];
const VARIABLE_NAMES = new Set(CARD_VARIABLES.map((v) => v.name));
// 조건 전략(customer_condition)은 특수 변수 — 카드 본문엔 인라인 칩으로 위치만 두고,
// 실제 조건 내용은 본문 아래 별도 Slate 에디터에서 작성한다(저장 시 그 위치에 주입).
export const CONDITION_VARIABLE = 'customer_condition';
export const CONDITION_LABEL = '조건 전략';
export const isConditionVariable = (name: string): boolean => name === CONDITION_VARIABLE;
const VARIABLE_NAMES = new Set([...CARD_VARIABLES.map((v) => v.name), CONDITION_VARIABLE]);
export const isKnownVariable = (name: string): boolean => VARIABLE_NAMES.has(name);
export const variableLabel = (name: string): string =>
CARD_VARIABLES.find((v) => v.name === name)?.label ?? name;
name === CONDITION_VARIABLE
? CONDITION_LABEL
: (CARD_VARIABLES.find((v) => v.name === name)?.label ?? name);