[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:
parent
26d9fa3c4b
commit
b41f7c67f1
@ -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">
|
||||
|
||||
@ -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>
|
||||
),
|
||||
},
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
37
negodata/front/src/features/cards/editor/preview.tsx
Normal file
37
negodata/front/src/features/cards/editor/preview.tsx
Normal 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>;
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user