"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 = ({ component, isDesignMode = false, isSelected = false, onClick, onDragStart, onDragEnd, config, className, style, ...props }) => { // ─── 4경로 머지 (표준 패턴) ────────────────────────────────────────── // ★ 주의: props.type 은 component.type ('widget'/'component') 일 수 있으므로 // InputFieldType 10종에 포함될 때만 사용. const fromProps: Partial = {}; 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( 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 ( propagate(e.target.valueAsNumber)} min={componentConfig.min} max={componentConfig.max} step={componentConfig.step} {...common} /> ); case "date": return ( 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 ( 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 ( propagate(v)} disabled={disabled} readonly={readonly} className="border-0 bg-transparent rounded-none" /> ); case "daterange": return ( 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 (