From b41f7c67f129e604201c386d177bdc75fe23153c Mon Sep 17 00:00:00 2001 From: hbyang Date: Fri, 10 Jul 2026 15:18:11 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20negodata:=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EC=97=90=EB=94=94=ED=84=B0=20=EC=A1=B0=EA=B1=B4=20=EC=A0=84?= =?UTF-8?q?=EB=9E=B5=20=EB=B3=80=EC=88=98=20+=20=EB=AF=B8=EB=A6=AC?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=EB=B3=80=EC=88=98=20=EB=9D=BC=EB=B2=A8=20?= =?UTF-8?q?=EC=B9=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ① 카드 목록 스크립트 미리보기에서 {변수} 토큰을 한글 라벨 칩으로 렌더 (renderCardScriptPreview) — 원시 토큰({prev_partner_price} 등) 노출 제거. ② "조건 전략"(customer_condition) 특수 변수 추가: - 툴바 '+ 조건 전략' → 본문에 위치 칩 삽입, 본문 아래 별도 Slate 입력창 노출 - 조건 내용은 변수 노드에 중첩 보관(edit_script 배열 유지·하위호환) + 저장 시 script 의 해당 위치에 주입 → agent 는 완성된 문구를 그대로 읽음(무변경) - 서브 에디터는 조건 전략 버튼 숨김(중첩 방지), 빈 조건 저장 차단 DB·백엔드·agent 변경 없음(edit_script 타입 unknown, 조건 노드 내 중첩). Co-Authored-By: Claude Fable 5 --- .../cards/components/CardFormSheet.tsx | 61 ++++++++++++++- .../features/cards/components/CardTable.tsx | 6 +- .../cards/editor/CardScriptEditor.tsx | 5 +- .../cards/editor/CardScriptToolbar.tsx | 19 ++++- .../front/src/features/cards/editor/index.ts | 12 ++- .../src/features/cards/editor/preview.tsx | 37 +++++++++ .../front/src/features/cards/editor/slate.ts | 75 ++++++++++++++++++- .../src/features/cards/editor/variables.ts | 13 +++- 8 files changed, 210 insertions(+), 18 deletions(-) create mode 100644 negodata/front/src/features/cards/editor/preview.tsx diff --git a/negodata/front/src/features/cards/components/CardFormSheet.tsx b/negodata/front/src/features/cards/components/CardFormSheet.tsx index 88924c3..4a0f151 100644 --- a/negodata/front/src/features/cards/components/CardFormSheet.tsx +++ b/negodata/front/src/features/cards/components/CardFormSheet.tsx @@ -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((v) => Array.isArray(v)) .refine((v) => serializeToText(v).trim().length > 0, '스크립트를 작성해 주십시오.'), + conditionScript: z.custom((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; @@ -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({ )} + {/* 조건 전략 내용 — 본문에 조건 전략 칩을 넣으면 나타나는 별도 입력창. + 여기 작성한 내용이 저장 시 본문의 조건 전략 위치에 주입된다. */} + {showConditionEditor && ( +
+ + + {CONDITION_LABEL} 내용 + +

+ 협력사에 요구·교환할 조건(예: 물량 보장, 결제 조건 등)을 작성하세요. 본문의 “{CONDITION_LABEL}” 위치에 삽입됩니다. +

+ ( + + )} + /> + {errors.conditionScript && ( +

{errors.conditionScript.message as string}

+ )} +
+ )} + {/* Wildcard-only fields */} {isWildcard && (
diff --git a/negodata/front/src/features/cards/components/CardTable.tsx b/negodata/front/src/features/cards/components/CardTable.tsx index a989920..44772cd 100644 --- a/negodata/front/src/features/cards/components/CardTable.tsx +++ b/negodata/front/src/features/cards/components/CardTable.tsx @@ -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) => (
- {/* 마커(**굵게**·{{색}})를 스타일로 렌더 — 표 미리보기에 원시 마커가 보이지 않게 */} - {renderEmphasis(card.scriptPreview)} + {/* 마커(**굵게**·{{색}})는 스타일로, {변수} 토큰은 한글 라벨 칩으로 렌더 */} + {renderCardScriptPreview(card.scriptPreview)}
), }, diff --git a/negodata/front/src/features/cards/editor/CardScriptEditor.tsx b/negodata/front/src/features/cards/editor/CardScriptEditor.tsx index 2a07dfd..bde1d94 100644 --- a/negodata/front/src/features/cards/editor/CardScriptEditor.tsx +++ b/negodata/front/src/features/cards/editor/CardScriptEditor.tsx @@ -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) => , []); @@ -30,7 +31,7 @@ export function CardScriptEditor({ value, onChange, disabled, placeholder }: Car return (
- {!disabled && } + {!disabled && } 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} ))} + + {!hideConditionVar && ( + <> + + {/* 조건 전략 — 삽입 시 본문에 위치 칩이 들어가고, 본문 아래 별도 조건 입력 에디터가 열린다. */} + + + )}
); } diff --git a/negodata/front/src/features/cards/editor/index.ts b/negodata/front/src/features/cards/editor/index.ts index 6e093ef..7843301 100644 --- a/negodata/front/src/features/cards/editor/index.ts +++ b/negodata/front/src/features/cards/editor/index.ts @@ -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'; diff --git a/negodata/front/src/features/cards/editor/preview.tsx b/negodata/front/src/features/cards/editor/preview.tsx new file mode 100644 index 0000000..57c1ba3 --- /dev/null +++ b/negodata/front/src/features/cards/editor/preview.tsx @@ -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({renderEmphasis(text.slice(last, m.index))}); + const name = m[1]; + if (isKnownVariable(name)) { + out.push( + + {variableLabel(name)} + , + ); + } else { + out.push({m[0]}); // 미등록 토큰은 원형 유지 + } + last = VAR_TOKEN_RE.lastIndex; + seq += 1; + } + if (last < text.length) out.push({renderEmphasis(text.slice(last))}); + return {out}; +} diff --git a/negodata/front/src/features/cards/editor/slate.ts b/negodata/front/src/features/cards/editor/slate.ts index b8872da..0eea4a4 100644 --- a/negodata/front/src/features/cards/editor/slate.ts +++ b/negodata/front/src/features/cards/editor/slate.ts @@ -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); diff --git a/negodata/front/src/features/cards/editor/variables.ts b/negodata/front/src/features/cards/editor/variables.ts index dfa23c9..d067344 100644 --- a/negodata/front/src/features/cards/editor/variables.ts +++ b/negodata/front/src/features/cards/editor/variables.ts @@ -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);