Files
invyone/frontend/lib/registry/components/input/InputComponent.tsx
T

998 lines
37 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, SwapPicker } from "./select-pickers";
import { NumberingPicker } from "./numbering-picker";
import { FilePicker } from "./file-picker";
import { useOptionLoader } from "./use-option-loader";
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);
}
/**
* 텍스트 입력 mask 적용. `#`/`0` = 숫자, `A` = 영문, `*` = 임의 문자, 그 외는 literal.
* 예: `###-####` + "1234567" → "123-4567"
*
* 사용자가 친 값에서 mask 와 매칭되지 않는 문자는 건너뛰고, literal 자리는 항상 그대로 출력.
* 사용자가 literal 위치에 literal 과 동일한 문자를 친 경우는 1회 소비해서 정렬을 맞춘다.
*/
function applyInputMask(value: string, mask: string): string {
if (!mask) return value;
let out = "";
let vi = 0;
for (let mi = 0; mi < mask.length; mi++) {
const m = mask[mi];
if (vi >= value.length) break;
if (m === "#" || m === "0") {
while (vi < value.length && !/[0-9]/.test(value[vi])) vi++;
if (vi >= value.length) break;
out += value[vi++];
} else if (m === "A") {
while (vi < value.length && !/[A-Za-z]/.test(value[vi])) vi++;
if (vi >= value.length) break;
out += value[vi++];
} else if (m === "*") {
out += value[vi++];
} else {
out += m;
if (value[vi] === m) vi++;
}
}
return out;
}
const HEX_PATTERN = /^#[0-9a-fA-F]{6}$/;
function normalizeHex(v: unknown, fallback = "#000000"): string {
if (typeof v === "string" && HEX_PATTERN.test(v)) return v;
return fallback;
}
/**
* 색상 텍스트 입력 sanitizer — 항상 `#rrggbb` 6자리 hex 로 강제.
* - 선행 `#` 제거
* - hex 가 아닌 문자 제거
* - 6자 초과는 잘라냄
* - 모자란 자리는 `0` 으로 패딩
* - 결과는 소문자 `#rrggbb`
*/
function sanitizeHexInput(raw: string): string {
const stripped = raw.startsWith("#") ? raw.slice(1) : raw;
const hexOnly = stripped.replace(/[^0-9a-fA-F]/g, "").toLowerCase();
const trimmed = hexOnly.slice(0, 6);
const padded = trimmed.padEnd(6, "0");
return `#${padded}`;
}
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;
if (typeof p.mask === "string") fromProps.mask = p.mask;
const componentConfig = {
...config,
...((component as any).config ?? {}),
...((component as any).componentConfig ?? {}),
...fromProps,
} as InputConfig;
// type 결정 — InvField canonical (kind/type/format) 우선, 옛 inputType/webType 폴백.
// webType 은 camelCase / snake_case 두 표기 모두 흡수.
const rawType: any = componentConfig.type;
const inputType: any = (componentConfig as any).inputType;
const webType: any =
(componentConfig as any).webType ?? (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";
}
// slider / color — 옛 inputType/webType 진입점. 각각 number/text 로 라우팅 후 format 으로 분기.
// rawType 이 text/number 같은 유효값으로 같이 들어와도 명시 inputType/webType 을 존중한다.
if (inputType === "slider" || webType === "slider") return "number";
if (inputType === "color" || webType === "color") return "text";
// text/number/file/checkbox 등 InputFieldType 와 동일한 키는 그대로
if (isValidType(rawType)) return rawType;
// ─── 옛 inputType / webType 폴백 (점진 폐기 영역) ───────────
if (isValidType(inputType)) return inputType;
// (옛 V2 선택 rawType 분기 — Phase D.2 에서 제거. 더 이상 옛 V2 선택 컴포넌트가 생성/렌더되지 않음)
// V2-era 의 inputType="numbering" → code (autonum 의 옛 키)
if (inputType === "numbering" || webType === "numbering") return "code";
if (inputType === "entity") return "entity";
if (inputType === "select" || inputType === "code" || inputType === "dropdown" || inputType === "radio")
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" || inputType === "img") return "file";
if (webType === "entity") return "entity";
if (webType === "select" || webType === "code" || webType === "dropdown" || webType === "radio")
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" || webType === "img" || webType === "picture" || webType === "photo")
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 ??
(componentConfig as any).columnName ??
(componentConfig 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]);
// ─── 옵션 loader (select 계열) ──────────────────────────────────────
// type 이 select 가 아니더라도 hook 은 항상 호출 (React 규칙). source 가 static
// 이거나 fetch url 이 만들어지지 않으면 hook 내부에서 API 호출 자체 안 일어남.
// user context — runtime 의 `value_type === "user"` 필터 치환에 사용. props 에서
// camelCase / snake_case 둘 다 흡수. 값 없으면 user 필터는 hook 내부에서 skip.
const userContext = {
companyCode: (props as any).companyCode || (props as any).company_code,
userId: (props as any).userId || (props as any).user_id,
deptCode: (props as any).deptCode || (props as any).dept_code,
userName: (props as any).userName || (props as any).user_name,
};
const { options: loadedOptions } = useOptionLoader({
config: componentConfig as any,
tableName,
columnName,
formData: formDataProp,
userContext,
isDesignMode,
});
// ─── 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,
webType: _17a,
autoGeneration: _18,
isInteractive: _19,
formData: _20,
onFormDataChange: _21,
menuId: _22,
menuObjid: _23,
onSave: _24,
userId: _25,
userName: _26,
companyCode: _27,
deptCode: _27a,
user_id: _27b,
user_name: _27c,
company_code: _27d,
dept_code: _27e,
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,
mask: _71a,
// 기타 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 selectFormat = (componentConfig as any).format;
const isMultiSelect =
type === "select" && (rawType === "multi" || !!(componentConfig as any).multiple);
const selectMode =
(componentConfig as any).mode ||
(isMultiSelect && selectFormat === "list" ? "check" : "dropdown");
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": {
const numFmt = (componentConfig as any).format;
const isSlider =
numFmt === "slider" || inputType === "slider" || webType === "slider";
if (isSlider) {
const minN = typeof componentConfig.min === "number" ? componentConfig.min : 0;
const maxN = typeof componentConfig.max === "number" ? componentConfig.max : 100;
const stepN = typeof componentConfig.step === "number" ? componentConfig.step : 1;
const parsed =
typeof value === "number"
? value
: typeof value === "string" && value !== ""
? Number(value)
: NaN;
const sliderVal = Number.isFinite(parsed) ? parsed : minN;
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
padding: "0 8px",
boxSizing: "border-box",
}}
>
<input
type="range"
min={minN}
max={maxN}
step={stepN}
value={sliderVal}
onChange={(e) => propagate(e.target.valueAsNumber)}
disabled={disabled || isDesignMode || readonly}
style={{ flex: 1, accentColor: "hsl(var(--primary))" }}
/>
<span
style={{
fontSize: 12,
minWidth: 36,
textAlign: "right",
color: "hsl(var(--foreground))",
}}
>
{sliderVal}
</span>
</div>
);
}
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": {
// 옵션은 useOptionLoader 가 결정. static / code / category / distinct / api 모두 처리.
// 로딩 중 / fetch 미발동 (디자인 모드 등) 시에는 static 옵션을 우선 보여준다.
const normalizedOptions = loadedOptions;
const isMulti = rawType === "multi" || !!(componentConfig as any).multiple;
// 기존 옵션 — config.mode (dropdown|combobox|radio|check|tag|swap|toggle)
// multi + 고정 목록은 패널 설명처럼 체크박스 list 를 기본값으로 둔다.
const mode = selectMode;
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"
/>
);
}
if (mode === "swap") {
return (
<SwapPicker
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": {
// entity 는 검색 모달이 아니라 참조 테이블의 value/label 컬럼을 읽는
// code-name 선택형이다. 명시 검색 UI 는 entity-search-input 쪽 책임.
const entityOptions = loadedOptions;
const entityMode = (componentConfig as any).mode || "combobox";
const entitySearchable =
entityMode === "combobox" || (componentConfig as any).searchable === true;
const isEntityMulti = !!(componentConfig as any).multiple;
if (isEntityMulti) {
const arrValue = Array.isArray(value) ? (value as string[]) : value ? [String(value)] : [];
if (entityMode === "check") {
return (
<CheckboxListPicker
value={arrValue}
onChange={(v) => propagate(v)}
options={entityOptions}
maxSelect={(componentConfig as any).maxSelect}
disabled={disabled}
readonly={readonly}
className="border-0 bg-transparent rounded-none"
/>
);
}
if (entityMode === "swap") {
return (
<SwapPicker
value={arrValue}
onChange={(v) => propagate(v)}
options={entityOptions}
maxSelect={(componentConfig as any).maxSelect}
disabled={disabled}
readonly={readonly}
className="border-0 bg-transparent rounded-none"
/>
);
}
return (
<MultiSelectPicker
value={arrValue}
onChange={(v) => propagate(v)}
options={entityOptions}
placeholder={placeholder || "선택"}
searchable={entitySearchable}
allowClear={!!(componentConfig as any).allowClear}
maxSelect={(componentConfig as any).maxSelect}
disabled={disabled}
readonly={readonly}
className="border-0 bg-transparent rounded-none"
/>
);
}
return (
<SingleSelectPicker
value={typeof value === "string" ? value : value != null ? String(value) : ""}
onChange={(v) => propagate(v)}
options={entityOptions}
placeholder={placeholder || "선택"}
searchable={entitySearchable}
allowClear={!!(componentConfig as any).allowClear}
disabled={disabled || isDesignMode}
readonly={readonly}
className="border-0 bg-transparent rounded-none"
/>
);
}
case "file": {
// InvField format: "image" / "file" / "doc". image 이면 자동 preview + image/* accept.
// accept 가 명시되었으면 우선, 아니면 format 으로 기본값 결정.
const fmt = (componentConfig as any).format;
const isImageFormat = fmt === "image";
const defaultAccept = isImageFormat
? "image/*"
: fmt === "doc"
? ".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx"
: "*/*";
const acceptValue = componentConfig.accept || defaultAccept;
const showPreview =
(componentConfig as any).showPreview ?? (isImageFormat || acceptValue.startsWith("image/"));
return (
<FilePicker
value={value as any}
onChange={(v) => propagate(v)}
accept={acceptValue}
multiple={!!componentConfig.multiple}
maxFiles={(componentConfig as any).maxFiles}
disabled={disabled}
readonly={readonly}
placeholder={placeholder}
showPreview={!!showPreview}
className="border-0 bg-transparent rounded-none"
/>
);
}
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/color)
const f = (componentConfig as any).format;
const isColor =
f === "color" || inputType === "color" || webType === "color";
if (isColor) {
const rawStr = typeof value === "string" ? value : "";
const colorVal = normalizeHex(rawStr);
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
width: "100%",
padding: "0 8px",
boxSizing: "border-box",
}}
>
<input
type="color"
value={colorVal}
onChange={(e) => propagate(e.target.value)}
disabled={disabled || isDesignMode || readonly}
style={{
width: 28,
height: 22,
padding: 0,
border: 0,
background: "transparent",
cursor: disabled || readonly ? "not-allowed" : "pointer",
}}
/>
<input
type="text"
value={rawStr}
onChange={(e) => propagate(sanitizeHexInput(e.target.value))}
placeholder={placeholder || "#000000"}
maxLength={7}
disabled={disabled || isDesignMode}
readOnly={readonly}
style={{ ...baseInputStyle, flex: 1, padding: "0 4px" }}
/>
</div>
);
}
const maskPattern: string | undefined =
typeof componentConfig.mask === "string" && componentConfig.mask
? componentConfig.mask
: undefined;
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) => {
const raw = e.target.value;
const next = maskPattern ? applyInputMask(raw, maskPattern) : raw;
propagate(next);
}}
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} />;
};