998 lines
37 KiB
TypeScript
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} />;
|
|
};
|