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>
742 lines
26 KiB
TypeScript
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} />;
|
|
};
|