feat(pop-field): 섹션 기반 멀티필드 입력 컴포넌트 구현

pop-field 컴포넌트 전체 구현 (types, component, config, registry):
- 6개 서브타입 (text/number/date/select/auto/numpad)
- 섹션 기반 레이아웃 (summary/form, auto 열 수 계산)
- 저장 탭 5섹션 (테이블/읽기/입력/숨은/자동생성 필드)
- 자동생성 레이아웃 연결 (linkedFieldId)
- 숨은 필드 DB/JSON 소스 선택 (JsonKeySelect 재사용)
- 채번 규칙 API 연동 (getAvailableNumberingRulesForScreen)
- 저장 탭 섹션별 접기/펼치기 토글
- 팔레트 4곳 등록 + index.ts 활성화
This commit is contained in:
SeongHyun Kim 2026-02-27 12:48:33 +09:00
parent 0ca031282b
commit 7bf20bda14
9 changed files with 3268 additions and 4 deletions

10
.gitignore vendored
View File

@ -294,3 +294,13 @@ claude.md
# 개인 작업 문서 (popdocs) # 개인 작업 문서 (popdocs)
popdocs/ popdocs/
.cursor/rules/popdocs-safety.mdc .cursor/rules/popdocs-safety.mdc
# ============================================
# KSH 개인 오케스트레이션 설정 (팀 공유 안함)
# ============================================
.cursor/rules/orchestrator.mdc
.cursor/agents/
.cursor/commands/
.cursor/hooks.json
.cursor/hooks/
.cursor/plans/

View File

@ -3,7 +3,7 @@
import { useDrag } from "react-dnd"; import { useDrag } from "react-dnd";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PopComponentType } from "../types/pop-layout"; import { PopComponentType } from "../types/pop-layout";
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search } from "lucide-react"; import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput } from "lucide-react";
import { DND_ITEM_TYPES } from "../constants"; import { DND_ITEM_TYPES } from "../constants";
// 컴포넌트 정의 // 컴포넌트 정의
@ -63,6 +63,12 @@ const PALETTE_ITEMS: PaletteItem[] = [
icon: Search, icon: Search,
description: "조건 입력 (텍스트/날짜/선택/모달)", description: "조건 입력 (텍스트/날짜/선택/모달)",
}, },
{
type: "pop-field",
label: "입력 필드",
icon: TextCursorInput,
description: "저장용 값 입력 (섹션별 멀티필드)",
},
]; ];
// 드래그 가능한 컴포넌트 아이템 // 드래그 가능한 컴포넌트 아이템

View File

@ -75,6 +75,7 @@ const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
"pop-button": "버튼", "pop-button": "버튼",
"pop-string-list": "리스트 목록", "pop-string-list": "리스트 목록",
"pop-search": "검색", "pop-search": "검색",
"pop-field": "입력",
}; };
// ======================================== // ========================================

View File

@ -9,7 +9,7 @@
/** /**
* POP * POP
*/ */
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button" | "pop-string-list" | "pop-search"; export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button" | "pop-string-list" | "pop-search" | "pop-field";
/** /**
* *
@ -361,6 +361,7 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: nu
"pop-button": { colSpan: 2, rowSpan: 1 }, "pop-button": { colSpan: 2, rowSpan: 1 },
"pop-string-list": { colSpan: 4, rowSpan: 3 }, "pop-string-list": { colSpan: 4, rowSpan: 3 },
"pop-search": { colSpan: 4, rowSpan: 2 }, "pop-search": { colSpan: 4, rowSpan: 2 },
"pop-field": { colSpan: 6, rowSpan: 2 },
}; };
/** /**

View File

@ -21,6 +21,7 @@ import "./pop-button";
import "./pop-string-list"; import "./pop-string-list";
import "./pop-search"; import "./pop-search";
import "./pop-field";
// 향후 추가될 컴포넌트들: // 향후 추가될 컴포넌트들:
// import "./pop-field";
// import "./pop-list"; // import "./pop-list";

View File

@ -0,0 +1,732 @@
"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<HTMLDivElement>(null);
const [allValues, setAllValues] = useState<Record<string, unknown>>({});
const [hiddenValues, setHiddenValues] = useState<Record<string, unknown>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
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<string, unknown>;
const extracted: Record<string, unknown> = {};
for (const mapping of readSource.fieldMappings || []) {
if (mapping.valueSource === "json_extract" && mapping.columnName && mapping.jsonKey) {
const raw = row[mapping.columnName];
let parsed: Record<string, unknown> = {};
if (typeof raw === "string") {
try { parsed = JSON.parse(raw); } catch { /* ignore */ }
} else if (typeof raw === "object" && raw !== null) {
parsed = raw as Record<string, unknown>;
}
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<string, unknown> = {};
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<string, unknown> | undefined;
if (!raw) return;
// useConnectionResolver가 감싼 enrichedPayload인지 확인
const isConnectionPayload = raw._connectionId !== undefined;
const actual = isConnectionPayload
? (raw.value as Record<string, unknown> | undefined)
: raw;
if (!actual) return;
const data = actual as {
fieldName?: string;
value?: unknown;
values?: Record<string, unknown>;
pkValue?: unknown;
};
// row 객체가 통째로 온 경우 (pop-card-list selected_row 등)
if (!data.fieldName && !data.values && !data.pkValue && typeof actual === "object") {
const rowObj = actual as Record<string, unknown>;
setAllValues((prev) => ({ ...prev, ...rowObj }));
// 숨은 필드 값 추출 (valueSource 기반)
if (hiddenMappings.length > 0) {
const extracted: Record<string, unknown> = {};
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<string, unknown> = {};
if (typeof raw === "string") {
try { parsed = JSON.parse(raw); } catch { /* ignore */ }
} else if (typeof raw === "object" && raw !== null) {
parsed = raw as Record<string, unknown>;
}
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<string, unknown>;
const extracted: Record<string, unknown> = {};
for (const mapping of cfg.readSource!.fieldMappings || []) {
if (mapping.valueSource === "json_extract" && mapping.columnName && mapping.jsonKey) {
const rawVal = row[mapping.columnName];
let parsed: Record<string, unknown> = {};
if (typeof rawVal === "string") {
try { parsed = JSON.parse(rawVal); } catch { /* ignore */ }
} else if (typeof rawVal === "object" && rawVal !== null) {
parsed = rawVal as Record<string, unknown>;
}
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<string, unknown> = {};
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 (
<div
ref={containerRef}
className="flex h-full w-full flex-col gap-2 overflow-auto p-1"
>
{cfg.sections.map((section) => {
const fields = section.fields || [];
const fieldCount = fields.length;
if (fieldCount === 0) return null;
const cols = resolveColumns(section.columns, fieldCount);
return (
<div key={section.id} className={sectionClassName(section)}>
{section.label && (
<div className="mb-1 text-xs font-medium text-muted-foreground">
{section.label}
</div>
)}
<div
className="grid gap-3"
style={{ gridTemplateColumns: `repeat(${cols}, 1fr)` }}
>
{fields.map((field) => {
const fKey = field.fieldName || field.id;
return (
<FieldRenderer
key={field.id}
field={{ ...field, fieldName: fKey }}
value={allValues[fKey]}
showLabel={section.showLabels}
error={errors[fKey]}
onChange={handleFieldChange}
sectionStyle={section.style}
/>
);
})}
</div>
</div>
);
})}
{visibleAutoGens.length > 0 && (
<div className="rounded-lg border border-border bg-background px-4 py-3">
<div
className="grid gap-3"
style={{ gridTemplateColumns: `repeat(${resolveColumns("auto", visibleAutoGens.length)}, 1fr)` }}
>
{visibleAutoGens.map((ag) => (
<AutoGenFieldDisplay key={ag.id} mapping={ag} />
))}
</div>
</div>
)}
</div>
);
}
// ========================================
// 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 (
<div className="flex flex-col gap-1">
{showLabel && field.labelText && (
<label className="text-xs font-medium text-muted-foreground">
{field.labelText}
{field.validation?.required && (
<span className="ml-0.5 text-destructive">*</span>
)}
</label>
)}
{renderByType(field, value, handleChange, inputClassName)}
{error && <p className="text-[10px] text-destructive">{error}</p>}
</div>
);
}
// ========================================
// 서브타입별 렌더링 분기
// ========================================
function renderByType(
field: PopFieldItem,
value: unknown,
onChange: (v: unknown) => void,
className: string
) {
switch (field.inputType) {
case "text":
return (
<Input
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
readOnly={field.readOnly}
placeholder={field.placeholder}
className={className}
/>
);
case "number":
return (
<NumberFieldInput
field={field}
value={value}
onChange={onChange}
className={className}
/>
);
case "date":
return (
<Input
type="date"
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
readOnly={field.readOnly}
className={className}
/>
);
case "select":
return (
<SelectFieldInput
field={field}
value={value}
onChange={onChange}
className={className}
/>
);
case "auto":
return <AutoFieldInput field={field} value={value} className={className} />;
case "numpad":
return (
<NumpadFieldInput
field={field}
value={value}
onChange={onChange}
className={className}
/>
);
default:
return (
<Input
value={String(value ?? "")}
readOnly
className={className}
/>
);
}
}
// ========================================
// NumberFieldInput
// ========================================
function NumberFieldInput({
field,
value,
onChange,
className,
}: {
field: PopFieldItem;
value: unknown;
onChange: (v: unknown) => void;
className: string;
}) {
return (
<div className="flex items-center gap-1">
<Input
type="number"
value={value !== undefined && value !== null ? String(value) : ""}
onChange={(e) => {
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 && (
<span className="shrink-0 text-xs text-muted-foreground">
{field.unit}
</span>
)}
</div>
);
}
// ========================================
// 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<string, unknown>) => ({
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 (
<div className={cn(className, "flex items-center justify-center")}>
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
);
}
if (field.readOnly) {
const selectedLabel =
options.find((o) => o.value === String(value ?? ""))?.label ??
String(value ?? "-");
return (
<Input value={selectedLabel} readOnly className={className} />
);
}
if (!source) {
return (
<div className={cn(className, "flex items-center text-muted-foreground")}>
</div>
);
}
return (
<Select
value={String(value ?? "")}
onValueChange={(v) => onChange(v)}
>
<SelectTrigger className={cn(className, "justify-between")}>
<SelectValue placeholder={field.placeholder || "선택"} />
</SelectTrigger>
<SelectContent>
{options.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
</div>
) : (
options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
);
}
// ========================================
// 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 (
<Input
value={displayValue}
readOnly
className={cn(className, "cursor-default bg-muted text-muted-foreground")}
placeholder="자동생성"
/>
);
}
// ========================================
// AutoGenFieldDisplay (자동생성 필드 - showInForm일 때 표시)
// ========================================
function AutoGenFieldDisplay({ mapping }: { mapping: PopFieldAutoGenMapping }) {
return (
<div className="flex flex-col gap-1">
{mapping.label && (
<label className="text-xs font-medium text-muted-foreground">
{mapping.label}
</label>
)}
<div className="flex h-9 items-center rounded-md border border-dashed border-muted-foreground/30 bg-muted px-3">
<span className="text-xs text-muted-foreground">
</span>
</div>
</div>
);
}
// ========================================
// 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 (
<div className="flex items-center gap-1">
<Input
type="number"
value={displayValue}
onChange={(e) => {
const num = e.target.value === "" ? "" : Number(e.target.value);
onChange(num);
}}
readOnly={field.readOnly}
placeholder={field.placeholder || "수량 입력"}
className={cn(className, "flex-1")}
/>
{field.unit && (
<span className="shrink-0 text-xs text-muted-foreground">
{field.unit}
</span>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,83 @@
"use client";
import { PopComponentRegistry } from "../../PopComponentRegistry";
import { PopFieldComponent } from "./PopFieldComponent";
import { PopFieldConfigPanel } from "./PopFieldConfig";
import type { PopFieldConfig } from "./types";
import { DEFAULT_FIELD_CONFIG, FIELD_INPUT_TYPE_LABELS } from "./types";
function PopFieldPreviewComponent({
config,
label,
}: {
config?: PopFieldConfig;
label?: string;
}) {
const cfg: PopFieldConfig = {
...DEFAULT_FIELD_CONFIG,
...config,
sections: config?.sections?.length ? config.sections : DEFAULT_FIELD_CONFIG.sections,
};
const totalFields = cfg.sections.reduce(
(sum, s) => sum + (s.fields?.length || 0),
0
);
const sectionCount = cfg.sections.length;
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-2">
<span className="text-[10px] font-medium text-muted-foreground">
{label || "입력 필드"}
</span>
<div className="flex flex-wrap gap-1">
{cfg.sections.map((section) =>
(section.fields || []).slice(0, 3).map((field) => (
<div
key={field.id}
className="flex h-5 items-center rounded border border-dashed border-muted-foreground/30 px-1.5"
>
<span className="text-[8px] text-muted-foreground">
{field.labelText || field.fieldName || FIELD_INPUT_TYPE_LABELS[field.inputType]}
</span>
</div>
))
)}
</div>
<span className="text-[8px] text-muted-foreground">
{sectionCount} / {totalFields}
</span>
</div>
);
}
PopComponentRegistry.registerComponent({
id: "pop-field",
name: "입력 필드",
description: "저장용 값 입력 (섹션별 멀티필드, 읽기전용/입력 혼합)",
category: "input",
icon: "TextCursorInput",
component: PopFieldComponent,
configPanel: PopFieldConfigPanel,
preview: PopFieldPreviewComponent,
defaultProps: DEFAULT_FIELD_CONFIG,
connectionMeta: {
sendable: [
{
key: "value_changed",
label: "값 변경",
type: "value",
description: "필드값 변경 시 fieldName + value + allValues 전달",
},
],
receivable: [
{
key: "set_value",
label: "값 설정",
type: "value",
description: "외부에서 특정 필드 또는 일괄로 값 세팅",
},
],
},
touchOptimized: true,
supportedDevices: ["mobile", "tablet"],
});

View File

@ -0,0 +1,210 @@
/**
* pop-field
* 멀티필드: 하나의 ,
* (text/number/date/select/auto/numpad)
*/
import type { DataSourceFilter } from "../types";
// ===== 서브타입 =====
export type FieldInputType = "text" | "number" | "date" | "select" | "auto" | "numpad";
export const FIELD_INPUT_TYPE_LABELS: Record<FieldInputType, string> = {
text: "텍스트",
number: "숫자",
date: "날짜",
select: "선택",
auto: "자동채번",
numpad: "숫자패드",
};
// ===== 섹션 스타일 =====
export type FieldSectionStyle = "display" | "input";
export const FIELD_SECTION_STYLE_LABELS: Record<FieldSectionStyle, string> = {
display: "읽기 폼",
input: "입력 폼",
};
// 섹션 커스텀 외관 옵션
export interface FieldSectionAppearance {
bgColor?: string;
borderColor?: string;
textColor?: string;
}
export const DEFAULT_SECTION_APPEARANCES: Record<FieldSectionStyle, FieldSectionAppearance> = {
display: { bgColor: "bg-emerald-50", borderColor: "border-emerald-200", textColor: "text-foreground" },
input: { bgColor: "bg-background", borderColor: "border-border", textColor: "text-foreground" },
};
// ===== select 옵션 소스 =====
export type FieldSelectSourceType = "static" | "table";
export interface FieldSelectSource {
type: FieldSelectSourceType;
staticOptions?: { value: string; label: string }[];
tableName?: string;
valueColumn?: string;
labelColumn?: string;
filters?: DataSourceFilter[];
}
// ===== 자동 채번 설정 =====
export interface AutoNumberConfig {
prefix?: string;
dateFormat?: string;
separator?: string;
sequenceDigits?: number;
numberingRuleId?: string;
}
// ===== 유효성 검증 =====
export interface PopFieldValidation {
required?: boolean;
min?: number;
max?: number;
pattern?: string;
customMessage?: string;
}
// ===== 개별 필드 정의 =====
export interface PopFieldItem {
id: string;
inputType: FieldInputType;
fieldName: string;
labelText?: string;
placeholder?: string;
defaultValue?: unknown;
readOnly?: boolean;
unit?: string;
selectSource?: FieldSelectSource;
autoNumber?: AutoNumberConfig;
validation?: PopFieldValidation;
}
// ===== 섹션 정의 =====
export interface PopFieldSection {
id: string;
label?: string;
style: FieldSectionStyle;
columns: "auto" | 1 | 2 | 3 | 4;
showLabels: boolean;
appearance?: FieldSectionAppearance;
fields: PopFieldItem[];
}
// ===== 저장 설정: 값 소스 타입 =====
export type FieldValueSource = "direct" | "json_extract" | "db_column";
export const FIELD_VALUE_SOURCE_LABELS: Record<FieldValueSource, string> = {
direct: "직접 입력",
json_extract: "JSON 추출",
db_column: "DB 컬럼",
};
// ===== 저장 설정: 필드-컬럼 매핑 =====
export interface PopFieldSaveMapping {
fieldId: string;
valueSource: FieldValueSource;
targetColumn: string;
sourceJsonColumn?: string;
sourceJsonKey?: string;
}
// ===== 숨은 필드 매핑 (UI에 미표시, 전달 데이터에서 추출하여 저장) =====
export type HiddenValueSource = "json_extract" | "db_column";
export interface PopFieldHiddenMapping {
id: string;
label?: string;
valueSource: HiddenValueSource;
sourceJsonColumn?: string;
sourceJsonKey?: string;
sourceDbColumn?: string;
targetColumn: string;
}
// ===== 자동생성 필드 (서버 채번규칙으로 저장 시점 생성) =====
export interface PopFieldAutoGenMapping {
id: string;
linkedFieldId?: string;
label: string;
targetColumn: string;
numberingRuleId?: string;
showInForm: boolean;
showResultModal: boolean;
}
export interface PopFieldSaveConfig {
tableName: string;
fieldMappings: PopFieldSaveMapping[];
hiddenMappings?: PopFieldHiddenMapping[];
autoGenMappings?: PopFieldAutoGenMapping[];
}
// ===== 읽기 데이터 소스 =====
export interface PopFieldReadMapping {
fieldId: string;
valueSource: FieldValueSource;
columnName: string;
jsonKey?: string;
}
export interface PopFieldReadSource {
tableName: string;
pkColumn: string;
fieldMappings: PopFieldReadMapping[];
}
// ===== pop-field 전체 설정 (루트) =====
export interface PopFieldConfig {
targetTable?: string;
sections: PopFieldSection[];
saveConfig?: PopFieldSaveConfig;
readSource?: PopFieldReadSource;
}
// ===== 기본값 =====
export const DEFAULT_FIELD_CONFIG: PopFieldConfig = {
targetTable: "",
sections: [
{
id: "section_display",
label: "요약",
style: "display",
columns: "auto",
showLabels: true,
fields: [
{ id: "f_disp_1", inputType: "text", fieldName: "", labelText: "항목1", readOnly: true },
{ id: "f_disp_2", inputType: "text", fieldName: "", labelText: "항목2", readOnly: true },
],
},
{
id: "section_input",
label: "입력",
style: "input",
columns: "auto",
showLabels: true,
fields: [
{ id: "f_input_1", inputType: "text", fieldName: "", labelText: "필드1" },
{ id: "f_input_2", inputType: "number", fieldName: "", labelText: "필드2", unit: "EA" },
],
},
],
};