feat(pop-field): 숨은 필드 고정값 + Select 데이터 연동(linkedFilters) 구현
입고 확정 시 status/inbound_status가 빈 값으로 저장되는 문제(FIX-3)와 창고내 위치 셀렉트가 전체 위치를 보여주는 문제를 해결한다. [FIX-3: 숨은 필드 고정값] - types.ts: HiddenValueSource에 "static" 추가, staticValue 필드 - PopFieldConfig: 숨은 필드 설정 UI에 "고정값" 모드 추가 - PopFieldComponent: collected_data에 hiddenMappings 포함 - popActionRoutes: INSERT 시 hiddenMappings 값 주입 [Select 데이터 연동 - BLOCK L] - types.ts: SelectLinkedFilter 인터페이스 + FieldSelectSource.linkedFilters - PopFieldConfig: "데이터 연동" 토글 + LinkedFiltersEditor 컴포넌트 (섹션 내 필드 선택 → 필터 컬럼 매핑) - PopFieldComponent: fieldIdToName 맵으로 id-name 변환, SelectFieldInput에서 연동 필드 값 변경 시 동적 필터 재조회, 상위 미선택 시 안내 메시지, 상위 변경 시 하위 자동 초기화
This commit is contained in:
parent
a6c0ab5664
commit
91c9dda6ae
|
|
@ -19,10 +19,20 @@ interface AutoGenMappingInfo {
|
||||||
showResultModal?: boolean;
|
showResultModal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface HiddenMappingInfo {
|
||||||
|
valueSource: "json_extract" | "db_column" | "static";
|
||||||
|
targetColumn: string;
|
||||||
|
staticValue?: string;
|
||||||
|
sourceJsonColumn?: string;
|
||||||
|
sourceJsonKey?: string;
|
||||||
|
sourceDbColumn?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface MappingInfo {
|
interface MappingInfo {
|
||||||
targetTable: string;
|
targetTable: string;
|
||||||
columnMapping: Record<string, string>;
|
columnMapping: Record<string, string>;
|
||||||
autoGenMappings?: AutoGenMappingInfo[];
|
autoGenMappings?: AutoGenMappingInfo[];
|
||||||
|
hiddenMappings?: HiddenMappingInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StatusConditionRule {
|
interface StatusConditionRule {
|
||||||
|
|
@ -122,7 +132,7 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||||
|
|
||||||
let processedCount = 0;
|
let processedCount = 0;
|
||||||
let insertedCount = 0;
|
let insertedCount = 0;
|
||||||
const generatedCodes: Array<{ targetColumn: string; code: string }> = [];
|
const generatedCodes: Array<{ targetColumn: string; code: string; showResultModal?: boolean }> = [];
|
||||||
|
|
||||||
if (action === "inbound-confirm") {
|
if (action === "inbound-confirm") {
|
||||||
// 1. 매핑 기반 INSERT (장바구니 데이터 -> 대상 테이블)
|
// 1. 매핑 기반 INSERT (장바구니 데이터 -> 대상 테이블)
|
||||||
|
|
@ -153,6 +163,33 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 숨은 필드 매핑 처리 (고정값 / JSON추출 / DB컬럼)
|
||||||
|
const allHidden = [
|
||||||
|
...(fieldMapping?.hiddenMappings ?? []),
|
||||||
|
...(cardMapping?.hiddenMappings ?? []),
|
||||||
|
];
|
||||||
|
for (const hm of allHidden) {
|
||||||
|
if (!hm.targetColumn || !isSafeIdentifier(hm.targetColumn)) continue;
|
||||||
|
if (columns.includes(`"${hm.targetColumn}"`)) continue;
|
||||||
|
|
||||||
|
let value: unknown = null;
|
||||||
|
if (hm.valueSource === "static") {
|
||||||
|
value = hm.staticValue ?? null;
|
||||||
|
} else if (hm.valueSource === "json_extract" && hm.sourceJsonColumn && hm.sourceJsonKey) {
|
||||||
|
const jsonCol = item[hm.sourceJsonColumn];
|
||||||
|
if (typeof jsonCol === "object" && jsonCol !== null) {
|
||||||
|
value = (jsonCol as Record<string, unknown>)[hm.sourceJsonKey] ?? null;
|
||||||
|
} else if (typeof jsonCol === "string") {
|
||||||
|
try { value = JSON.parse(jsonCol)[hm.sourceJsonKey] ?? null; } catch { /* skip */ }
|
||||||
|
}
|
||||||
|
} else if (hm.valueSource === "db_column" && hm.sourceDbColumn) {
|
||||||
|
value = item[hm.sourceDbColumn] ?? fieldValues[hm.sourceDbColumn] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
columns.push(`"${hm.targetColumn}"`);
|
||||||
|
values.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
// 채번 규칙 실행: field + cardList의 autoGenMappings에서 코드 발급
|
// 채번 규칙 실행: field + cardList의 autoGenMappings에서 코드 발급
|
||||||
const allAutoGen = [
|
const allAutoGen = [
|
||||||
...(fieldMapping?.autoGenMappings ?? []),
|
...(fieldMapping?.autoGenMappings ?? []),
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import type {
|
||||||
FieldSectionStyle,
|
FieldSectionStyle,
|
||||||
PopFieldReadSource,
|
PopFieldReadSource,
|
||||||
PopFieldAutoGenMapping,
|
PopFieldAutoGenMapping,
|
||||||
|
SelectLinkedFilter,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import type { CollectDataRequest, CollectedDataResponse } from "../types";
|
import type { CollectDataRequest, CollectedDataResponse } from "../types";
|
||||||
import { DEFAULT_FIELD_CONFIG, DEFAULT_SECTION_APPEARANCES } from "./types";
|
import { DEFAULT_FIELD_CONFIG, DEFAULT_SECTION_APPEARANCES } from "./types";
|
||||||
|
|
@ -60,6 +61,16 @@ export function PopFieldComponent({
|
||||||
const autoGenMappings = cfg.saveConfig?.autoGenMappings ?? [];
|
const autoGenMappings = cfg.saveConfig?.autoGenMappings ?? [];
|
||||||
const visibleAutoGens = autoGenMappings.filter((m) => m.showInForm);
|
const visibleAutoGens = autoGenMappings.filter((m) => m.showInForm);
|
||||||
|
|
||||||
|
const fieldIdToName = useMemo(() => {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const section of cfg.sections) {
|
||||||
|
for (const f of section.fields ?? []) {
|
||||||
|
map[f.id] = f.fieldName || f.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [cfg.sections]);
|
||||||
|
|
||||||
// ResizeObserver로 컨테이너 너비 감시
|
// ResizeObserver로 컨테이너 너비 감시
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined" || !containerRef.current) return;
|
if (typeof window === "undefined" || !containerRef.current) return;
|
||||||
|
|
@ -218,6 +229,16 @@ export function PopFieldComponent({
|
||||||
targetColumn: m.targetColumn,
|
targetColumn: m.targetColumn,
|
||||||
showResultModal: m.showResultModal,
|
showResultModal: m.showResultModal,
|
||||||
})),
|
})),
|
||||||
|
hiddenMappings: (cfg.saveConfig.hiddenMappings || [])
|
||||||
|
.filter((m) => m.targetColumn)
|
||||||
|
.map((m) => ({
|
||||||
|
valueSource: m.valueSource,
|
||||||
|
targetColumn: m.targetColumn,
|
||||||
|
staticValue: m.staticValue,
|
||||||
|
sourceJsonColumn: m.sourceJsonColumn,
|
||||||
|
sourceJsonKey: m.sourceJsonKey,
|
||||||
|
sourceDbColumn: m.sourceDbColumn,
|
||||||
|
})),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
|
|
@ -367,6 +388,8 @@ export function PopFieldComponent({
|
||||||
error={errors[fKey]}
|
error={errors[fKey]}
|
||||||
onChange={handleFieldChange}
|
onChange={handleFieldChange}
|
||||||
sectionStyle={section.style}
|
sectionStyle={section.style}
|
||||||
|
allValues={allValues}
|
||||||
|
fieldIdToName={fieldIdToName}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -401,6 +424,8 @@ interface FieldRendererProps {
|
||||||
error?: string;
|
error?: string;
|
||||||
onChange: (fieldName: string, value: unknown) => void;
|
onChange: (fieldName: string, value: unknown) => void;
|
||||||
sectionStyle: FieldSectionStyle;
|
sectionStyle: FieldSectionStyle;
|
||||||
|
allValues?: Record<string, unknown>;
|
||||||
|
fieldIdToName?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FieldRenderer({
|
function FieldRenderer({
|
||||||
|
|
@ -410,6 +435,8 @@ function FieldRenderer({
|
||||||
error,
|
error,
|
||||||
onChange,
|
onChange,
|
||||||
sectionStyle,
|
sectionStyle,
|
||||||
|
allValues,
|
||||||
|
fieldIdToName,
|
||||||
}: FieldRendererProps) {
|
}: FieldRendererProps) {
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
(v: unknown) => onChange(field.fieldName, v),
|
(v: unknown) => onChange(field.fieldName, v),
|
||||||
|
|
@ -436,7 +463,7 @@ function FieldRenderer({
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
{renderByType(field, value, handleChange, inputClassName)}
|
{renderByType(field, value, handleChange, inputClassName, allValues, fieldIdToName)}
|
||||||
{error && <p className="text-[10px] text-destructive">{error}</p>}
|
{error && <p className="text-[10px] text-destructive">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -450,7 +477,9 @@ function renderByType(
|
||||||
field: PopFieldItem,
|
field: PopFieldItem,
|
||||||
value: unknown,
|
value: unknown,
|
||||||
onChange: (v: unknown) => void,
|
onChange: (v: unknown) => void,
|
||||||
className: string
|
className: string,
|
||||||
|
allValues?: Record<string, unknown>,
|
||||||
|
fieldIdToName?: Record<string, string>,
|
||||||
) {
|
) {
|
||||||
switch (field.inputType) {
|
switch (field.inputType) {
|
||||||
case "text":
|
case "text":
|
||||||
|
|
@ -489,6 +518,8 @@ function renderByType(
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
className={className}
|
className={className}
|
||||||
|
allValues={allValues}
|
||||||
|
fieldIdToName={fieldIdToName}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "auto":
|
case "auto":
|
||||||
|
|
@ -561,11 +592,15 @@ function SelectFieldInput({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
className,
|
className,
|
||||||
|
allValues,
|
||||||
|
fieldIdToName,
|
||||||
}: {
|
}: {
|
||||||
field: PopFieldItem;
|
field: PopFieldItem;
|
||||||
value: unknown;
|
value: unknown;
|
||||||
onChange: (v: unknown) => void;
|
onChange: (v: unknown) => void;
|
||||||
className: string;
|
className: string;
|
||||||
|
allValues?: Record<string, unknown>;
|
||||||
|
fieldIdToName?: Record<string, string>;
|
||||||
}) {
|
}) {
|
||||||
const [options, setOptions] = useState<{ value: string; label: string }[]>(
|
const [options, setOptions] = useState<{ value: string; label: string }[]>(
|
||||||
[]
|
[]
|
||||||
|
|
@ -573,6 +608,30 @@ function SelectFieldInput({
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const source = field.selectSource;
|
const source = field.selectSource;
|
||||||
|
const linkedFilters = source?.linkedFilters;
|
||||||
|
const hasLinkedFilters = !!linkedFilters?.length;
|
||||||
|
|
||||||
|
// 연동 필터에서 참조하는 필드의 현재 값들을 안정적인 문자열로 직렬화
|
||||||
|
const linkedFilterKey = useMemo(() => {
|
||||||
|
if (!hasLinkedFilters || !allValues || !fieldIdToName) return "";
|
||||||
|
return linkedFilters!
|
||||||
|
.map((lf) => {
|
||||||
|
const fieldName = fieldIdToName[lf.sourceFieldId] ?? lf.sourceFieldId;
|
||||||
|
const val = allValues[fieldName] ?? "";
|
||||||
|
return `${lf.filterColumn}=${String(val)}`;
|
||||||
|
})
|
||||||
|
.join("&");
|
||||||
|
}, [hasLinkedFilters, linkedFilters, allValues, fieldIdToName]);
|
||||||
|
|
||||||
|
// 연동 필터의 소스 값이 모두 채워졌는지 확인
|
||||||
|
const linkedFiltersFilled = useMemo(() => {
|
||||||
|
if (!hasLinkedFilters || !allValues || !fieldIdToName) return true;
|
||||||
|
return linkedFilters!.every((lf) => {
|
||||||
|
const fieldName = fieldIdToName[lf.sourceFieldId] ?? lf.sourceFieldId;
|
||||||
|
const val = allValues[fieldName];
|
||||||
|
return val != null && val !== "";
|
||||||
|
});
|
||||||
|
}, [hasLinkedFilters, linkedFilters, allValues, fieldIdToName]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!source) return;
|
if (!source) return;
|
||||||
|
|
@ -588,6 +647,24 @@ function SelectFieldInput({
|
||||||
source.valueColumn &&
|
source.valueColumn &&
|
||||||
source.labelColumn
|
source.labelColumn
|
||||||
) {
|
) {
|
||||||
|
// 연동 필터가 있는데 소스 값이 비어있으면 빈 옵션 표시
|
||||||
|
if (hasLinkedFilters && !linkedFiltersFilled) {
|
||||||
|
setOptions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 동적 필터 구성
|
||||||
|
const dynamicFilters: Record<string, string> = {};
|
||||||
|
if (hasLinkedFilters && allValues && fieldIdToName) {
|
||||||
|
for (const lf of linkedFilters!) {
|
||||||
|
const fieldName = fieldIdToName[lf.sourceFieldId] ?? lf.sourceFieldId;
|
||||||
|
const val = allValues[fieldName];
|
||||||
|
if (val != null && val !== "" && lf.filterColumn) {
|
||||||
|
dynamicFilters[lf.filterColumn] = String(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
dataApi
|
dataApi
|
||||||
.getTableData(source.tableName, {
|
.getTableData(source.tableName, {
|
||||||
|
|
@ -595,6 +672,7 @@ function SelectFieldInput({
|
||||||
size: 500,
|
size: 500,
|
||||||
sortBy: source.labelColumn,
|
sortBy: source.labelColumn,
|
||||||
sortOrder: "asc",
|
sortOrder: "asc",
|
||||||
|
...(Object.keys(dynamicFilters).length > 0 ? { filters: dynamicFilters } : {}),
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (Array.isArray(res.data)) {
|
if (Array.isArray(res.data)) {
|
||||||
|
|
@ -614,7 +692,16 @@ function SelectFieldInput({
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}
|
}
|
||||||
}, [source?.type, source?.tableName, source?.valueColumn, source?.labelColumn, source?.staticOptions]);
|
}, [source?.type, source?.tableName, source?.valueColumn, source?.labelColumn, source?.staticOptions, linkedFilterKey, linkedFiltersFilled]);
|
||||||
|
|
||||||
|
// W3: 옵션이 바뀌었을 때 현재 선택값이 유효하지 않으면 자동 초기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasLinkedFilters || !value || loading) return;
|
||||||
|
const currentStr = String(value);
|
||||||
|
if (options.length > 0 && !options.some((o) => o.value === currentStr)) {
|
||||||
|
onChange("");
|
||||||
|
}
|
||||||
|
}, [options, hasLinkedFilters]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -641,6 +728,11 @@ function SelectFieldInput({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// W2: 연동 필터의 소스 값이 비어있으면 안내 메시지
|
||||||
|
const emptyMessage = hasLinkedFilters && !linkedFiltersFilled
|
||||||
|
? "상위 필드를 먼저 선택하세요"
|
||||||
|
: "옵션이 없습니다";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
value={String(value ?? "")}
|
value={String(value ?? "")}
|
||||||
|
|
@ -652,7 +744,7 @@ function SelectFieldInput({
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{options.length === 0 ? (
|
{options.length === 0 ? (
|
||||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||||
옵션이 없습니다
|
{emptyMessage}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
options.map((opt) => (
|
options.map((opt) => (
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ import type {
|
||||||
FieldSectionStyle,
|
FieldSectionStyle,
|
||||||
FieldSectionAppearance,
|
FieldSectionAppearance,
|
||||||
FieldSelectSource,
|
FieldSelectSource,
|
||||||
|
SelectLinkedFilter,
|
||||||
AutoNumberConfig,
|
AutoNumberConfig,
|
||||||
FieldValueSource,
|
FieldValueSource,
|
||||||
PopFieldSaveMapping,
|
PopFieldSaveMapping,
|
||||||
|
|
@ -213,6 +214,7 @@ export function PopFieldConfigPanel({
|
||||||
onUpdate={(partial) => updateSection(section.id, partial)}
|
onUpdate={(partial) => updateSection(section.id, partial)}
|
||||||
onRemove={() => removeSection(section.id)}
|
onRemove={() => removeSection(section.id)}
|
||||||
onMoveUp={() => moveSectionUp(idx)}
|
onMoveUp={() => moveSectionUp(idx)}
|
||||||
|
allSections={cfg.sections}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -1063,6 +1065,8 @@ function SaveTabContent({
|
||||||
{!collapsed["hidden"] && <div className="space-y-3 border-t p-3">
|
{!collapsed["hidden"] && <div className="space-y-3 border-t p-3">
|
||||||
{hiddenMappings.map((m) => {
|
{hiddenMappings.map((m) => {
|
||||||
const isJson = m.valueSource === "json_extract";
|
const isJson = m.valueSource === "json_extract";
|
||||||
|
const isStatic = m.valueSource === "static";
|
||||||
|
const isDbColumn = m.valueSource === "db_column";
|
||||||
return (
|
return (
|
||||||
<div key={m.id} className="space-y-1.5 rounded border bg-background p-2">
|
<div key={m.id} className="space-y-1.5 rounded border bg-background p-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -1090,6 +1094,7 @@ function SaveTabContent({
|
||||||
sourceDbColumn: undefined,
|
sourceDbColumn: undefined,
|
||||||
sourceJsonColumn: undefined,
|
sourceJsonColumn: undefined,
|
||||||
sourceJsonKey: undefined,
|
sourceJsonKey: undefined,
|
||||||
|
staticValue: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -1099,10 +1104,10 @@ function SaveTabContent({
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="db_column" className="text-xs">DB 컬럼</SelectItem>
|
<SelectItem value="db_column" className="text-xs">DB 컬럼</SelectItem>
|
||||||
<SelectItem value="json_extract" className="text-xs">JSON</SelectItem>
|
<SelectItem value="json_extract" className="text-xs">JSON</SelectItem>
|
||||||
|
<SelectItem value="static" className="text-xs">고정값</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{!isJson && (
|
{isDbColumn && (
|
||||||
<>
|
|
||||||
<Select
|
<Select
|
||||||
value={m.sourceDbColumn || "__none__"}
|
value={m.sourceDbColumn || "__none__"}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
|
|
@ -1121,7 +1126,14 @@ function SaveTabContent({
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</>
|
)}
|
||||||
|
{isStatic && (
|
||||||
|
<Input
|
||||||
|
value={m.staticValue || ""}
|
||||||
|
onChange={(e) => updateHiddenMapping(m.id, { staticValue: e.target.value })}
|
||||||
|
placeholder="고정값 입력"
|
||||||
|
className="h-7 flex-1 text-xs"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isJson && (
|
{isJson && (
|
||||||
|
|
@ -1365,6 +1377,7 @@ interface SectionEditorProps {
|
||||||
onUpdate: (partial: Partial<PopFieldSection>) => void;
|
onUpdate: (partial: Partial<PopFieldSection>) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onMoveUp: () => void;
|
onMoveUp: () => void;
|
||||||
|
allSections: PopFieldSection[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function migrateStyle(style: string): FieldSectionStyle {
|
function migrateStyle(style: string): FieldSectionStyle {
|
||||||
|
|
@ -1381,6 +1394,7 @@ function SectionEditor({
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onRemove,
|
onRemove,
|
||||||
onMoveUp,
|
onMoveUp,
|
||||||
|
allSections,
|
||||||
}: SectionEditorProps) {
|
}: SectionEditorProps) {
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const resolvedStyle = migrateStyle(section.style);
|
const resolvedStyle = migrateStyle(section.style);
|
||||||
|
|
@ -1562,6 +1576,7 @@ function SectionEditor({
|
||||||
sectionStyle={resolvedStyle}
|
sectionStyle={resolvedStyle}
|
||||||
onUpdate={(partial) => updateField(field.id, partial)}
|
onUpdate={(partial) => updateField(field.id, partial)}
|
||||||
onRemove={() => removeField(field.id)}
|
onRemove={() => removeField(field.id)}
|
||||||
|
allSections={allSections}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -1589,6 +1604,7 @@ interface FieldItemEditorProps {
|
||||||
sectionStyle?: FieldSectionStyle;
|
sectionStyle?: FieldSectionStyle;
|
||||||
onUpdate: (partial: Partial<PopFieldItem>) => void;
|
onUpdate: (partial: Partial<PopFieldItem>) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
|
allSections?: PopFieldSection[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function FieldItemEditor({
|
function FieldItemEditor({
|
||||||
|
|
@ -1596,6 +1612,7 @@ function FieldItemEditor({
|
||||||
sectionStyle,
|
sectionStyle,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
allSections,
|
||||||
}: FieldItemEditorProps) {
|
}: FieldItemEditorProps) {
|
||||||
const isDisplay = sectionStyle === "display";
|
const isDisplay = sectionStyle === "display";
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
@ -1685,9 +1702,9 @@ function FieldItemEditor({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 읽기전용 + 필수 (입력 폼에서만 표시) */}
|
{/* 읽기전용 + 필수 + 데이터 연동 (입력 폼에서만 표시) */}
|
||||||
{!isDisplay && (
|
{!isDisplay && (
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Switch
|
<Switch
|
||||||
checked={field.readOnly || false}
|
checked={field.readOnly || false}
|
||||||
|
|
@ -1706,6 +1723,29 @@ function FieldItemEditor({
|
||||||
/>
|
/>
|
||||||
<Label className="text-[10px]">필수</Label>
|
<Label className="text-[10px]">필수</Label>
|
||||||
</div>
|
</div>
|
||||||
|
{field.inputType === "select" && field.selectSource?.type === "table" && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Switch
|
||||||
|
checked={!!field.selectSource?.linkedFilters?.length}
|
||||||
|
onCheckedChange={(v) => {
|
||||||
|
const src = field.selectSource ?? { type: "table" as const };
|
||||||
|
if (v) {
|
||||||
|
onUpdate({
|
||||||
|
selectSource: {
|
||||||
|
...src,
|
||||||
|
linkedFilters: [{ sourceFieldId: "", filterColumn: "" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
onUpdate({
|
||||||
|
selectSource: { ...src, linkedFilters: undefined },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label className="text-[10px]">데이터 연동</Label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -1730,6 +1770,24 @@ function FieldItemEditor({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* select + table + 연동 필터 활성화 시 */}
|
||||||
|
{field.inputType === "select" &&
|
||||||
|
field.selectSource?.type === "table" &&
|
||||||
|
field.selectSource?.linkedFilters &&
|
||||||
|
field.selectSource.linkedFilters.length > 0 && (
|
||||||
|
<LinkedFiltersEditor
|
||||||
|
linkedFilters={field.selectSource.linkedFilters}
|
||||||
|
tableName={field.selectSource.tableName || ""}
|
||||||
|
currentFieldId={field.id}
|
||||||
|
allSections={allSections || []}
|
||||||
|
onUpdate={(filters) =>
|
||||||
|
onUpdate({
|
||||||
|
selectSource: { ...field.selectSource!, linkedFilters: filters },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* auto 전용: 저장 탭에서 채번 규칙을 연결하라는 안내 */}
|
{/* auto 전용: 저장 탭에서 채번 규칙을 연결하라는 안내 */}
|
||||||
{field.inputType === "auto" && (
|
{field.inputType === "auto" && (
|
||||||
<div className="rounded border bg-muted/30 p-2">
|
<div className="rounded border bg-muted/30 p-2">
|
||||||
|
|
@ -2072,6 +2130,118 @@ function JsonKeySelect({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// LinkedFiltersEditor: 데이터 연동 필터 설정
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
function LinkedFiltersEditor({
|
||||||
|
linkedFilters,
|
||||||
|
tableName,
|
||||||
|
currentFieldId,
|
||||||
|
allSections,
|
||||||
|
onUpdate,
|
||||||
|
}: {
|
||||||
|
linkedFilters: SelectLinkedFilter[];
|
||||||
|
tableName: string;
|
||||||
|
currentFieldId: string;
|
||||||
|
allSections: PopFieldSection[];
|
||||||
|
onUpdate: (filters: SelectLinkedFilter[]) => void;
|
||||||
|
}) {
|
||||||
|
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tableName) {
|
||||||
|
fetchTableColumns(tableName).then(setColumns);
|
||||||
|
} else {
|
||||||
|
setColumns([]);
|
||||||
|
}
|
||||||
|
}, [tableName]);
|
||||||
|
|
||||||
|
const candidateFields = useMemo(() => {
|
||||||
|
return allSections.flatMap((sec) =>
|
||||||
|
(sec.fields ?? [])
|
||||||
|
.filter((f) => f.id !== currentFieldId)
|
||||||
|
.map((f) => ({ id: f.id, label: f.labelText || f.fieldName || f.id, sectionLabel: sec.label }))
|
||||||
|
);
|
||||||
|
}, [allSections, currentFieldId]);
|
||||||
|
|
||||||
|
const updateFilter = (idx: number, partial: Partial<SelectLinkedFilter>) => {
|
||||||
|
const next = linkedFilters.map((f, i) => (i === idx ? { ...f, ...partial } : f));
|
||||||
|
onUpdate(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFilter = (idx: number) => {
|
||||||
|
const next = linkedFilters.filter((_, i) => i !== idx);
|
||||||
|
onUpdate(next.length > 0 ? next : [{ sourceFieldId: "", filterColumn: "" }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addFilter = () => {
|
||||||
|
onUpdate([...linkedFilters, { sourceFieldId: "", filterColumn: "" }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 rounded border bg-muted/30 p-2">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">데이터 연동</Label>
|
||||||
|
{linkedFilters.map((lf, idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-1">
|
||||||
|
<Select
|
||||||
|
value={lf.sourceFieldId || "__none__"}
|
||||||
|
onValueChange={(v) => updateFilter(idx, { sourceFieldId: v === "__none__" ? "" : v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 flex-1 text-xs">
|
||||||
|
<SelectValue placeholder="연동 필드" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__" className="text-xs">필드 선택</SelectItem>
|
||||||
|
{candidateFields.map((cf) => (
|
||||||
|
<SelectItem key={cf.id} value={cf.id} className="text-xs">
|
||||||
|
{cf.sectionLabel ? `[${cf.sectionLabel}] ` : ""}{cf.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span className="text-[10px] text-muted-foreground">=</span>
|
||||||
|
<Select
|
||||||
|
value={lf.filterColumn || "__none__"}
|
||||||
|
onValueChange={(v) => updateFilter(idx, { filterColumn: v === "__none__" ? "" : v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 flex-1 text-xs">
|
||||||
|
<SelectValue placeholder="필터 컬럼" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__" className="text-xs">컬럼 선택</SelectItem>
|
||||||
|
{columns.map((c) => (
|
||||||
|
<SelectItem key={c.name} value={c.name} className="text-xs">
|
||||||
|
{c.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{linkedFilters.length > 1 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5 shrink-0 text-destructive"
|
||||||
|
onClick={() => removeFilter(idx)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-full text-[10px]"
|
||||||
|
onClick={addFilter}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
필터 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// AppearanceEditor: 섹션 외관 설정
|
// AppearanceEditor: 섹션 외관 설정
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,11 @@ export const DEFAULT_SECTION_APPEARANCES: Record<FieldSectionStyle, FieldSection
|
||||||
|
|
||||||
export type FieldSelectSourceType = "static" | "table";
|
export type FieldSelectSourceType = "static" | "table";
|
||||||
|
|
||||||
|
export interface SelectLinkedFilter {
|
||||||
|
sourceFieldId: string;
|
||||||
|
filterColumn: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FieldSelectSource {
|
export interface FieldSelectSource {
|
||||||
type: FieldSelectSourceType;
|
type: FieldSelectSourceType;
|
||||||
staticOptions?: { value: string; label: string }[];
|
staticOptions?: { value: string; label: string }[];
|
||||||
|
|
@ -51,6 +56,7 @@ export interface FieldSelectSource {
|
||||||
valueColumn?: string;
|
valueColumn?: string;
|
||||||
labelColumn?: string;
|
labelColumn?: string;
|
||||||
filters?: DataSourceFilter[];
|
filters?: DataSourceFilter[];
|
||||||
|
linkedFilters?: SelectLinkedFilter[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 자동 채번 설정 =====
|
// ===== 자동 채번 설정 =====
|
||||||
|
|
@ -124,7 +130,7 @@ export interface PopFieldSaveMapping {
|
||||||
|
|
||||||
// ===== 숨은 필드 매핑 (UI에 미표시, 전달 데이터에서 추출하여 저장) =====
|
// ===== 숨은 필드 매핑 (UI에 미표시, 전달 데이터에서 추출하여 저장) =====
|
||||||
|
|
||||||
export type HiddenValueSource = "json_extract" | "db_column";
|
export type HiddenValueSource = "json_extract" | "db_column" | "static";
|
||||||
|
|
||||||
export interface PopFieldHiddenMapping {
|
export interface PopFieldHiddenMapping {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -133,6 +139,7 @@ export interface PopFieldHiddenMapping {
|
||||||
sourceJsonColumn?: string;
|
sourceJsonColumn?: string;
|
||||||
sourceJsonKey?: string;
|
sourceJsonKey?: string;
|
||||||
sourceDbColumn?: string;
|
sourceDbColumn?: string;
|
||||||
|
staticValue?: string;
|
||||||
targetColumn: string;
|
targetColumn: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -643,6 +643,14 @@ export interface SaveMapping {
|
||||||
targetColumn: string;
|
targetColumn: string;
|
||||||
showResultModal?: boolean;
|
showResultModal?: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
hiddenMappings?: Array<{
|
||||||
|
valueSource: "json_extract" | "db_column" | "static";
|
||||||
|
targetColumn: string;
|
||||||
|
staticValue?: string;
|
||||||
|
sourceJsonColumn?: string;
|
||||||
|
sourceJsonKey?: string;
|
||||||
|
sourceDbColumn?: string;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StatusChangeRule {
|
export interface StatusChangeRule {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue