a5bbd1eb7c
- 옛 registry/numbering-rule, registry/v2-numbering-rule, V2NumberingRuleConfigPanel, NumberingRuleTemplate 폐기 — InvFieldConfigPanel + InputComponent 로 통합 - input 에 numbering-picker / select-pickers 추가, autonum 타입 흡수 - 채번 관리 전용 admin 페이지(systemMng/numberingRuleList) + CreateDialog + SequenceManagementPanel 신설 - backend NumberingRule controller/service/mapper 갱신 (시퀀스 관리 엔드포인트) - input canonical 진행 노트 + 채번 관리 mockup 추가 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
415 lines
14 KiB
TypeScript
415 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
import { previewNumberingCode } from "@/lib/api/numberingRule";
|
|
|
|
/**
|
|
* NumberingPicker — 채번 코드 picker
|
|
*
|
|
* InvField autonum 의 캔버스 위젯. 운영 모드에서 채번 규칙 API 를 호출해
|
|
* 자동으로 코드를 생성하고, 템플릿에 `____` 가 있으면 사용자 수동입력 분기
|
|
* (prefix span + input + suffix span) 로 렌더.
|
|
*
|
|
* V2Input.tsx 의 numbering 본체 (state/effect/렌더) 를 추출. 차이:
|
|
* - 디자인 모드 체크 추가 (preview API 미호출)
|
|
* - props.numberingRuleId 우선 (autoGen.options.numberingRuleId 에서 직접 명시 시)
|
|
* - by-column API + getTableColumns fallback 흐름 유지
|
|
*
|
|
* 관련: notes/gbpark/2026-05-08-input-canonical-migration.md §A.6
|
|
*/
|
|
export interface NumberingPickerProps {
|
|
value: unknown;
|
|
onChange: (v: string) => void;
|
|
tableName?: string;
|
|
columnName?: string;
|
|
formData?: Record<string, any>;
|
|
numberingRuleId?: string;
|
|
isEditMode?: boolean;
|
|
isDesignMode?: boolean;
|
|
disabled?: boolean;
|
|
readonly?: boolean;
|
|
placeholder?: string;
|
|
className?: string;
|
|
style?: React.CSSProperties;
|
|
/**
|
|
* ruleId 가 결정 (props 또는 by-column 조회) 되면 1회 호출.
|
|
* EditModal / buttonActions 의 `${columnName}_numberingRuleId` 메타 키 호환용.
|
|
*/
|
|
onRuleIdResolved?: (ruleId: string) => void;
|
|
}
|
|
|
|
export const NumberingPicker: React.FC<NumberingPickerProps> = ({
|
|
value,
|
|
onChange,
|
|
tableName,
|
|
columnName,
|
|
formData,
|
|
numberingRuleId: propRuleId,
|
|
isEditMode = false,
|
|
isDesignMode = false,
|
|
disabled = false,
|
|
readonly = false,
|
|
placeholder,
|
|
className,
|
|
style,
|
|
onRuleIdResolved,
|
|
}) => {
|
|
// ─── state / refs ──────────────────────────────────────────
|
|
const [autoGeneratedValue, setAutoGeneratedValue] = useState<string | null>(null);
|
|
const [isGenerating, setIsGenerating] = useState(false);
|
|
const [manualInputValue, setManualInputValue] = useState<string>("");
|
|
|
|
const ruleIdRef = useRef<string | null>(propRuleId ?? null);
|
|
const hasGeneratedRef = useRef<boolean>(false);
|
|
const lastCategoryValuesRef = useRef<string>("");
|
|
const userEditedRef = useRef<boolean>(false);
|
|
const hadManualPartRef = useRef<boolean>(false);
|
|
const templateRef = useRef<string>("");
|
|
const formDataRef = useRef<Record<string, any> | undefined>(formData);
|
|
|
|
// 최신 formData 추적 (effect closure stale 방지)
|
|
useEffect(() => {
|
|
formDataRef.current = formData;
|
|
}, [formData]);
|
|
|
|
// props.numberingRuleId 변경 시 ref 갱신 + 외부 알림 (autoGen 설정 변경 대응)
|
|
useEffect(() => {
|
|
if (propRuleId && propRuleId !== ruleIdRef.current) {
|
|
ruleIdRef.current = propRuleId;
|
|
hasGeneratedRef.current = false;
|
|
onRuleIdResolved?.(propRuleId);
|
|
} else if (propRuleId && ruleIdRef.current === propRuleId) {
|
|
// 첫 mount 시 useRef 초기화에서 set 된 케이스
|
|
onRuleIdResolved?.(propRuleId);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [propRuleId]);
|
|
|
|
// formData 의 다른 string 필드 변화 추적 (카테고리 기반 채번 재생성용)
|
|
const categoryValuesForNumbering = useMemo(() => {
|
|
if (!formData) return "";
|
|
const categoryFields: Record<string, string> = {};
|
|
for (const [key, val] of Object.entries(formData)) {
|
|
if (key === columnName) continue;
|
|
if (typeof val === "string" && val) {
|
|
categoryFields[key] = val;
|
|
}
|
|
}
|
|
return JSON.stringify(categoryFields);
|
|
}, [formData, columnName]);
|
|
|
|
// ─── main effect: ruleId 조회 + preview ────────────────────
|
|
useEffect(() => {
|
|
if (isDesignMode) return;
|
|
if (isEditMode) return; // 수정 모드는 기존 값 유지
|
|
|
|
let cancelled = false;
|
|
|
|
const run = async () => {
|
|
if (isGenerating) return;
|
|
|
|
const categoryChanged = categoryValuesForNumbering !== lastCategoryValuesRef.current;
|
|
if (userEditedRef.current && !categoryChanged) return;
|
|
if (hasGeneratedRef.current && !categoryChanged) return;
|
|
|
|
// 첫 생성 시 값이 이미 있고 카테고리 변경이 아니면 스킵
|
|
if (!categoryChanged && value !== undefined && value !== null && value !== "") return;
|
|
|
|
setIsGenerating(true);
|
|
try {
|
|
// ruleId 결정 (없을 때만 by-column → fallback 흐름)
|
|
if (!ruleIdRef.current) {
|
|
if (!tableName || !columnName) {
|
|
console.warn("NumberingPicker: tableName/columnName 없음", { tableName, columnName });
|
|
return;
|
|
}
|
|
// by-column API
|
|
try {
|
|
const { apiClient } = await import("@/lib/api/client");
|
|
const ruleResp = await apiClient.get(`/numbering-rules/by-column/${tableName}/${columnName}`);
|
|
if (cancelled) return;
|
|
if (ruleResp.data?.success && ruleResp.data?.data?.ruleId) {
|
|
ruleIdRef.current = ruleResp.data.data.ruleId;
|
|
onRuleIdResolved?.(ruleResp.data.data.ruleId);
|
|
}
|
|
} catch {
|
|
// detailSettings fallback
|
|
try {
|
|
const { getTableColumns } = await import("@/lib/api/tableManagement");
|
|
const colsResp = await getTableColumns(tableName);
|
|
if (cancelled) return;
|
|
if (colsResp.success && colsResp.data) {
|
|
const cols = colsResp.data.columns || colsResp.data;
|
|
const target = cols.find((c: { column_name: string }) => c.column_name === columnName);
|
|
if (target?.detail_settings) {
|
|
const parsed =
|
|
typeof target.detail_settings === "string"
|
|
? JSON.parse(target.detail_settings)
|
|
: target.detail_settings;
|
|
ruleIdRef.current = parsed.numberingRuleId || null;
|
|
if (ruleIdRef.current) onRuleIdResolved?.(ruleIdRef.current);
|
|
}
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
}
|
|
|
|
const ruleId = ruleIdRef.current;
|
|
if (!ruleId) {
|
|
console.warn("NumberingPicker: 채번 규칙 없음. 옵션설정 > 채번설정에서 생성 필요", {
|
|
tableName,
|
|
columnName,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const resp = await previewNumberingCode(ruleId, formDataRef.current, manualInputValue || undefined);
|
|
if (cancelled) return;
|
|
|
|
if (resp.success && resp.data?.generatedCode) {
|
|
const code = resp.data.generatedCode;
|
|
hasGeneratedRef.current = true;
|
|
lastCategoryValuesRef.current = categoryValuesForNumbering;
|
|
|
|
if (code.includes("____")) {
|
|
hadManualPartRef.current = true;
|
|
const oldTemplate = templateRef.current;
|
|
templateRef.current = code;
|
|
|
|
if (!userEditedRef.current) {
|
|
// 첫 생성 — 템플릿 그대로 표시
|
|
setAutoGeneratedValue(code);
|
|
onChange(code);
|
|
} else if (oldTemplate !== code) {
|
|
// 카테고리 변경으로 템플릿 갱신 + 기존 manualInputValue 유지 → 새 조합값 부모에 반영
|
|
const parts = code.split("____");
|
|
const newPrefix = parts[0] || "";
|
|
const newSuffix = parts.length > 1 ? parts.slice(1).join("") : "";
|
|
const newCombined = newPrefix + manualInputValue + newSuffix;
|
|
setAutoGeneratedValue(newCombined);
|
|
onChange(newCombined);
|
|
}
|
|
} else {
|
|
hadManualPartRef.current = false;
|
|
templateRef.current = "";
|
|
setAutoGeneratedValue(code);
|
|
onChange(code);
|
|
userEditedRef.current = false;
|
|
}
|
|
} else {
|
|
console.warn("NumberingPicker: 채번 코드 생성 실패", resp);
|
|
}
|
|
} catch (err) {
|
|
if (!cancelled) console.error("NumberingPicker: 자동생성 오류", err);
|
|
} finally {
|
|
if (!cancelled) setIsGenerating(false);
|
|
}
|
|
};
|
|
|
|
run();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [tableName, columnName, isDesignMode, isEditMode, categoryValuesForNumbering, propRuleId]);
|
|
|
|
// ─── 디바운스: manualInputValue 변경 시 suffix 동적 갱신 ────
|
|
useEffect(() => {
|
|
if (isDesignMode) return;
|
|
if (!templateRef.current.includes("____")) return;
|
|
if (!ruleIdRef.current) return;
|
|
if (!userEditedRef.current) return;
|
|
|
|
let cancelled = false;
|
|
|
|
const timer = setTimeout(async () => {
|
|
try {
|
|
const resp = await previewNumberingCode(
|
|
ruleIdRef.current!,
|
|
formDataRef.current,
|
|
manualInputValue || undefined,
|
|
);
|
|
if (cancelled) return;
|
|
if (resp.success && resp.data?.generatedCode) {
|
|
const newTemplate = resp.data.generatedCode;
|
|
if (newTemplate.includes("____")) {
|
|
templateRef.current = newTemplate;
|
|
const parts = newTemplate.split("____");
|
|
const prefix = parts[0] || "";
|
|
const suffix = parts.length > 1 ? parts.slice(1).join("") : "";
|
|
const combined = prefix + manualInputValue + suffix;
|
|
setAutoGeneratedValue(combined);
|
|
onChange(combined);
|
|
}
|
|
}
|
|
} catch {
|
|
/* 미리보기 실패 시 기존 suffix 유지 */
|
|
}
|
|
}, 300);
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
clearTimeout(timer);
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [manualInputValue, isDesignMode]);
|
|
|
|
// ─── beforeFormSave 리스너: 저장 직전 조합값 주입 ──────────
|
|
useEffect(() => {
|
|
if (isDesignMode || !columnName) return;
|
|
|
|
const handler = (event: Event) => {
|
|
const customEvent = event as CustomEvent;
|
|
const template = templateRef.current;
|
|
if (!template || !template.includes("____")) return;
|
|
|
|
const parts = template.split("____");
|
|
const prefix = parts[0] || "";
|
|
const suffix = parts.length > 1 ? parts.slice(1).join("") : "";
|
|
const combined = prefix + manualInputValue + suffix;
|
|
|
|
if (customEvent.detail?.formData && columnName) {
|
|
customEvent.detail.formData[columnName] = combined;
|
|
}
|
|
};
|
|
|
|
window.addEventListener("beforeFormSave", handler);
|
|
return () => window.removeEventListener("beforeFormSave", handler);
|
|
}, [columnName, manualInputValue, isDesignMode]);
|
|
|
|
// ─── 렌더 ──────────────────────────────────────────────────
|
|
const displayValue =
|
|
autoGeneratedValue !== null
|
|
? autoGeneratedValue
|
|
: typeof value === "string"
|
|
? value
|
|
: "";
|
|
|
|
const template = templateRef.current;
|
|
const canEdit = hadManualPartRef.current && template && !readonly && !disabled;
|
|
|
|
// 박스 외관은 부모 (InputComponent) 가 담당. 여기선 슬롯 내부만.
|
|
// 부모의 borderRadius 안에 prefix/suffix muted span 이 깨끗하게 잘리도록 overflow/inherit.
|
|
const wrapperStyle: React.CSSProperties = {
|
|
width: "100%",
|
|
height: "100%",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
overflow: "hidden",
|
|
borderRadius: "inherit",
|
|
...style,
|
|
};
|
|
|
|
// ____ 없는 경우: readonly 표시
|
|
if (!canEdit) {
|
|
return (
|
|
<input
|
|
type="text"
|
|
value={displayValue}
|
|
readOnly
|
|
disabled={disabled || isGenerating}
|
|
placeholder={isGenerating ? "생성 중..." : placeholder || "자동채번"}
|
|
className={className}
|
|
style={{
|
|
width: "100%",
|
|
height: "100%",
|
|
padding: "5px 8px",
|
|
fontSize: "13px",
|
|
border: 0,
|
|
borderRadius: 0,
|
|
background: "hsl(var(--muted))",
|
|
color: "hsl(var(--muted-foreground))",
|
|
fontFamily: "monospace",
|
|
outline: "none",
|
|
boxSizing: "border-box",
|
|
...style,
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// ____ 있는 경우: prefix + 편집 input + suffix
|
|
const parts = template.split("____");
|
|
const prefix = parts[0] || "";
|
|
const suffix = parts.length > 1 ? parts.slice(1).join("") : "";
|
|
|
|
return (
|
|
<div className={className} style={wrapperStyle}>
|
|
{prefix && (
|
|
<span
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
height: "100%",
|
|
padding: "0 8px",
|
|
fontSize: "13px",
|
|
fontFamily: "monospace",
|
|
color: "hsl(var(--muted-foreground))",
|
|
background: "hsl(var(--muted))",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{prefix}
|
|
</span>
|
|
)}
|
|
<input
|
|
type="text"
|
|
value={manualInputValue}
|
|
onChange={(e) => {
|
|
const newInput = e.target.value;
|
|
setManualInputValue(newInput);
|
|
userEditedRef.current = true;
|
|
|
|
const newValue = prefix + newInput + suffix;
|
|
setAutoGeneratedValue(newValue);
|
|
onChange(newValue);
|
|
|
|
// 외부 listener (다른 컴포넌트가 채번 변경 감지) 호환
|
|
if (typeof window !== "undefined" && columnName) {
|
|
window.dispatchEvent(
|
|
new CustomEvent("numberingValueChanged", {
|
|
detail: { columnName, value: newValue },
|
|
}),
|
|
);
|
|
}
|
|
}}
|
|
placeholder="입력"
|
|
disabled={disabled || isGenerating}
|
|
style={{
|
|
flex: 1,
|
|
minWidth: 60,
|
|
height: "100%",
|
|
padding: "0 8px",
|
|
fontSize: "13px",
|
|
fontFamily: "monospace",
|
|
border: 0,
|
|
borderRadius: 0,
|
|
background: "transparent",
|
|
color: "hsl(var(--foreground))",
|
|
outline: "none",
|
|
boxSizing: "border-box",
|
|
}}
|
|
/>
|
|
{suffix && (
|
|
<span
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
height: "100%",
|
|
padding: "0 8px",
|
|
fontSize: "13px",
|
|
fontFamily: "monospace",
|
|
color: "hsl(var(--muted-foreground))",
|
|
background: "hsl(var(--muted))",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{suffix}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|