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:
SeongHyun Kim 2026-03-05 12:13:07 +09:00
parent a6c0ab5664
commit 91c9dda6ae
5 changed files with 343 additions and 29 deletions

View File

@ -19,10 +19,20 @@ interface AutoGenMappingInfo {
showResultModal?: boolean;
}
interface HiddenMappingInfo {
valueSource: "json_extract" | "db_column" | "static";
targetColumn: string;
staticValue?: string;
sourceJsonColumn?: string;
sourceJsonKey?: string;
sourceDbColumn?: string;
}
interface MappingInfo {
targetTable: string;
columnMapping: Record<string, string>;
autoGenMappings?: AutoGenMappingInfo[];
hiddenMappings?: HiddenMappingInfo[];
}
interface StatusConditionRule {
@ -122,7 +132,7 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
let processedCount = 0;
let insertedCount = 0;
const generatedCodes: Array<{ targetColumn: string; code: string }> = [];
const generatedCodes: Array<{ targetColumn: string; code: string; showResultModal?: boolean }> = [];
if (action === "inbound-confirm") {
// 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에서 코드 발급
const allAutoGen = [
...(fieldMapping?.autoGenMappings ?? []),

View File

@ -20,6 +20,7 @@ import type {
FieldSectionStyle,
PopFieldReadSource,
PopFieldAutoGenMapping,
SelectLinkedFilter,
} from "./types";
import type { CollectDataRequest, CollectedDataResponse } from "../types";
import { DEFAULT_FIELD_CONFIG, DEFAULT_SECTION_APPEARANCES } from "./types";
@ -60,6 +61,16 @@ export function PopFieldComponent({
const autoGenMappings = cfg.saveConfig?.autoGenMappings ?? [];
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로 컨테이너 너비 감시
useEffect(() => {
if (typeof window === "undefined" || !containerRef.current) return;
@ -218,6 +229,16 @@ export function PopFieldComponent({
targetColumn: m.targetColumn,
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,
};
@ -367,6 +388,8 @@ export function PopFieldComponent({
error={errors[fKey]}
onChange={handleFieldChange}
sectionStyle={section.style}
allValues={allValues}
fieldIdToName={fieldIdToName}
/>
);
})}
@ -401,6 +424,8 @@ interface FieldRendererProps {
error?: string;
onChange: (fieldName: string, value: unknown) => void;
sectionStyle: FieldSectionStyle;
allValues?: Record<string, unknown>;
fieldIdToName?: Record<string, string>;
}
function FieldRenderer({
@ -410,6 +435,8 @@ function FieldRenderer({
error,
onChange,
sectionStyle,
allValues,
fieldIdToName,
}: FieldRendererProps) {
const handleChange = useCallback(
(v: unknown) => onChange(field.fieldName, v),
@ -436,7 +463,7 @@ function FieldRenderer({
)}
</label>
)}
{renderByType(field, value, handleChange, inputClassName)}
{renderByType(field, value, handleChange, inputClassName, allValues, fieldIdToName)}
{error && <p className="text-[10px] text-destructive">{error}</p>}
</div>
);
@ -450,7 +477,9 @@ function renderByType(
field: PopFieldItem,
value: unknown,
onChange: (v: unknown) => void,
className: string
className: string,
allValues?: Record<string, unknown>,
fieldIdToName?: Record<string, string>,
) {
switch (field.inputType) {
case "text":
@ -489,6 +518,8 @@ function renderByType(
value={value}
onChange={onChange}
className={className}
allValues={allValues}
fieldIdToName={fieldIdToName}
/>
);
case "auto":
@ -561,11 +592,15 @@ function SelectFieldInput({
value,
onChange,
className,
allValues,
fieldIdToName,
}: {
field: PopFieldItem;
value: unknown;
onChange: (v: unknown) => void;
className: string;
allValues?: Record<string, unknown>;
fieldIdToName?: Record<string, string>;
}) {
const [options, setOptions] = useState<{ value: string; label: string }[]>(
[]
@ -573,6 +608,30 @@ function SelectFieldInput({
const [loading, setLoading] = useState(false);
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(() => {
if (!source) return;
@ -588,6 +647,24 @@ function SelectFieldInput({
source.valueColumn &&
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);
dataApi
.getTableData(source.tableName, {
@ -595,6 +672,7 @@ function SelectFieldInput({
size: 500,
sortBy: source.labelColumn,
sortOrder: "asc",
...(Object.keys(dynamicFilters).length > 0 ? { filters: dynamicFilters } : {}),
})
.then((res) => {
if (Array.isArray(res.data)) {
@ -614,7 +692,16 @@ function SelectFieldInput({
})
.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) {
return (
@ -641,6 +728,11 @@ function SelectFieldInput({
);
}
// W2: 연동 필터의 소스 값이 비어있으면 안내 메시지
const emptyMessage = hasLinkedFilters && !linkedFiltersFilled
? "상위 필드를 먼저 선택하세요"
: "옵션이 없습니다";
return (
<Select
value={String(value ?? "")}
@ -652,7 +744,7 @@ function SelectFieldInput({
<SelectContent>
{options.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{emptyMessage}
</div>
) : (
options.map((opt) => (

View File

@ -52,6 +52,7 @@ import type {
FieldSectionStyle,
FieldSectionAppearance,
FieldSelectSource,
SelectLinkedFilter,
AutoNumberConfig,
FieldValueSource,
PopFieldSaveMapping,
@ -213,6 +214,7 @@ export function PopFieldConfigPanel({
onUpdate={(partial) => updateSection(section.id, partial)}
onRemove={() => removeSection(section.id)}
onMoveUp={() => moveSectionUp(idx)}
allSections={cfg.sections}
/>
))}
<Button
@ -1063,6 +1065,8 @@ function SaveTabContent({
{!collapsed["hidden"] && <div className="space-y-3 border-t p-3">
{hiddenMappings.map((m) => {
const isJson = m.valueSource === "json_extract";
const isStatic = m.valueSource === "static";
const isDbColumn = m.valueSource === "db_column";
return (
<div key={m.id} className="space-y-1.5 rounded border bg-background p-2">
<div className="flex items-center gap-2">
@ -1090,6 +1094,7 @@ function SaveTabContent({
sourceDbColumn: undefined,
sourceJsonColumn: undefined,
sourceJsonKey: undefined,
staticValue: undefined,
})
}
>
@ -1099,29 +1104,36 @@ function SaveTabContent({
<SelectContent>
<SelectItem value="db_column" className="text-xs">DB </SelectItem>
<SelectItem value="json_extract" className="text-xs">JSON</SelectItem>
<SelectItem value="static" className="text-xs"></SelectItem>
</SelectContent>
</Select>
{!isJson && (
<>
<Select
value={m.sourceDbColumn || "__none__"}
onValueChange={(v) =>
updateHiddenMapping(m.id, { sourceDbColumn: v === "__none__" ? "" : v })
}
>
<SelectTrigger className="h-7 flex-1 text-xs">
<SelectValue placeholder="소스 컬럼" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs"></SelectItem>
{readColumns.map((c) => (
<SelectItem key={colName(c)} value={colName(c)} className="text-xs">
{colName(c)}
</SelectItem>
))}
</SelectContent>
</Select>
</>
{isDbColumn && (
<Select
value={m.sourceDbColumn || "__none__"}
onValueChange={(v) =>
updateHiddenMapping(m.id, { sourceDbColumn: v === "__none__" ? "" : v })
}
>
<SelectTrigger className="h-7 flex-1 text-xs">
<SelectValue placeholder="소스 컬럼" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs"></SelectItem>
{readColumns.map((c) => (
<SelectItem key={colName(c)} value={colName(c)} className="text-xs">
{colName(c)}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{isStatic && (
<Input
value={m.staticValue || ""}
onChange={(e) => updateHiddenMapping(m.id, { staticValue: e.target.value })}
placeholder="고정값 입력"
className="h-7 flex-1 text-xs"
/>
)}
</div>
{isJson && (
@ -1365,6 +1377,7 @@ interface SectionEditorProps {
onUpdate: (partial: Partial<PopFieldSection>) => void;
onRemove: () => void;
onMoveUp: () => void;
allSections: PopFieldSection[];
}
function migrateStyle(style: string): FieldSectionStyle {
@ -1381,6 +1394,7 @@ function SectionEditor({
onUpdate,
onRemove,
onMoveUp,
allSections,
}: SectionEditorProps) {
const [collapsed, setCollapsed] = useState(false);
const resolvedStyle = migrateStyle(section.style);
@ -1562,6 +1576,7 @@ function SectionEditor({
sectionStyle={resolvedStyle}
onUpdate={(partial) => updateField(field.id, partial)}
onRemove={() => removeField(field.id)}
allSections={allSections}
/>
))}
<Button
@ -1589,6 +1604,7 @@ interface FieldItemEditorProps {
sectionStyle?: FieldSectionStyle;
onUpdate: (partial: Partial<PopFieldItem>) => void;
onRemove: () => void;
allSections?: PopFieldSection[];
}
function FieldItemEditor({
@ -1596,6 +1612,7 @@ function FieldItemEditor({
sectionStyle,
onUpdate,
onRemove,
allSections,
}: FieldItemEditorProps) {
const isDisplay = sectionStyle === "display";
const [expanded, setExpanded] = useState(false);
@ -1685,9 +1702,9 @@ function FieldItemEditor({
/>
</div>
{/* 읽기전용 + 필수 (입력 폼에서만 표시) */}
{/* 읽기전용 + 필수 + 데이터 연동 (입력 폼에서만 표시) */}
{!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">
<Switch
checked={field.readOnly || false}
@ -1706,6 +1723,29 @@ function FieldItemEditor({
/>
<Label className="text-[10px]"></Label>
</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>
)}
@ -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 전용: 저장 탭에서 채번 규칙을 연결하라는 안내 */}
{field.inputType === "auto" && (
<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: 섹션 외관 설정
// ========================================

View File

@ -44,6 +44,11 @@ export const DEFAULT_SECTION_APPEARANCES: Record<FieldSectionStyle, FieldSection
export type FieldSelectSourceType = "static" | "table";
export interface SelectLinkedFilter {
sourceFieldId: string;
filterColumn: string;
}
export interface FieldSelectSource {
type: FieldSelectSourceType;
staticOptions?: { value: string; label: string }[];
@ -51,6 +56,7 @@ export interface FieldSelectSource {
valueColumn?: string;
labelColumn?: string;
filters?: DataSourceFilter[];
linkedFilters?: SelectLinkedFilter[];
}
// ===== 자동 채번 설정 =====
@ -124,7 +130,7 @@ export interface PopFieldSaveMapping {
// ===== 숨은 필드 매핑 (UI에 미표시, 전달 데이터에서 추출하여 저장) =====
export type HiddenValueSource = "json_extract" | "db_column";
export type HiddenValueSource = "json_extract" | "db_column" | "static";
export interface PopFieldHiddenMapping {
id: string;
@ -133,6 +139,7 @@ export interface PopFieldHiddenMapping {
sourceJsonColumn?: string;
sourceJsonKey?: string;
sourceDbColumn?: string;
staticValue?: string;
targetColumn: string;
}

View File

@ -643,6 +643,14 @@ export interface SaveMapping {
targetColumn: string;
showResultModal?: boolean;
}>;
hiddenMappings?: Array<{
valueSource: "json_extract" | "db_column" | "static";
targetColumn: string;
staticValue?: string;
sourceJsonColumn?: string;
sourceJsonKey?: string;
sourceDbColumn?: string;
}>;
}
export interface StatusChangeRule {