Files
Gnuboard7/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/insertVariable.ts
T
HeuJung e17731d8cc feat(message_bizppurio): 알림톡 템플릿 라이프사이클 개편 — DB 기반 작성·검수·승인
비즈뿌리오 알림톡 템플릿을 콘솔 위임(발송 전 실시간 조회) 방식에서 시스템 내
작성(draft) → 검수 신청(requested) → 승인(approved) 라이프사이클로 개편한다.
발송 판정의 SSoT 를 DB(bizppurio_templates)로 옮기고 발송 전 실시간 조회를 폐지.

- 알림↔템플릿 바인딩 모델(notification_bindings) 제거, 알림 1건당 템플릿 1행으로 대체
- 검수 신청: template_code 자체 채번(codeCheck 재시도) → kapi add/update → request
- 상태 동기화: 30분 스케줄러 + 수동 새로고침, 승인 전이 시 content 를 approved_content 로 동결
- SMS 본문 언어별 입력 복원(수신자 로케일 발송), SMS 단독 선택 시 알림톡 미발송
- 3면(코어/게시판/이커머스) '비즈뿌리오' 통합 탭 + 플러그인 '알림 템플릿 관리' 화면
- 중복 검수 신청은 서버 원자 선점(claimForInspection 조건부 UPDATE)으로 차단
- admin_basic 1.0.6 / board 1.0.5 / ecommerce 1.1.2: 채널 서브탭 통합 탭 선언(tab_channels) 지원
- 검증: 계획서 대비 5라운드 전수검증, §6.3 실측 매트릭스 Playwright 19 고정,
 PHPUnit 421 / Vitest 228 green, audit 0 error
2026-08-22 22:44:57 +09:00

118 lines
4.6 KiB
TypeScript

/**
* insertVariable 핸들러 (#597 §4.2)
*
* 알림톡 템플릿 작성 모달·SMS 본문 모달에서 "사용 가능 변수" 칩을 클릭하면, 대상 본문의
* 현재 커서 위치에 `#{변수}` 를 삽입한다. 값 자체는 G7 상태를 SSoT 로 삼아 갱신한다
* (DOM 값을 직접 쓰지 않으므로 다음 렌더에서 상태값으로 덮여 사라지는 충돌이 없다).
*
* 커서 위치만 DOM 에서 읽고(selectionStart), 없으면 문자열 끝에 붙인다.
*
* 레이아웃 JSON 사용 예:
* {
* "handler": "sirsoft-message_bizppurio.insertVariable",
* "params": {
* "variable": "name",
* "target": "bz_template_content",
* "stateTarget": "global",
* "statePath": "bz_tpl_modal.content.templateContent"
* }
* }
*/
import type { ActionContext, ActionWithParams } from '../types';
const logger = ((window as any).G7Core?.createLogger?.('MessageBizppurio:InsertVariable')) ?? {
log: (...args: unknown[]) => console.log('[MessageBizppurio:InsertVariable]', ...args),
warn: (...args: unknown[]) => console.warn('[MessageBizppurio:InsertVariable]', ...args),
error: (...args: unknown[]) => console.error('[MessageBizppurio:InsertVariable]', ...args),
};
/** 삽입 대상 본문 필드(name) 기본값 */
const DEFAULT_TARGET = 'bz_template_content';
/** 상태 경로 기본값 */
const DEFAULT_STATE_PATH = 'bz_tpl_modal.content.templateContent';
/**
* 대상 본문의 커서 위치에 `#{변수}` 를 삽입하고 G7 상태를 갱신합니다.
*
* @param action 액션 객체(params.variable/target/stateTarget/statePath)
* @param context 액션 컨텍스트(state 스냅샷)
*/
export function insertVariableHandler(
action: ActionWithParams,
context: ActionContext,
): void {
const G7Core = (window as any).G7Core;
const params = action.params ?? {};
const variable = String(params.variable ?? '').trim();
const target = String(params.target ?? DEFAULT_TARGET);
const stateTarget = String(params.stateTarget ?? 'global');
const statePath = String(params.statePath ?? DEFAULT_STATE_PATH);
if (variable === '') {
logger.warn('삽입할 변수명이 지정되지 않았습니다.');
return;
}
const setState = stateTarget === 'local'
? G7Core?.state?.setLocal
: (G7Core?.state?.setGlobal ?? G7Core?.state?.set);
const getState = stateTarget === 'local'
? G7Core?.state?.getLocal
: (G7Core?.state?.getGlobal ?? G7Core?.state?.get);
if (!setState || !getState) {
logger.error('G7Core.state API 를 사용할 수 없습니다.');
return;
}
// 현재 값: 핸들러 컨텍스트 스냅샷 우선(React 커밋 직후 stale globalState 회피), 없으면 getState.
const stateSnapshot = stateTarget === 'local'
? ((context as any)?.state ?? getState() ?? {})
: (getState() ?? {});
const current = String(readPath(stateSnapshot, statePath) ?? '');
const token = `#{${variable}}`;
// 커서 위치: DOM 에서 selectionStart 를 읽되, 값 자체는 상태 기준으로 조립.
const field = document.querySelector<HTMLTextAreaElement | HTMLInputElement>(
`textarea[name="${target}"], input[name="${target}"]`,
);
const domValue = field?.value ?? '';
// DOM 값과 상태 값이 동일할 때만 커서 위치를 신뢰(동기화된 경우). 다르면 끝에 붙인다.
const inSync = domValue === current;
const start = inSync ? (field?.selectionStart ?? current.length) : current.length;
const end = inSync ? (field?.selectionEnd ?? current.length) : current.length;
const next = current.slice(0, start) + token + current.slice(end);
setState({ [statePath]: next });
// 커서를 삽입한 토큰 뒤로 이동(다음 렌더 후 DOM 반영되므로 requestAnimationFrame 안에서).
if (field) {
const caret = start + token.length;
requestAnimationFrame(() => {
try {
field.focus();
field.setSelectionRange(caret, caret);
} catch {
// setSelectionRange 미지원 input — 무시
}
});
}
logger.log(`변수 삽입: ${token} → ${stateTarget}:${statePath}`);
}
/**
* dot notation 경로로 객체에서 값을 읽습니다. (예: "bz_tpl_modal.content.templateContent")
*
* @param obj 대상 객체
* @param path dot 경로
* @return 경로 값(없으면 undefined)
*/
function readPath(obj: Record<string, any>, path: string): unknown {
return path.split('.').reduce<any>((acc, key) => (acc == null ? undefined : acc[key]), obj);
}