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:
parent
0ca031282b
commit
7bf20bda14
|
|
@ -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/
|
||||||
|
|
@ -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: "저장용 값 입력 (섹션별 멀티필드)",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// 드래그 가능한 컴포넌트 아이템
|
// 드래그 가능한 컴포넌트 아이템
|
||||||
|
|
|
||||||
|
|
@ -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": "입력",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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"],
|
||||||
|
});
|
||||||
|
|
@ -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" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue