"use client"; import { useState, useCallback, useEffect, useRef, useMemo } from "react"; import { cn } from "@/lib/utils"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Loader2 } from "lucide-react"; import { usePopEvent } from "@/hooks/pop"; import { dataApi } from "@/lib/api/data"; import type { PopFieldConfig, PopFieldItem, PopFieldSection, FieldSectionStyle, PopFieldReadSource, PopFieldAutoGenMapping, } from "./types"; import { DEFAULT_FIELD_CONFIG, DEFAULT_SECTION_APPEARANCES } from "./types"; // ======================================== // Props // ======================================== interface PopFieldComponentProps { config?: PopFieldConfig; screenId?: string; componentId?: string; } // ======================================== // 메인 컴포넌트 // ======================================== export function PopFieldComponent({ config, screenId, componentId, }: PopFieldComponentProps) { const cfg: PopFieldConfig = { ...DEFAULT_FIELD_CONFIG, ...config, sections: config?.sections?.length ? config.sections : DEFAULT_FIELD_CONFIG.sections, }; const { publish, subscribe } = usePopEvent(screenId || "default"); const containerRef = useRef(null); const [allValues, setAllValues] = useState>({}); const [hiddenValues, setHiddenValues] = useState>({}); const [errors, setErrors] = useState>({}); const [containerWidth, setContainerWidth] = useState(0); const hiddenMappings = cfg.saveConfig?.hiddenMappings ?? []; const autoGenMappings = cfg.saveConfig?.autoGenMappings ?? []; const visibleAutoGens = autoGenMappings.filter((m) => m.showInForm); // ResizeObserver로 컨테이너 너비 감시 useEffect(() => { if (typeof window === "undefined" || !containerRef.current) return; const ro = new ResizeObserver(([entry]) => { setContainerWidth(entry.contentRect.width); }); ro.observe(containerRef.current); return () => ro.disconnect(); }, []); // readSource 기반 DB 조회 + JSON 파싱 const fetchReadSourceData = useCallback( async (pkValue: unknown, readSource: PopFieldReadSource) => { if (!readSource.tableName || !readSource.pkColumn || !pkValue) return; try { const res = await dataApi.getTableData(readSource.tableName, { page: 1, size: 1, filters: { [readSource.pkColumn]: String(pkValue) }, }); if (!Array.isArray(res.data) || res.data.length === 0) return; const row = res.data[0] as Record; const extracted: Record = {}; for (const mapping of readSource.fieldMappings || []) { if (mapping.valueSource === "json_extract" && mapping.columnName && mapping.jsonKey) { const raw = row[mapping.columnName]; let parsed: Record = {}; if (typeof raw === "string") { try { parsed = JSON.parse(raw); } catch { /* ignore */ } } else if (typeof raw === "object" && raw !== null) { parsed = raw as Record; } extracted[mapping.fieldId] = parsed[mapping.jsonKey] ?? ""; } else if (mapping.valueSource === "db_column" && mapping.columnName) { extracted[mapping.fieldId] = row[mapping.columnName] ?? ""; } } const allFieldsInConfig = cfg.sections.flatMap((s) => s.fields || []); const valuesUpdate: Record = {}; for (const [fieldId, val] of Object.entries(extracted)) { const f = allFieldsInConfig.find((fi) => fi.id === fieldId); const key = f?.fieldName || f?.id || fieldId; valuesUpdate[key] = val; } if (Object.keys(valuesUpdate).length > 0) { setAllValues((prev) => ({ ...prev, ...valuesUpdate })); } } catch { // 조회 실패 시 무시 } }, [cfg.sections] ); // set_value 이벤트 수신 (useConnectionResolver의 enrichedPayload도 처리) useEffect(() => { if (!componentId) return; const unsub = subscribe( `__comp_input__${componentId}__set_value`, (payload: unknown) => { const raw = payload as Record | undefined; if (!raw) return; // useConnectionResolver가 감싼 enrichedPayload인지 확인 const isConnectionPayload = raw._connectionId !== undefined; const actual = isConnectionPayload ? (raw.value as Record | undefined) : raw; if (!actual) return; const data = actual as { fieldName?: string; value?: unknown; values?: Record; pkValue?: unknown; }; // row 객체가 통째로 온 경우 (pop-card-list selected_row 등) if (!data.fieldName && !data.values && !data.pkValue && typeof actual === "object") { const rowObj = actual as Record; setAllValues((prev) => ({ ...prev, ...rowObj })); // 숨은 필드 값 추출 (valueSource 기반) if (hiddenMappings.length > 0) { const extracted: Record = {}; for (const hm of hiddenMappings) { if (hm.valueSource === "db_column" && hm.sourceDbColumn) { if (rowObj[hm.sourceDbColumn] !== undefined) { extracted[hm.targetColumn] = rowObj[hm.sourceDbColumn]; } } else if (hm.valueSource === "json_extract" && hm.sourceJsonColumn && hm.sourceJsonKey) { const raw = rowObj[hm.sourceJsonColumn]; let parsed: Record = {}; if (typeof raw === "string") { try { parsed = JSON.parse(raw); } catch { /* ignore */ } } else if (typeof raw === "object" && raw !== null) { parsed = raw as Record; } if (parsed[hm.sourceJsonKey] !== undefined) { extracted[hm.targetColumn] = parsed[hm.sourceJsonKey]; } } } if (Object.keys(extracted).length > 0) { setHiddenValues((prev) => ({ ...prev, ...extracted })); } } const pkCol = cfg.readSource?.pkColumn; const pkVal = pkCol ? rowObj[pkCol] : undefined; if (pkVal && cfg.readSource) { fetchReadSourceData(pkVal, cfg.readSource); } return; } if (data.values) { setAllValues((prev) => ({ ...prev, ...data.values })); } else if (data.fieldName) { setAllValues((prev) => ({ ...prev, [data.fieldName!]: data.value, })); } if (data.pkValue && cfg.readSource) { fetchReadSourceData(data.pkValue, cfg.readSource); } } ); return unsub; }, [componentId, subscribe, cfg.readSource, fetchReadSourceData]); // 필드 값 변경 핸들러 const handleFieldChange = useCallback( (fieldName: string, value: unknown) => { setAllValues((prev) => { const next = { ...prev, [fieldName]: value }; if (componentId) { publish(`__comp_output__${componentId}__value_changed`, { fieldName, value, allValues: next, hiddenValues, targetTable: cfg.saveConfig?.tableName || cfg.targetTable, saveConfig: cfg.saveConfig, readSource: cfg.readSource, }); } return next; }); setErrors((prev) => { if (!prev[fieldName]) return prev; const next = { ...prev }; delete next[fieldName]; return next; }); }, [componentId, publish, cfg.targetTable, cfg.saveConfig, cfg.readSource, hiddenValues] ); // readSource 설정 시 자동 샘플 데이터 조회 (디자인 모드 미리보기) const readSourceKey = cfg.readSource ? `${cfg.readSource.tableName}__${cfg.readSource.pkColumn}__${(cfg.readSource.fieldMappings || []).map((m) => `${m.fieldId}:${m.columnName}:${m.jsonKey || ""}`).join(",")}` : ""; const previewFetchedRef = useRef(""); useEffect(() => { if (!cfg.readSource?.tableName || !cfg.readSource.fieldMappings?.length) return; if (previewFetchedRef.current === readSourceKey) return; previewFetchedRef.current = readSourceKey; (async () => { try { const res = await dataApi.getTableData(cfg.readSource!.tableName, { page: 1, size: 1, }); if (!Array.isArray(res.data) || res.data.length === 0) return; const row = res.data[0] as Record; const extracted: Record = {}; for (const mapping of cfg.readSource!.fieldMappings || []) { if (mapping.valueSource === "json_extract" && mapping.columnName && mapping.jsonKey) { const rawVal = row[mapping.columnName]; let parsed: Record = {}; if (typeof rawVal === "string") { try { parsed = JSON.parse(rawVal); } catch { /* ignore */ } } else if (typeof rawVal === "object" && rawVal !== null) { parsed = rawVal as Record; } extracted[mapping.fieldId] = parsed[mapping.jsonKey] ?? ""; } else if (mapping.valueSource === "db_column" && mapping.columnName) { extracted[mapping.fieldId] = row[mapping.columnName] ?? ""; } } const allFieldsInConfig = cfg.sections.flatMap((s) => s.fields || []); const valuesUpdate: Record = {}; for (const [fieldId, val] of Object.entries(extracted)) { const f = allFieldsInConfig.find((fi) => fi.id === fieldId); const key = f?.fieldName || f?.id || fieldId; valuesUpdate[key] = val; } if (Object.keys(valuesUpdate).length > 0) { setAllValues((prev) => ({ ...prev, ...valuesUpdate })); } } catch { // 미리보기 조회 실패 시 무시 } })(); }, [readSourceKey, cfg.readSource, cfg.sections]); // "auto" 열 수 계산 function resolveColumns( columns: "auto" | 1 | 2 | 3 | 4, fieldCount: number ): number { if (columns !== "auto") return columns; if (containerWidth < 200) return 1; if (containerWidth < 400) return Math.min(2, fieldCount); if (containerWidth < 600) return Math.min(3, fieldCount); return Math.min(4, fieldCount); } function migrateStyle(style: string): FieldSectionStyle { if (style === "display" || style === "input") return style; if (style === "summary") return "display"; if (style === "form") return "input"; return "input"; } function sectionClassName(section: PopFieldSection): string { const resolved = migrateStyle(section.style); const defaults = DEFAULT_SECTION_APPEARANCES[resolved]; const a = section.appearance || {}; const bg = a.bgColor || defaults.bgColor; const border = a.borderColor || defaults.borderColor; return cn("rounded-lg border px-4", bg, border, resolved === "display" ? "py-2" : "py-3"); } return (
{cfg.sections.map((section) => { const fields = section.fields || []; const fieldCount = fields.length; if (fieldCount === 0) return null; const cols = resolveColumns(section.columns, fieldCount); return (
{section.label && (
{section.label}
)}
{fields.map((field) => { const fKey = field.fieldName || field.id; return ( ); })}
); })} {visibleAutoGens.length > 0 && (
{visibleAutoGens.map((ag) => ( ))}
)}
); } // ======================================== // FieldRenderer: 개별 필드 렌더링 // ======================================== interface FieldRendererProps { field: PopFieldItem; value: unknown; showLabel: boolean; error?: string; onChange: (fieldName: string, value: unknown) => void; sectionStyle: FieldSectionStyle; } function FieldRenderer({ field, value, showLabel, error, onChange, sectionStyle, }: FieldRendererProps) { const handleChange = useCallback( (v: unknown) => onChange(field.fieldName, v), [onChange, field.fieldName] ); const resolvedStyle = sectionStyle === "summary" ? "display" : sectionStyle === "form" ? "input" : sectionStyle; const inputClassName = cn( "h-9 w-full rounded-md border px-3 text-sm", field.readOnly ? "cursor-default bg-muted text-muted-foreground" : "bg-background", resolvedStyle === "display" && "border-transparent bg-transparent text-sm font-medium" ); return (
{showLabel && field.labelText && ( )} {renderByType(field, value, handleChange, inputClassName)} {error &&

{error}

}
); } // ======================================== // 서브타입별 렌더링 분기 // ======================================== function renderByType( field: PopFieldItem, value: unknown, onChange: (v: unknown) => void, className: string ) { switch (field.inputType) { case "text": return ( onChange(e.target.value)} readOnly={field.readOnly} placeholder={field.placeholder} className={className} /> ); case "number": return ( ); case "date": return ( onChange(e.target.value)} readOnly={field.readOnly} className={className} /> ); case "select": return ( ); case "auto": return ; case "numpad": return ( ); default: return ( ); } } // ======================================== // NumberFieldInput // ======================================== function NumberFieldInput({ field, value, onChange, className, }: { field: PopFieldItem; value: unknown; onChange: (v: unknown) => void; className: string; }) { return (
{ const num = e.target.value === "" ? "" : Number(e.target.value); onChange(num); }} readOnly={field.readOnly} placeholder={field.placeholder} min={field.validation?.min} max={field.validation?.max} className={cn(className, "flex-1")} /> {field.unit && ( {field.unit} )}
); } // ======================================== // SelectFieldInput // ======================================== function SelectFieldInput({ field, value, onChange, className, }: { field: PopFieldItem; value: unknown; onChange: (v: unknown) => void; className: string; }) { const [options, setOptions] = useState<{ value: string; label: string }[]>( [] ); const [loading, setLoading] = useState(false); const source = field.selectSource; useEffect(() => { if (!source) return; if (source.type === "static" && source.staticOptions) { setOptions(source.staticOptions); return; } if ( source.type === "table" && source.tableName && source.valueColumn && source.labelColumn ) { setLoading(true); dataApi .getTableData(source.tableName, { page: 1, pageSize: 500, sortColumn: source.labelColumn, sortDirection: "asc", }) .then((res) => { if (res.data?.success && Array.isArray(res.data.data?.data)) { setOptions( res.data.data.data.map((row: Record) => ({ value: String(row[source.valueColumn!] ?? ""), label: String(row[source.labelColumn!] ?? ""), })) ); } }) .catch(() => { setOptions([]); }) .finally(() => setLoading(false)); } }, [source?.type, source?.tableName, source?.valueColumn, source?.labelColumn, source?.staticOptions]); if (loading) { return (
); } if (field.readOnly) { const selectedLabel = options.find((o) => o.value === String(value ?? ""))?.label ?? String(value ?? "-"); return ( ); } if (!source) { return (
옵션 소스를 설정해주세요
); } return ( ); } // ======================================== // AutoFieldInput (자동 채번 - 읽기전용) // ======================================== function AutoFieldInput({ field, value, className, }: { field: PopFieldItem; value: unknown; className: string; }) { const displayValue = useMemo(() => { if (value) return String(value); if (!field.autoNumber) return "자동생성"; const { prefix, separator, dateFormat, sequenceDigits } = field.autoNumber; const parts: string[] = []; if (prefix) parts.push(prefix); if (dateFormat) { const now = new Date(); const dateStr = dateFormat .replace("YYYY", String(now.getFullYear())) .replace("MM", String(now.getMonth() + 1).padStart(2, "0")) .replace("DD", String(now.getDate()).padStart(2, "0")); parts.push(dateStr); } if (sequenceDigits) { parts.push("0".repeat(sequenceDigits)); } return parts.join(separator || "-") || "자동생성"; }, [value, field.autoNumber]); return ( ); } // ======================================== // AutoGenFieldDisplay (자동생성 필드 - showInForm일 때 표시) // ======================================== function AutoGenFieldDisplay({ mapping }: { mapping: PopFieldAutoGenMapping }) { return (
{mapping.label && ( )}
저장 시 자동발급
); } // ======================================== // NumpadFieldInput (클릭 시 숫자 직접 입력) // ======================================== function NumpadFieldInput({ field, value, onChange, className, }: { field: PopFieldItem; value: unknown; onChange: (v: unknown) => void; className: string; }) { const displayValue = value !== undefined && value !== null ? String(value) : ""; return (
{ const num = e.target.value === "" ? "" : Number(e.target.value); onChange(num); }} readOnly={field.readOnly} placeholder={field.placeholder || "수량 입력"} className={cn(className, "flex-1")} /> {field.unit && ( {field.unit} )}
); }