Files
invyone/frontend/lib/registry/components/input/InputComponent.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

742 lines
26 KiB
TypeScript

"use client";
import React, { useState, useEffect } from "react";
import { ComponentRendererProps } from "@/types/component";
import { InputConfig, InputFieldType } from "./types";
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
import { SingleDatePicker, DateTimePicker, TimePicker, RangeDatePicker } from "./pickers";
import { SingleSelectPicker, MultiSelectPicker, RadioPicker, CheckboxListPicker, TogglePicker, TagPicker } from "./select-pickers";
import { NumberingPicker } from "./numbering-picker";
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
/**
* Input — 통합 필드 입력 컴포넌트
*
* FieldConfig.type (10종) 기반 내부 분기 렌더. 하나의 컴포넌트가 text/number/
* date/datetime/select/entity/checkbox/textarea/file/code 를 전부 처리한다.
*
* 관련: notes/gbpark/2026-04-11-component-unification-plan.md §3.4
*/
const VALID_TYPES: InputFieldType[] = [
"text",
"number",
"date",
"datetime",
"time",
"daterange",
"select",
"entity",
"checkbox",
"textarea",
"file",
"code",
];
function isValidType(v: unknown): v is InputFieldType {
return typeof v === "string" && (VALID_TYPES as string[]).includes(v);
}
export interface InputComponentProps extends ComponentRendererProps {
config?: InputConfig;
}
export const InputComponent: React.FC<InputComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
onClick,
onDragStart,
onDragEnd,
config,
className,
style,
...props
}) => {
// ─── 4경로 머지 (표준 패턴) ──────────────────────────────────────────
// ★ 주의: props.type 은 component.type ('widget'/'component') 일 수 있으므로
// InputFieldType 10종에 포함될 때만 사용.
const fromProps: Partial<InputConfig> = {};
const p = props as any;
if (isValidType(p.type)) fromProps.type = p.type;
if (typeof p.label === "string") fromProps.label = p.label;
if (typeof p.placeholder === "string") fromProps.placeholder = p.placeholder;
if (typeof p.helperText === "string") fromProps.helperText = p.helperText;
if (p.defaultValue !== undefined) fromProps.defaultValue = p.defaultValue;
if (typeof p.required === "boolean") fromProps.required = p.required;
if (typeof p.editable === "boolean") fromProps.editable = p.editable;
if (typeof p.readonly === "boolean") fromProps.readonly = p.readonly;
if (typeof p.disabled === "boolean") fromProps.disabled = p.disabled;
if (Array.isArray(p.options)) fromProps.options = p.options;
if (p.ref && typeof p.ref === "object" && !("current" in p.ref))
fromProps.ref = p.ref;
if (typeof p.format === "string") fromProps.format = p.format;
if (typeof p.computed === "string") fromProps.computed = p.computed;
if (typeof p.min === "number") fromProps.min = p.min;
if (typeof p.max === "number") fromProps.max = p.max;
if (typeof p.step === "number") fromProps.step = p.step;
if (typeof p.minLength === "number") fromProps.minLength = p.minLength;
if (typeof p.maxLength === "number") fromProps.maxLength = p.maxLength;
if (typeof p.rows === "number") fromProps.rows = p.rows;
if (typeof p.accept === "string") fromProps.accept = p.accept;
if (typeof p.multiple === "boolean") fromProps.multiple = p.multiple;
const componentConfig = {
...config,
...((component as any).config ?? {}),
...((component as any).componentConfig ?? {}),
...fromProps,
} as InputConfig;
// type 결정 — InvField canonical (kind/type/format) 우선, 옛 inputType/web_type 폴백.
const rawType: any = componentConfig.type;
const inputType: any = (componentConfig as any).inputType;
const webType: any = (componentConfig as any).web_type;
const fmt: any = (componentConfig as any).format;
const type: InputFieldType = (() => {
// ─── InvField (canonical) type 매핑 ─────────────────────────
// InvField 의 type union 은 text/number/money/date/single/multi/autonum/formula/audit/file.
// InputFieldType 과 다른 일부는 매핑.
if (rawType === "money") return "number"; // money → number (currency 표시)
if (rawType === "single" || rawType === "multi") {
if (fmt === "boolean") return "checkbox";
return "select";
}
if (rawType === "autonum") return "code";
if (rawType === "formula") return "text"; // computed readonly
if (rawType === "audit") return "datetime"; // current_time autoGen
if (rawType === "date") {
// InvField 의 date 는 format=date|datetime|time|range 로 sub 분기
if (fmt === "datetime") return "datetime";
if (fmt === "time") return "time";
if (fmt === "range") return "daterange";
return "date";
}
// text/number/file/checkbox 등 InputFieldType 와 동일한 키는 그대로
if (isValidType(rawType)) return rawType;
// ─── 옛 inputType / web_type 폴백 (점진 폐기 영역) ───────────
if (isValidType(inputType)) return inputType;
if (rawType === "v2-select") return "select";
// V2-era 의 inputType="numbering" → code (autonum 의 옛 키)
if (inputType === "numbering" || webType === "numbering") return "code";
if (
inputType === "select" ||
inputType === "code" ||
inputType === "dropdown" ||
inputType === "radio" ||
inputType === "entity"
) return "select";
if (inputType === "number" || inputType === "decimal" || inputType === "currency") return "number";
if (inputType === "date") return "date";
if (inputType === "datetime") return "datetime";
if (inputType === "time") return "time";
if (inputType === "daterange") return "daterange";
if (inputType === "textarea") return "textarea";
if (inputType === "checkbox" || inputType === "boolean") return "checkbox";
if (inputType === "file" || inputType === "image") return "file";
if (
webType === "select" ||
webType === "code" ||
webType === "dropdown" ||
webType === "radio" ||
webType === "entity"
) return "select";
if (webType === "number" || webType === "decimal") return "number";
if (webType === "date") return "date";
if (webType === "datetime") return "datetime";
if (webType === "time") return "time";
if (webType === "daterange") return "daterange";
if (webType === "textarea") return "textarea";
if (webType === "checkbox" || webType === "boolean") return "checkbox";
if (webType === "file" || webType === "image") return "file";
return "text";
})();
const label = componentConfig.label;
const placeholder = componentConfig.placeholder ?? "";
const helperText = componentConfig.helperText;
const required = componentConfig.required ?? false;
const editable = componentConfig.editable !== false;
const readonly = componentConfig.readonly ?? !editable;
const disabled = componentConfig.disabled ?? false;
const rows = componentConfig.rows ?? 3;
// 부모가 formData[columnName] 또는 직접 value 를 넘겨주면 controlled. 없으면 로컬 state.
// 입력 변경 시 부모 콜백(onFormDataChange / onChange) 둘 다 시도해서 BlockRenderer 의
// formRow 갱신 경로를 끊김 없이 잇는다.
const formDataProp = (props as any).formData ?? (props as any).form_data;
const onFormDataChangeProp = (props as any).onFormDataChange;
const onChangeProp = (props as any).onChange;
const columnName: string | undefined =
(component as any).columnName ?? (component as any).column_name;
// tableName / isEditMode — autonum (code) 채번 hook 용
// `||` 사용: `??` 는 빈 문자열을 통과시켜 뒤 폴백을 막음.
const tableName: string | undefined =
(props as any).tableName ||
(componentConfig as any).tableName ||
(component as any).tableName ||
(component as any).overrides?.tableName ||
(props as any).screenInfo?.tableName;
// originalData 와 _originalData (V2-era 별칭) 둘 다 고려
const originalData = (props as any).originalData || (props as any)._originalData;
const isEditMode = !!originalData && Object.keys(originalData).length > 0;
const controlledValue =
formDataProp && columnName
? formDataProp[columnName]
: (props as any).value !== undefined
? (props as any).value
: undefined;
// 의미있는 controlled 값 — 빈 문자열 / null 은 "값 없음" 으로 취급 → defaultValue fallback.
// (ScreenDesigner 가 디자인 모드에서 빈 value 를 넘기는 케이스 방어)
const hasControlled =
controlledValue !== undefined && controlledValue !== null && controlledValue !== "";
const [localValue, setLocalValue] = useState<unknown>(
hasControlled ? controlledValue : (componentConfig.defaultValue ?? ""),
);
useEffect(() => {
if (hasControlled) {
setLocalValue((prev: unknown) => (prev === controlledValue ? prev : controlledValue));
} else if (componentConfig.defaultValue !== undefined && componentConfig.defaultValue !== null) {
setLocalValue((prev: unknown) =>
prev === componentConfig.defaultValue ? prev : componentConfig.defaultValue,
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasControlled, controlledValue, componentConfig.defaultValue]);
// value 우선순위:
// 1) 운영 모드의 controlledValue (formData / props.value)
// 2) componentConfig.defaultValue (사용자가 ConfigPanel 에 설정한 기본값)
// 3) localValue (사용자 직접 입력 / 자동생성 결과)
const dvDefined =
componentConfig.defaultValue !== undefined &&
componentConfig.defaultValue !== null &&
componentConfig.defaultValue !== "";
const value: unknown = hasControlled
? controlledValue
: (dvDefined ? componentConfig.defaultValue : localValue);
const propagate = (v: unknown) => {
setLocalValue(v);
if (typeof onFormDataChangeProp === "function" && columnName) onFormDataChangeProp(columnName, v);
if (typeof onChangeProp === "function") onChangeProp(v);
};
// ─── 자동생성 hook ──────────────────────────────────────────
// 디자인 모드 X / 자동생성 enabled / 값 비어있을 때만 trigger.
// numbering_rule 은 별도 API hook (Phase A.6) — 여기선 skip.
const autoGen = (componentConfig as any).autoGeneration;
const autoGenEnabled = !!autoGen?.enabled;
const autoGenType = autoGen?.type;
useEffect(() => {
if (isDesignMode) return;
if (!autoGenEnabled || !autoGenType || autoGenType === "none") return;
if (autoGenType === "numbering_rule") return;
const isEmpty = value === undefined || value === null || value === "";
if (!isEmpty) return;
let cancelled = false;
AutoGenerationUtils.generateValue(autoGen, columnName, formDataProp).then((generated) => {
if (cancelled || generated == null) return;
propagate(generated);
});
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDesignMode, autoGenEnabled, autoGenType]);
// ─── DOM props filter (React warning 방지) ────────────────────────────
/* eslint-disable @typescript-eslint/no-unused-vars */
const {
selectedScreen: _1,
onZoneComponentDrop: _2,
onZoneClick: _3,
componentConfig: _4,
component: _5,
isSelected: _6,
onClick: _7,
onDragStart: _8,
onDragEnd: _9,
size: _10,
position: _11,
style: _12,
screenId: _13,
tableName: _14,
onRefresh: _15,
onClose: _16,
web_type: _17,
autoGeneration: _18,
isInteractive: _19,
formData: _20,
onFormDataChange: _21,
menuId: _22,
menuObjid: _23,
onSave: _24,
userId: _25,
userName: _26,
companyCode: _27,
isInModal: _28,
originalData: _30,
_originalData: _31,
_initialData: _32,
_groupedData: _33,
allComponents: _34,
onUpdateLayout: _35,
selectedRows: _36,
selectedRowsData: _37,
onSelectedRowsChange: _38,
sortBy: _39,
sortOrder: _40,
tableDisplayData: _41,
flowSelectedData: _42,
flowSelectedStepId: _43,
onFlowSelectedDataChange: _44,
onConfigChange: _45,
refreshKey: _46,
flowRefreshKey: _47,
onFlowRefresh: _48,
isPreview: _49,
groupedData: _50,
// ★ InputConfig 필드 — DOM 에 spread 되면 React warning. 제외.
type: _51,
label: _52,
placeholder: _53,
helperText: _54,
defaultValue: _55,
required: _56,
editable: _57,
readonly: _58,
disabled: _59,
options: _60,
ref: _61,
format: _62,
computed: _63,
min: _64,
max: _65,
step: _66,
minLength: _67,
maxLength: _68,
rows: _69,
accept: _70,
multiple: _71,
// 기타 noise
columnName: _72,
fieldKey: _73,
fieldType: _74,
inputType: _75,
value: _76,
...domProps
} = props as any;
/* eslint-enable @typescript-eslint/no-unused-vars */
// container 자체가 input box 역할 — border + radius + background.
// 자식 element 는 자체 border 없이 transparent 로 가득 채움 (이중 박스 방지).
// 단 radio/check 같은 list 형태는 외각 box 자체 불필요 (자체 visual element 가 표시).
const selectMode = (componentConfig as any).mode;
const isListLikeMode = type === "select" && (selectMode === "radio" || selectMode === "check");
const containerStyle: React.CSSProperties = {
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
boxSizing: "border-box",
border: isListLikeMode ? "none" : "1px solid hsl(var(--border))",
borderRadius: isListLikeMode ? 0 : "4px",
background: isListLikeMode ? "transparent" : (disabled ? "hsl(var(--muted))" : "hsl(var(--card))"),
position: "relative",
...(component as any).style,
...style,
};
if (isDesignMode && isSelected) {
containerStyle.outline = "2px solid hsl(var(--primary))";
containerStyle.outlineOffset = "2px";
}
// 위젯 슬롯 — type 별 element 가 들어갈 통일 wrapper. 박스 가득.
// overflow:hidden + borderRadius:inherit — numbering picker 의 prefix/suffix muted span
// 이 container 의 라운드 모서리 안에서 깨끗하게 잘리도록. label 은 absolute top:-18
// 로 container 바깥에 그려져서 영향 없음.
const inputSlotStyle: React.CSSProperties = {
flex: 1,
display: "flex",
width: "100%",
height: "100%",
minHeight: 0,
overflow: "hidden",
borderRadius: "inherit",
};
// 통일 element style — border/radius 없이 transparent. container 의 border 가 box 역할.
const baseInputStyle: React.CSSProperties = {
width: "100%",
height: "100%",
padding: "5px 8px",
fontSize: "13px",
border: 0,
borderRadius: 0,
background: "transparent",
color: "hsl(var(--foreground))",
outline: "none",
boxSizing: "border-box",
};
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.();
};
// ─── 타입별 입력 위젯 ─────────────────────────────────────────────────
const renderInput = () => {
const common = {
style: baseInputStyle,
disabled: disabled || isDesignMode,
readOnly: readonly,
placeholder,
};
switch (type) {
case "number":
return (
<input
type="number"
value={typeof value === "number" || typeof value === "string" ? (value as any) : ""}
onChange={(e) => propagate(e.target.valueAsNumber)}
min={componentConfig.min}
max={componentConfig.max}
step={componentConfig.step}
{...common}
/>
);
case "date":
return (
<SingleDatePicker
value={typeof value === "string" ? value : ""}
onChange={(v) => propagate(v)}
dateFormat={componentConfig.dateFormat || "YYYY-MM-DD"}
showToday={componentConfig.showToday !== false}
minDate={componentConfig.minDate}
maxDate={componentConfig.maxDate}
disabled={disabled}
readonly={readonly}
placeholder={placeholder}
className="border-0 bg-transparent rounded-none"
/>
);
case "datetime":
return (
<DateTimePicker
value={typeof value === "string" ? value : ""}
onChange={(v) => propagate(v)}
dateFormat={componentConfig.dateFormat || "YYYY-MM-DD HH:mm"}
minDate={componentConfig.minDate}
maxDate={componentConfig.maxDate}
disabled={disabled}
readonly={readonly}
className="border-0 bg-transparent rounded-none"
/>
);
case "time":
return (
<TimePicker
value={typeof value === "string" ? value : ""}
onChange={(v) => propagate(v)}
disabled={disabled}
readonly={readonly}
className="border-0 bg-transparent rounded-none"
/>
);
case "daterange":
return (
<RangeDatePicker
value={
Array.isArray(value) && value.length === 2
? (value as [string, string])
: ["", ""]
}
onChange={(v) => propagate(v)}
dateFormat={componentConfig.dateFormat || "YYYY-MM-DD"}
minDate={componentConfig.minDate}
maxDate={componentConfig.maxDate}
disabled={disabled}
readonly={readonly}
className="border-0 bg-transparent rounded-none"
/>
);
case "textarea":
return (
<textarea
value={typeof value === "string" ? value : ""}
onChange={(e) => propagate(e.target.value)}
rows={rows}
minLength={componentConfig.minLength}
maxLength={componentConfig.maxLength}
{...common}
style={{ ...baseInputStyle, resize: "vertical" }}
/>
);
case "select": {
const rawOptions = componentConfig.options ?? [];
const normalizedOptions = rawOptions.map((opt: any) =>
typeof opt === "string"
? { value: opt, label: opt }
: { value: String(opt.value ?? ""), label: String(opt.label ?? opt.value ?? "") },
);
const isMulti = rawType === "multi" || !!(componentConfig as any).multiple;
// 기존 옵션 — config.mode (dropdown|combobox|radio|check|tag|toggle)
const mode = (componentConfig as any).mode || "dropdown";
const searchable = mode === "combobox" || !!(componentConfig as any).searchable;
// multi 분기
if (isMulti) {
const arrValue = Array.isArray(value) ? (value as string[]) : value ? [String(value)] : [];
// format=tags 또는 mode=tag → TagPicker (chip + 입력)
const fmt = (componentConfig as any).format;
if (fmt === "tags" || mode === "tag") {
return (
<TagPicker
value={arrValue}
onChange={(v) => propagate(v)}
placeholder={placeholder || "태그 입력 후 Enter"}
maxSelect={(componentConfig as any).maxSelect}
disabled={disabled}
readonly={readonly}
className="border-0 bg-transparent rounded-none"
/>
);
}
if (mode === "check") {
return (
<CheckboxListPicker
value={arrValue}
onChange={(v) => propagate(v)}
options={normalizedOptions}
maxSelect={(componentConfig as any).maxSelect}
disabled={disabled}
readonly={readonly}
className="border-0 bg-transparent rounded-none"
/>
);
}
// dropdown / combobox (기본) — MultiSelectPicker
return (
<MultiSelectPicker
value={arrValue}
onChange={(v) => propagate(v)}
options={normalizedOptions}
placeholder={placeholder || "선택"}
searchable={searchable}
allowClear={!!(componentConfig as any).allowClear}
maxSelect={(componentConfig as any).maxSelect}
disabled={disabled}
readonly={readonly}
className="border-0 bg-transparent rounded-none"
/>
);
}
// single 분기
if (mode === "radio") {
return (
<RadioPicker
value={typeof value === "string" ? value : ""}
onChange={(v) => propagate(v)}
options={normalizedOptions}
disabled={disabled}
readonly={readonly}
className="border-0 bg-transparent rounded-none"
/>
);
}
// dropdown / combobox (기본) — SingleSelectPicker
return (
<SingleSelectPicker
value={typeof value === "string" ? value : ""}
onChange={(v) => propagate(v)}
options={normalizedOptions}
placeholder={placeholder || "선택"}
searchable={searchable}
allowClear={!!(componentConfig as any).allowClear}
disabled={disabled || isDesignMode}
readonly={readonly}
className="border-0 bg-transparent rounded-none"
/>
);
}
case "checkbox": {
// single.boolean 영역. mode=toggle (기본 권장) 이면 TogglePicker, 그 외 단일 체크박스.
const cbMode = (componentConfig as any).mode;
if (cbMode === "toggle") {
return (
<TogglePicker
value={value}
onChange={(v) => propagate(v)}
disabled={disabled}
readonly={readonly}
className="border-0 bg-transparent rounded-none"
/>
);
}
return (
<label
style={{
display: "inline-flex",
alignItems: "center",
gap: "6px",
fontSize: "13px",
padding: "0 8px",
cursor: disabled ? "not-allowed" : "pointer",
}}
>
<input
type="checkbox"
checked={!!value}
onChange={(e) => propagate(e.target.checked)}
disabled={disabled}
readOnly={readonly}
/>
<span>{placeholder || label || "체크"}</span>
</label>
);
}
case "entity":
return (
<div style={{ display: "flex", gap: "4px", width: "100%", height: "100%" }}>
<input
type="text"
value={typeof value === "string" ? value : ""}
onChange={(e) => propagate(e.target.value)}
{...common}
readOnly
placeholder={placeholder || "검색 팝업에서 선택"}
/>
<button
type="button"
style={{
padding: "5px 10px",
fontSize: "12px",
height: "100%",
border: "1px solid hsl(var(--border))",
background: "hsl(var(--muted))",
borderRadius: "4px",
cursor: disabled || isDesignMode ? "not-allowed" : "pointer",
flexShrink: 0,
}}
disabled={disabled || isDesignMode}
>
🔍
</button>
</div>
);
case "file":
return (
<input
type="file"
accept={componentConfig.accept}
multiple={componentConfig.multiple}
{...common}
style={{ ...baseInputStyle, padding: "3px 6px" }}
/>
);
case "code":
return (
<NumberingPicker
value={value}
onChange={(v) => propagate(v)}
tableName={tableName}
columnName={columnName}
formData={formDataProp}
numberingRuleId={autoGen?.options?.numberingRuleId}
isEditMode={isEditMode}
isDesignMode={isDesignMode}
disabled={disabled}
readonly={readonly}
placeholder={placeholder}
className="border-0 bg-transparent rounded-none"
onRuleIdResolved={(ruleId) => {
// EditModal / buttonActions 의 `${columnName}_numberingRuleId` 메타 키 호환
if (typeof onFormDataChangeProp === "function" && columnName) {
onFormDataChangeProp(`${columnName}_numberingRuleId`, ruleId);
}
}}
/>
);
case "text":
default: {
// InvField format 별 native input type 분기 (password/email/tel/url)
const f = (componentConfig as any).format;
const inputHtmlType =
f === "password" ? "password" :
f === "email" ? "email" :
f === "phone" || f === "tel" ? "tel" :
f === "url" ? "url" :
"text";
return (
<input
type={inputHtmlType}
value={typeof value === "string" ? value : ""}
onChange={(e) => propagate(e.target.value)}
minLength={componentConfig.minLength}
maxLength={componentConfig.maxLength}
{...common}
/>
);
}
}
};
return (
<div
style={containerStyle}
className={className}
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
{...filterDOMProps(domProps)}
>
{label && (
<label
style={{
position: "absolute",
top: "-18px",
left: 0,
fontSize: "11px",
fontWeight: 600,
color: "hsl(var(--foreground))",
display: "flex",
alignItems: "center",
gap: "3px",
whiteSpace: "nowrap",
pointerEvents: "none",
}}
>
{label}
{required && <span style={{ color: "hsl(var(--destructive))" }}>*</span>}
</label>
)}
<div style={inputSlotStyle}>{renderInput()}</div>
{helperText && (
<span
style={{
fontSize: "10px",
color: "hsl(var(--muted-foreground))",
}}
>
{helperText}
</span>
)}
</div>
);
};
export const InputWrapper: React.FC<InputComponentProps> = (props) => {
return <InputComponent {...props} />;
};