Files
invyone/frontend/lib/registry/components/input/numbering-picker.tsx
T
gbpark a5bbd1eb7c refactor(numbering-rule): NumberingRule → Input canonical 흡수 + 채번 관리 페이지 분리
- 옛 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>
2026-05-11 21:42:13 +09:00

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>
);
};